More work on editor and messages

This commit is contained in:
tezlm 2023-12-27 04:28:12 -08:00
parent 19b688f7fd
commit 8abc0c0148
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
12 changed files with 548 additions and 147 deletions

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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;
}
}

View file

@ -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());

View file

@ -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>

View file

@ -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()],

View file

@ -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;
}
}

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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?: {

View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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>
);
}

View file

@ -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,
});

View file

@ -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;
}