More work on editor and messages
This commit is contained in:
parent
19b688f7fd
commit
8abc0c0148
12 changed files with 548 additions and 147 deletions
19
design.md
19
design.md
|
@ -1,19 +0,0 @@
|
|||
# design notes
|
||||
|
||||
## colors
|
||||
|
||||
- text:
|
||||
- text dim:
|
||||
- text bright:
|
||||
- background:
|
||||
- background-dim:
|
||||
- background-dim-2:
|
||||
- background-dim-3:
|
||||
- accent:
|
||||
- link:
|
||||
|
||||
## fonts
|
||||
|
||||
- main: Atkinson Hyperlegible
|
||||
- display:
|
||||
- monospace:
|
|
@ -28,7 +28,8 @@
|
|||
"prosemirror-view": "^1.32.6",
|
||||
"sdk": "link:../sdk-ts",
|
||||
"solid-floating-ui": "^0.2.1",
|
||||
"solid-js": "^1.8.7"
|
||||
"solid-js": "^1.8.7",
|
||||
"xss": "^1.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "^1.69.5",
|
||||
|
|
|
@ -65,6 +65,9 @@ dependencies:
|
|||
solid-js:
|
||||
specifier: ^1.8.7
|
||||
version: 1.8.7
|
||||
xss:
|
||||
specifier: ^1.0.14
|
||||
version: 1.0.14
|
||||
|
||||
devDependencies:
|
||||
sass:
|
||||
|
@ -951,10 +954,18 @@ packages:
|
|||
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
|
||||
dev: true
|
||||
|
||||
/commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
dev: false
|
||||
|
||||
/convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
dev: true
|
||||
|
||||
/cssfilter@0.0.10:
|
||||
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
|
||||
dev: false
|
||||
|
||||
/csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
|
@ -1437,6 +1448,15 @@ packages:
|
|||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
dev: false
|
||||
|
||||
/xss@1.0.14:
|
||||
resolution: {integrity: sha512-og7TEJhXvn1a7kzZGQ7ETjdQVS2UfZyTlsEdDOqvQF7GoxNfY+0YLCzBy1kPdsDDx4QuNAonQPddpsn6Xl/7sw==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
cssfilter: 0.0.10
|
||||
dev: false
|
||||
|
||||
/yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
dev: true
|
||||
|
|
|
@ -21,13 +21,11 @@
|
|||
color: #777;
|
||||
}
|
||||
|
||||
& .link {
|
||||
color: var(--foreground-link);
|
||||
}
|
||||
|
||||
& .placeholder {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
& code, pre {
|
||||
font-family: "Iosevka Zesty", 'Courier New', Courier, monospace;
|
||||
background: var(--background-3);
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
|
|
154
src/Editor.tsx
154
src/Editor.tsx
|
@ -1,9 +1,10 @@
|
|||
import { EditorState } from "prosemirror-state";
|
||||
import { Command, EditorState, Selection, TextSelection, Transaction } from "prosemirror-state";
|
||||
import { EditorView, Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { Schema, Slice } from "prosemirror-model";
|
||||
import { history, undo, redo } from "prosemirror-history";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { autocomplete } from "prosemirror-autocomplete";
|
||||
import { DOMParser } from "prosemirror-model";
|
||||
|
||||
import "./Editor.scss";
|
||||
import { For, VoidProps, createSignal, onCleanup, onMount } from "solid-js";
|
||||
|
@ -47,7 +48,7 @@ function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) {
|
|||
)
|
||||
}
|
||||
|
||||
export default function Editor(props: VoidProps<{ onSubmit: (text: string) => void }>) {
|
||||
export default function Editor(props: VoidProps<{ onSubmit: (data: { text: string, html: string }) => void }>) {
|
||||
let editorEl: HTMLDivElement;
|
||||
|
||||
const [autocompleteOptions, setAutocompleteOptions] = createSignal<null | Array<string>>(null);
|
||||
|
@ -56,9 +57,41 @@ export default function Editor(props: VoidProps<{ onSubmit: (text: string) => vo
|
|||
"@foobaz:celery.eu.org",
|
||||
"@foobarbaz:celery.eu.org",
|
||||
];
|
||||
|
||||
function createWrap(wrap: string): Command {
|
||||
const len = wrap.length;
|
||||
|
||||
return (state, dispatch) => {
|
||||
const { from, to } = state.selection;
|
||||
const tr = state.tr;
|
||||
|
||||
const isWrapped = ((
|
||||
tr.doc.textBetween(from - len, from) === wrap &&
|
||||
tr.doc.textBetween(to, to + len) === wrap
|
||||
) || (
|
||||
false
|
||||
// FIXME: fails?
|
||||
// tr.doc.textBetween(from, from + len) === wrap &&
|
||||
// tr.doc.textBetween(to - len, to) === wrap
|
||||
));
|
||||
|
||||
if (isWrapped) {
|
||||
tr.delete(to, to + len);
|
||||
tr.delete(from - len, from);
|
||||
} else {
|
||||
tr.insertText(wrap, to);
|
||||
tr.insertText(wrap, from);
|
||||
tr.setSelection(TextSelection.create(tr.doc, from + len, to + len));
|
||||
}
|
||||
|
||||
dispatch?.(tr);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const view = new EditorView({ mount: editorEl }, {
|
||||
domParser: DOMParser.fromSchema(schema),
|
||||
state: EditorState.create({
|
||||
schema,
|
||||
plugins: [
|
||||
|
@ -100,20 +133,23 @@ export default function Editor(props: VoidProps<{ onSubmit: (text: string) => vo
|
|||
"Ctrl-z": undo,
|
||||
"Ctrl-Shift-z": redo,
|
||||
"Ctrl-y": redo,
|
||||
"Ctrl-b": createWrap("**"),
|
||||
"Ctrl-i": createWrap("*"),
|
||||
"Ctrl-`": createWrap("`"),
|
||||
"Shift-Enter": (state, dispatch) => {
|
||||
dispatch?.(state.tr.insertText("\n"));
|
||||
return true;
|
||||
},
|
||||
"Enter": (state, dispatch) => {
|
||||
if (state.doc.textContent.startsWith("---")) {
|
||||
dispatch?.(state.tr.insertText("\n"));
|
||||
} else {
|
||||
// TODO: render properly?
|
||||
props.onSubmit(state.doc.textContent);
|
||||
// state.selection.content().content.firstChild?.type.name === "code";
|
||||
// console.log("send", state.toJSON());
|
||||
dispatch?.(state.tr.deleteRange(0, state.doc.nodeSize - 2));
|
||||
}
|
||||
console.log({
|
||||
text: state.doc.textContent,
|
||||
html: marked(state.doc.textContent, { breaks: true }) as string,
|
||||
})
|
||||
props.onSubmit({
|
||||
text: state.doc.textContent.trim(),
|
||||
html: (marked(state.doc.textContent, { breaks: true }) as string).trim(),
|
||||
});
|
||||
dispatch?.(state.tr.deleteRange(0, state.doc.nodeSize - 2));
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
|
@ -130,50 +166,110 @@ export default function Editor(props: VoidProps<{ onSubmit: (text: string) => vo
|
|||
let pos = 0;
|
||||
const walk = (ast: Token) => {
|
||||
switch (ast.type) {
|
||||
case "text": {
|
||||
if ("tokens" in ast) {
|
||||
ast.tokens?.forEach(walk);
|
||||
return;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
case "em": {
|
||||
const end = pos + ast.raw.length + 1;
|
||||
decorations.push(Decoration.inline(pos, pos + 2, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + 1, pos + 2, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + 2, end - 1, { nodeName: "em" }));
|
||||
decorations.push(Decoration.inline(end - 1, end, { class: "syn" }));
|
||||
break;
|
||||
}
|
||||
case "strong": {
|
||||
const end = pos + ast.raw.length + 1;
|
||||
decorations.push(Decoration.inline(pos, pos + 3, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + 1, pos + 3, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + 3, end - 2, { nodeName: "bold" }));
|
||||
decorations.push(Decoration.inline(end - 2, end, { class: "syn" }));
|
||||
break;
|
||||
}
|
||||
case "link": {
|
||||
if (ast.raw === ast.href) {
|
||||
const hrefLen = ast.href.length;
|
||||
decorations.push(Decoration.inline(pos, pos + hrefLen + 1, { class: "link" }));
|
||||
} else {
|
||||
const textLen = ast.text.length;
|
||||
const hrefLen = ast.href.length;
|
||||
decorations.push(Decoration.inline(pos + 1, pos + 2, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + textLen + 2, pos + textLen + 4, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + textLen + 4, pos + textLen + hrefLen + 4, { class: "link" }));
|
||||
decorations.push(Decoration.inline(pos + textLen + hrefLen + 4, pos + textLen + hrefLen + 5, { class: "syn" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "codespan": {
|
||||
const end = pos + ast.raw.length + 1;
|
||||
decorations.push(Decoration.inline(pos, pos + 2, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + 1, pos + 2, { class: "syn" }));
|
||||
decorations.push(Decoration.inline(pos + 2, end - 1, { nodeName: "code" }));
|
||||
decorations.push(Decoration.inline(end - 1, end, { class: "syn" }));
|
||||
break;
|
||||
}
|
||||
case "code": {
|
||||
// const headIdx = ast.raw.indexOf("\n");
|
||||
// const tailIdx = ast.raw.lastIndexOf("\n");
|
||||
// const hasTail = ast.raw.endsWith("```");
|
||||
// decorations.push(Decoration.inline(pos, headIdx === -1 ? ast.raw.length + pos + 1 : headIdx + pos + 1, { class: "syn" }));
|
||||
// if (hasTail) {
|
||||
// decorations.push(Decoration.inline(headIdx + pos + 2, tailIdx + pos + 1, { nodeName: "code" }));
|
||||
// decorations.push(Decoration.inline(tailIdx + pos + 1, pos + ast.raw.length + 1, { class: "syn" }));
|
||||
// } else if (headIdx !== -1) {
|
||||
// decorations.push(Decoration.inline(headIdx + pos + 2, ast.raw.length + pos + 1, { nodeName: "code" }));
|
||||
// }
|
||||
// decorations.push(Decoration.node(pos, ast.raw.length + pos + 1, { nodeName: "pre" }));
|
||||
break;
|
||||
}
|
||||
case "paragraph": ast.tokens?.forEach(walk); return;
|
||||
case "blockquote": {
|
||||
const synLen = ast.raw.length - ast.text.length;
|
||||
decorations.push(Decoration.inline(pos, pos + synLen, { class: "syn" }));
|
||||
pos += synLen;
|
||||
ast.tokens?.forEach(walk);
|
||||
return;
|
||||
}
|
||||
// case "list": {
|
||||
// console.log(ast);
|
||||
// for (const item of ast.items) {
|
||||
// const synLen = item.raw.length - item.text.length;
|
||||
// pos += synLen;
|
||||
// item.tokens.forEach(walk);
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// default: console.log(ast);
|
||||
}
|
||||
pos += ast.raw.length;
|
||||
};
|
||||
|
||||
marked.lexer(state.doc.textContent).forEach(walk);
|
||||
marked.lexer(state.doc.textContent, { breaks: true }).forEach(walk);
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
},
|
||||
handlePaste(view, _event, slice) {
|
||||
const str = slice.content.textBetween(0, slice.size);
|
||||
const tr = view.state.tr;
|
||||
if (/^(https?:\/\/|mailto:)\S+$/i.test(str) && !tr.selection.empty) {
|
||||
tr.insertText("[", tr.selection.from);
|
||||
tr.insertText(`](${str})`, tr.selection.to);
|
||||
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to - 1));
|
||||
view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"));
|
||||
} else {
|
||||
tr.replaceSelection(slice);
|
||||
view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste"));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
transformPastedHTML(html) {
|
||||
const tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
const replacements: Array<[string, (el: HTMLElement) => string]> = [
|
||||
["b, bold, strong, [style*='font-weight:700']", (el) => `**${el.textContent}**`],
|
||||
["em, i, [style*='font-style:italic']", (el) => `*${el.textContent}*`],
|
||||
["a", (el) => `[${el.textContent}](${el.getAttribute("href")})`],
|
||||
["code", (el) => `\`${el.textContent}\``],
|
||||
...[1, 2, 3, 4, 5, 6].map((level) => ["h" + level, (el: HTMLElement) => `${"#".repeat(level)} ${el.textContent}`]) as Array<any>,
|
||||
["ul", (el) => [...el.children].map(i => "- " + i.textContent).join("\n")],
|
||||
["ol", (el) => [...el.children].map((i, x) => (x + 1) + ". " + i.textContent).join("\n")],
|
||||
["br", () => "\n"],
|
||||
["p, span, div", (el) => el.textContent || ""],
|
||||
];
|
||||
for (const [selector, replacement] of replacements) {
|
||||
for (const el of tmp.querySelectorAll(selector)) {
|
||||
el.replaceWith(replacement(el as HTMLElement));
|
||||
}
|
||||
}
|
||||
console.log({ html, md: tmp.innerHTML });
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
});
|
||||
view.focus();
|
||||
onCleanup(() => view.destroy());
|
||||
|
|
21
src/Main.tsx
21
src/Main.tsx
|
@ -25,7 +25,11 @@ export default function App(): JSX.Element {
|
|||
if (!globals.client.lists.has("rooms")) {
|
||||
globals.client.lists.subscribe("rooms", {
|
||||
ranges: [[0, 99999]],
|
||||
required_state: [["m.room.name", ""], ["m.room.topic", ""]],
|
||||
required_state: [
|
||||
["m.room.name", ""],
|
||||
["m.room.topic", ""],
|
||||
["m.room.member", globals.client.config.userId],
|
||||
],
|
||||
// timeline_limit: 3,
|
||||
} as any);
|
||||
globals.client.lists.subscribe("requests", {
|
||||
|
@ -108,17 +112,24 @@ export function RoomView(props: VoidProps<{ room: Room }>) {
|
|||
}
|
||||
|
||||
function Threads(props: VoidProps<{ room: Room }>) {
|
||||
const [threadChunk] = createResource(() => props.room, async (room: Room) => {
|
||||
const [paginator, { mutate }] = createResource(() => props.room, async (room: Room) => {
|
||||
console.log("fetch room threads")
|
||||
return room.threads.paginate();
|
||||
}, {
|
||||
storage: (init) => createSignal(init, { equals: false}),
|
||||
});
|
||||
|
||||
|
||||
const threads = () => paginator()?.list ?? [];
|
||||
|
||||
return (
|
||||
<div class="timeline threads">
|
||||
<div class="items">
|
||||
<RoomHeader room={props.room} />
|
||||
<Show when={!threadChunk.loading}>
|
||||
<For each={threadChunk()!.threads}>
|
||||
<Show when={!paginator.loading}>
|
||||
<Show when={!paginator()?.isAtEnd}>
|
||||
<button onClick={() => paginator()?.paginate().then(() => mutate(paginator))}>load more</button>
|
||||
</Show>
|
||||
<For each={threads()}>
|
||||
{(thread: Thread) => <ThreadsItem thread={thread} />}
|
||||
</For>
|
||||
</Show>
|
||||
|
|
|
@ -52,7 +52,7 @@ export function Menu(props: ParentProps) {
|
|||
|
||||
export function Submenu(props: ParentProps<{ content: JSX.Element, onClick?: (e: MouseEvent) => void }>) {
|
||||
const [itemEl, setItemEl] = createSignal<Element | undefined>();
|
||||
const [subEl, setSubEl] = createSignal<Element | undefined>();
|
||||
const [subEl, setSubEl] = createSignal<HTMLElement | undefined>();
|
||||
const dims = useFloating(itemEl, subEl, {
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [flip()],
|
||||
|
|
119
src/Message.scss
119
src/Message.scss
|
@ -1,6 +1,16 @@
|
|||
.message {
|
||||
contain: content;
|
||||
// padding: 4px;
|
||||
|
||||
& .content {
|
||||
& p, li {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
& ul, li {
|
||||
list-style-position: inside;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
--name-width: 144px;
|
||||
|
@ -59,11 +69,112 @@
|
|||
}
|
||||
}
|
||||
|
||||
& a {
|
||||
color: #822eba;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #00000022;
|
||||
}
|
||||
|
||||
& > .content > .attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
|
||||
& > li > .audio {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio {
|
||||
background: var(--background-1);
|
||||
border: solid var(--background-4) 1px;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > .main {
|
||||
display: flex;
|
||||
height: 10;
|
||||
|
||||
& > .icon {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
background: #4af;
|
||||
}
|
||||
|
||||
& > .info {
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
& > .progress {
|
||||
height: 8px;
|
||||
background: var(--background-3);
|
||||
|
||||
& > .bar {
|
||||
height: 8px;
|
||||
background: var(--foreground-link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
contain: content;
|
||||
display: inline-block;
|
||||
|
||||
& > .info {
|
||||
position: fixed;
|
||||
bottom: -2px;
|
||||
background: #111d;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all .2s;
|
||||
|
||||
&.shown, &:hover {
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > .progress {
|
||||
height: 8px;
|
||||
background: var(--background-4);
|
||||
|
||||
& > .bar {
|
||||
height: 8px;
|
||||
width: 0;
|
||||
background: var(--foreground-link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
display: inline-flex;
|
||||
background: var(--background-3);
|
||||
position: relative;
|
||||
|
||||
& > img {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& > figcaption {
|
||||
opacity: 0;
|
||||
transition: all .2s;
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
background: #111d;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&:hover > figcaption {
|
||||
opacity: 1;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
|
243
src/Message.tsx
243
src/Message.tsx
|
@ -1,14 +1,72 @@
|
|||
import { MediaId } from "sdk/dist/src/api";
|
||||
import { For, Show, VoidProps } from "solid-js";
|
||||
import { For, JSX, Show, VoidProps, createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { Match } from "solid-js";
|
||||
import { Switch } from "solid-js";
|
||||
import { useGlobals } from "./Context";
|
||||
import { TextBlock } from "./Room";
|
||||
import "./Message.scss";
|
||||
import { Event } from "sdk";
|
||||
import { debounce } from "@solid-primitives/scheduled";
|
||||
import * as xss from "xss";
|
||||
|
||||
export function Message(props: VoidProps<{ event: any, title?: boolean, classes?: Record<string, boolean> }>) {
|
||||
function sanitize(html: string): string {
|
||||
return xss.filterXSS(html, {
|
||||
allowList: {
|
||||
"p": [],
|
||||
"b": [],
|
||||
"strong": [],
|
||||
"em": [],
|
||||
"a": ["href"],
|
||||
"code": [],
|
||||
"h1": [],
|
||||
"h2": [],
|
||||
"h3": [],
|
||||
"h4": [],
|
||||
"h5": [],
|
||||
"h6": [],
|
||||
"br": [],
|
||||
"ul": [],
|
||||
"ol": [],
|
||||
"li": [],
|
||||
"blockquote": [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fallback?: JSX.Element }>) {
|
||||
function escape(str: string) {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function hasBody() {
|
||||
return props.text.some((i: any) => !i.type || ["text/plain", "text/html"].includes(i.type));
|
||||
}
|
||||
|
||||
function getBody() {
|
||||
const html = props.text.find((i: any) => i.type === "text/html")?.body;
|
||||
if (html) return sanitize(html);
|
||||
|
||||
const text = props.text.find((i: any) => !i.type || i.type === "text/plain")?.body;
|
||||
if (text) return escape(text);
|
||||
return "";
|
||||
}
|
||||
|
||||
// for portability, it might be better to add a custom renderer instead of using html
|
||||
return (
|
||||
<Show when={hasBody()} fallback={props.fallback}>
|
||||
<div innerHTML={getBody()}></div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
export function Message(props: VoidProps<{ event: Event, title?: boolean, classes?: Record<string, boolean> }>) {
|
||||
const [globals] = useGlobals();
|
||||
const title = () => props.title;
|
||||
const compact = () => globals!.settings.compact;
|
||||
const name = () => props.event.room.getState("m.room.member", props.event.sender)?.content.displayname || props.event.sender;
|
||||
|
||||
if (props.event.type !== "m.message") {
|
||||
console.warn("constructed Message with a non-m.message event");
|
||||
|
@ -17,9 +75,9 @@ export function Message(props: VoidProps<{ event: any, title?: boolean, classes?
|
|||
return (
|
||||
<div class="message" classList={{ title: title(), [compact() ? "compact" : "cozy"]: true, ...props.classes }} data-event-id={props.event.id}>
|
||||
{title() && !compact() && <div class="avatar"></div>}
|
||||
{title() && compact() && <div class="name">{props.event.sender}</div>}
|
||||
{title() && compact() && <div class="name">{name()}</div>}
|
||||
<div class="content">
|
||||
{title() && !compact() && <div class="name">{props.event.sender}</div>}
|
||||
{title() && !compact() && <div class="name">{name()}</div>}
|
||||
<Show when={props.event.content.text}>
|
||||
<div class="body">
|
||||
<TextBlock text={props.event.content.text} fallback={props.event.content["m.files"] ? null : <em>no content?</em>} />
|
||||
|
@ -41,27 +99,18 @@ function File(props: VoidProps<{ file: FileI }>) {
|
|||
const [globals] = useGlobals();
|
||||
const major = () => props.file.info?.mimetype?.split("/")[0] || "application";
|
||||
const httpUrl = () => props.file.url.replace(/^mxc:\/\//, `${globals.client.config.baseUrl}/_matrix/media/v3/download/`);
|
||||
const info = () => props.file.info ?? {};
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={major() === "image"}>
|
||||
<div>
|
||||
image: {props.file.info?.name}
|
||||
<div style={{ height: `${Math.min(info().height || 300, 300)}px`, overflow: "hidden" }}>
|
||||
<img src={httpUrl()} style="height: 100%; background: var(--background-3)" />
|
||||
</div>
|
||||
<Image file={props.file} src={httpUrl()} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={major() === "audio"}>
|
||||
<div style="height: 64px">
|
||||
audio: {props.file.info?.name}
|
||||
<audio controls src={httpUrl()} title={props.file.info?.alt} />
|
||||
</div>
|
||||
<Audio file={props.file} src={httpUrl()} />
|
||||
</Match>
|
||||
<Match when={major() === "video"}>
|
||||
<div>
|
||||
video: {props.file.info?.name}
|
||||
</div>
|
||||
<Video file={props.file} src={httpUrl()} />
|
||||
</Match>
|
||||
<Match when={major() === "text" || props.file.info?.mimetype === "application/json"}>
|
||||
<div>
|
||||
|
@ -77,6 +126,166 @@ function File(props: VoidProps<{ file: FileI }>) {
|
|||
)
|
||||
}
|
||||
|
||||
function Image(props: VoidProps<{ file: FileI, src: string }>) {
|
||||
const info = () => props.file.info ?? {};
|
||||
|
||||
return (
|
||||
<figure class="image">
|
||||
<img src={props.src} style={getDimensions(info())} />
|
||||
<figcaption>{props.file.info?.name}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Audio(props: VoidProps<{ file: FileI, src: string }>) {
|
||||
const audio = new window.Audio();
|
||||
createEffect(() => audio.src = props.src);
|
||||
|
||||
const [duration, setDuration] = createSignal(0);
|
||||
const [progress, setProgress] = createSignal(0);
|
||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
||||
audio.ondurationchange = () => setDuration(audio.duration);
|
||||
audio.ontimeupdate = () => setProgress(audio.currentTime);
|
||||
audio.onplay = () => setIsPlaying(true);
|
||||
audio.onpause = () => setIsPlaying(false);
|
||||
|
||||
function togglePlayPause() {
|
||||
if (isPlaying()) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => audio.pause());
|
||||
|
||||
return (
|
||||
<div class="audio">
|
||||
<div class="main">
|
||||
<div class="icon"></div>
|
||||
<div class="info">
|
||||
<div><b>{props.file.info?.name}</b></div>
|
||||
<div>
|
||||
{formatTime(progress())} / {formatTime(duration())}
|
||||
<button onClick={togglePlayPause}>playpause</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Video(props: VoidProps<{ file: FileI, src: string }>) {
|
||||
let videoEl: HTMLVideoElement;
|
||||
let wrapperEl: HTMLDivElement;
|
||||
|
||||
const [duration, setDuration] = createSignal(0);
|
||||
const [progress, setProgress] = createSignal(0);
|
||||
const [isFullscreen, setIsFullscreen] = createSignal(false);
|
||||
const [state, setState] = createSignal<"play" | "pause" | "end">("end");
|
||||
|
||||
onMount(() => {
|
||||
videoEl.ondurationchange = () => setDuration(videoEl.duration);
|
||||
videoEl.ontimeupdate = () => setProgress(videoEl.currentTime);
|
||||
videoEl.onplay = () => setState("play");
|
||||
videoEl.onpause = () => setState("pause");
|
||||
videoEl.onended = () => setState("end");
|
||||
wrapperEl.onfullscreenchange = () => setIsFullscreen(document.fullscreenElement === wrapperEl);
|
||||
});
|
||||
|
||||
function togglePlayPause() {
|
||||
if (state() === "play") {
|
||||
videoEl.pause();
|
||||
} else {
|
||||
videoEl.play();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
videoEl.currentTime += e.deltaY < 0 ? -5 : 5;
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (isFullscreen()) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
wrapperEl.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
const [isMouseMoved, setIsMouseMoved] = createSignal(false);
|
||||
const [isHovering, setIsHovering] = createSignal(false);
|
||||
const unsetIsMouseMoved = debounce(() => setIsMouseMoved(false), 1000);
|
||||
|
||||
function handleMouseMove() {
|
||||
setIsMouseMoved(true);
|
||||
setIsHovering(true);
|
||||
unsetIsMouseMoved();
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
setIsHovering(false);
|
||||
}
|
||||
|
||||
const isInfoShown = () => {
|
||||
return state() === "pause" || (isFullscreen() ? isMouseMoved() : isHovering());
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="video"
|
||||
ref={wrapperEl!}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
classList={{ [state()]: true }}
|
||||
style={{ ...getDimensions(props.file.info ?? {}) }}
|
||||
>
|
||||
<video onClick={togglePlayPause} ref={videoEl!} src={props.src}></video>
|
||||
<div class="info" classList={{ shown: isInfoShown() }}>
|
||||
<div><b>{props.file.info?.name}</b> - <button onClick={toggleFullscreen}>full</button></div>
|
||||
<div onWheel={handleWheel}>
|
||||
{formatTime(progress())} / {formatTime(duration())} - {state()}
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="bar" style={{ width: `${progress() * 100 / duration()}%`}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(time: number): string {
|
||||
const t = Math.floor(time);
|
||||
const seconds = t % 60;
|
||||
const minutes = Math.floor(t / 60) % 60;
|
||||
const hours = Math.floor(t / 3600);
|
||||
if (hours) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getDimensions(info: { width?: number, height?: number }) {
|
||||
let height = Math.max(info.height || 300, 100);
|
||||
let width = Math.max(info.width || 300, 100);
|
||||
|
||||
const newHeight = Math.min(height, 300);
|
||||
width *= newHeight / height;
|
||||
height = newHeight;
|
||||
|
||||
const newWidth = Math.min(width, 300);
|
||||
height *= newWidth / width;
|
||||
width = newWidth;
|
||||
|
||||
return {
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
};
|
||||
}
|
||||
|
||||
interface FileI {
|
||||
url: MediaId,
|
||||
info?: {
|
||||
|
|
29
src/Room.tsx
29
src/Room.tsx
|
@ -5,19 +5,14 @@ import { Text, Time } from "./Atoms";
|
|||
import { ThreadTimeline } from "sdk/dist/src/timeline";
|
||||
import { shouldSplit } from "./util";
|
||||
import { MediaId } from "sdk/dist/src/api";
|
||||
import { Message, TextBlock } from "./Message";
|
||||
|
||||
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
||||
const [globals, change] = useGlobals();
|
||||
|
||||
const THREAD_PREVIEW_COUNT = 10;
|
||||
const [preview, { mutate }] = createResource(props.thread, async (thread) => {
|
||||
console.log("fetchthread");
|
||||
await thread.timelines.fetch("end");
|
||||
await thread.timelines.fetch("start");
|
||||
|
||||
const tl = await thread.timelines.fetch("start");
|
||||
await thread.timelines.fetch("end");
|
||||
return tl;
|
||||
return await thread.timelines.fetch("start");
|
||||
}, {
|
||||
storage: (init) => createSignal(init, { equals: false }),
|
||||
});
|
||||
|
@ -79,23 +74,3 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fallback?: JSX.Element }>) {
|
||||
function sanitize(str: string) {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
const body = () => props.text?.[0]?.body;
|
||||
// console.log(props.text, body());
|
||||
|
||||
// for portability, it might be better to add a custom renderer instead of using html
|
||||
return (
|
||||
<Show when={body()} fallback={props.fallback}>
|
||||
<div innerHTML={sanitize(body())}></div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense, createRenderEffect, on, Accessor } from "solid-js";
|
||||
import "./App.scss";
|
||||
import { Client, Event, EventId, Room, Thread } from "sdk";
|
||||
import { TextBlock, ThreadsItem } from "./Room";
|
||||
import { Message } from "./Message";
|
||||
import { ThreadsItem } from "./Room";
|
||||
import { Message, TextBlock } from "./Message";
|
||||
// import { computePosition, shift, offset, autoUpdate } from "@floating-ui/dom";
|
||||
// import { Portal } from "solid-js/web";
|
||||
import { Text, Time, createKeybinds } from "./Atoms";
|
||||
|
@ -21,19 +21,16 @@ type SliceInfo = {
|
|||
end: number,
|
||||
};
|
||||
|
||||
// type TimelineItem =
|
||||
// { type: "event", event: Event } |
|
||||
// { type: "events", events: Array<Event> } |
|
||||
// { type: "separator-date", ts: number } |
|
||||
// { type: "separator-unread" }
|
||||
|
||||
// TODO: dynamically calculate how many events are needed
|
||||
const SLICE_COUNT = 100;
|
||||
// const PAGINATE_COUNT = SLICE_COUNT * 3;
|
||||
const PAGINATE_COUNT = SLICE_COUNT;
|
||||
|
||||
type TimelineItem =
|
||||
{ type: "event", event: Event };
|
||||
|
||||
function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
|
||||
// const [items, setEvents] = createSignal<Array<TimelineItem>>([]);
|
||||
const [items, setItems] = createSignal<Array<TimelineItem>>([]);
|
||||
const [events, setEvents] = createSignal<Array<Event>>([]);
|
||||
const [info, setInfo] = createSignal<SliceInfo | null>(null);
|
||||
const [status, setStatus] = createSignal<TimelineStatus>("loading");
|
||||
|
@ -82,8 +79,6 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
|
|||
setStatus("ready");
|
||||
}
|
||||
|
||||
createEffect(() => console.log("info", info()))
|
||||
|
||||
async function forwards() {
|
||||
if (status() !== "ready") return;
|
||||
if (isAtEnd()) return;
|
||||
|
@ -175,15 +170,15 @@ export function ThreadView(props: VoidProps<{ thread: Thread }>) {
|
|||
|
||||
let offsetTop = 0, offsetHeight = 0, scrollRefEl: HTMLDivElement | undefined;
|
||||
createEffect(on(tl.status, (status) => {
|
||||
let isAutoscrolling = scrollEl.scrollTop + scrollEl.offsetHeight > scrollEl.scrollHeight - AUTOSCROLL_MARGIN && tl.isAtEnd();
|
||||
tl.setIsAutoscrolling(isAutoscrolling);
|
||||
console.log("timeline state", status);
|
||||
if (status === "update") {
|
||||
let isAutoscrolling = scrollEl.scrollTop + scrollEl.offsetHeight > scrollEl.scrollHeight - AUTOSCROLL_MARGIN && tl.isAtEnd();
|
||||
tl.setIsAutoscrolling(isAutoscrolling);
|
||||
scrollRefEl = (dir === "backwards" ? scrollEl.querySelector(".message.first") : scrollEl.querySelector(".message.last")) as HTMLDivElement | undefined;
|
||||
offsetTop = scrollRefEl?.offsetTop || 0;
|
||||
offsetHeight = scrollRefEl?.offsetHeight || 0;
|
||||
} else if (status === "ready") {
|
||||
if (isAutoscrolling) {
|
||||
if (tl.isAutoscrolling()) {
|
||||
scrollEl?.scrollBy(0, 999999);
|
||||
} else if (scrollRefEl) {
|
||||
const newOffsetTop = scrollRefEl?.offsetTop || 0;
|
||||
|
@ -193,7 +188,7 @@ export function ThreadView(props: VoidProps<{ thread: Thread }>) {
|
|||
console.log("scroll diff ", scrollRefEl, newOffsetTop, offsetTop);
|
||||
console.log("element moved by px", newOffsetTop - offsetTop);
|
||||
console.log("element resized by px", offsetHeight - newOffsetHeight);
|
||||
console.log("isAutoscrolling?", isAutoscrolling);
|
||||
console.log("isAutoscrolling?", tl.isAutoscrolling());
|
||||
console.groupEnd();
|
||||
scrollEl.scrollBy(0, (newOffsetTop - offsetTop) - (offsetHeight - newOffsetHeight));
|
||||
}
|
||||
|
@ -257,7 +252,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
|
|||
// the queue should upload all files as fast as possible, but send messages sequentially
|
||||
const [tempInputBlocking, setTempInputBlocking] = createSignal(false);
|
||||
|
||||
async function handleSubmit(text: string) {
|
||||
async function handleSubmit({ text, html }: { text: string, html: string }) {
|
||||
if (!text && !files().length) return;
|
||||
setTempInputBlocking(true);
|
||||
const contentFiles = [];
|
||||
|
@ -309,8 +304,8 @@ function Input(props: VoidProps<{ thread: Thread }>) {
|
|||
vid.onloadedmetadata = () => {
|
||||
res({
|
||||
duration: Math.floor(vid.duration),
|
||||
height: vid.height,
|
||||
width: vid.width,
|
||||
height: vid.videoHeight,
|
||||
width: vid.videoWidth,
|
||||
});
|
||||
URL.revokeObjectURL(vid.src);
|
||||
};
|
||||
|
@ -332,7 +327,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
|
|||
});
|
||||
}
|
||||
await props.thread.room.sendEvent("m.message", {
|
||||
text: [{ body: text }],
|
||||
text: [{ body: text }, { type: "text/html", body: html }],
|
||||
"m.relations": [{ rel_type: "m.thread", event_id: props.thread.id }],
|
||||
"m.files": contentFiles.length ? contentFiles : undefined,
|
||||
});
|
||||
|
|
|
@ -1,32 +1,23 @@
|
|||
@import "./fonts.scss";
|
||||
|
||||
:root {
|
||||
font: 16px/1.5 "Atkinson Hyperlegible", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
--background-1: #24262b;
|
||||
--background-2: #1e2024;
|
||||
--background-3: #191b1d;
|
||||
--background-4: #17181a;
|
||||
--foreground-1: #eae8efcc;
|
||||
--foreground-2: #eae8ef9f;
|
||||
--foreground-link: #b18cf3;
|
||||
--font-default: "Atkinson Hyperlegible", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
--font-mono: "Iosevka Zesty", 'Courier New', Courier, monospace;
|
||||
|
||||
font: 16px/1.5 var(--font-default);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: var(--background-1);
|
||||
color: var(--foreground-1);
|
||||
|
||||
// --background-1: #1a1923;
|
||||
// --background-2: #14131d;
|
||||
// --background-3: #100e18;
|
||||
// --background-4: #0a0e11;
|
||||
|
||||
/*
|
||||
--fg-text: #c7c6ca;
|
||||
--fg-link: #b18cf3;
|
||||
--fg-dimmed: #7f879b;
|
||||
*/
|
||||
}
|
||||
|
||||
* {
|
||||
|
@ -72,6 +63,19 @@ bold {
|
|||
padding: 8px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #b18cf3;
|
||||
a, a:visited {
|
||||
color: var(--foreground-link);
|
||||
}
|
||||
|
||||
code, pre {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--background-3);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: solid var(--background-4) 4px;
|
||||
background: var(--background-3);
|
||||
padding: 0 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue