Refactors and fixes

This commit is contained in:
tezlm 2024-01-13 06:52:57 -08:00
parent 858f263292
commit 076ba1f9c1
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
15 changed files with 556 additions and 268 deletions

View file

@ -112,6 +112,7 @@
right: 0;
height: 100%;
box-shadow: -1px 0 5px #1114;
width: 100%;
}
}

View file

@ -7,6 +7,7 @@ import { HashRouter, Route, Navigate } from "@solidjs/router";
const Auth = lazy(() => import("./Auth"));
const Main = lazy(() => import("./Main"));
const RoomSettings = lazy(() => import("./RoomSettings"));
const UserSettings = lazy(() => import("./UserSettings"));
// function useListener(emitter, event, callback) {
// emitter.on(event, callback);
@ -26,6 +27,7 @@ function Wrapper() {
<Route path="/login" component={Auth} />
<Route path="/register" component={Auth} />
<Route path="/home" component={Main} />
<Route path="/settings" component={UserSettings} />
<Route path="/rooms/:roomId" component={Main} />
<Route path="/rooms/:roomId/settings" component={RoomSettings} />
<Route path="/*all" component={NotFound} />

View file

@ -73,7 +73,7 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
const [globals, update] = createStore({
settings: {
compact: false,
compact: true,
locale: "en",
},
global: {

View file

@ -162,7 +162,7 @@ export default function Editor(props: VoidProps<{
}),
decorations(state) {
if (state.doc.firstChild!.firstChild === null) {
const placeholder = <div class="placeholder">{props.placeholder}</div> as HTMLDivElement;
const placeholder = <div class="placeholder">{/* @once */ props.placeholder}</div> as HTMLDivElement;
return DecorationSet.create(state.doc, [Decoration.widget(0, placeholder)]);
}

View file

@ -1,6 +1,6 @@
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createResource, createSignal, on, onCleanup, onMount } from "solid-js";
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createMemo, createResource, createSignal, on, onCleanup, onMount } from "solid-js";
import "./App.scss";
import { Client, Room, RoomList, Thread } from "sdk";
import { Client, Room, RoomId, RoomList, Thread } from "sdk";
import { RoomView, ThreadsItem } from "./Room";
import { Portal } from "solid-js/web";
import { Dialog, Dropdown, Text, Time, Tooltip } from "./Atoms";
@ -10,6 +10,7 @@ import { useParams, A, useNavigate, Navigate } from "@solidjs/router";
import * as menu from "./Menu";
import { useFloating } from "solid-floating-ui";
import { ClientRectObject, ReferenceElement, autoPlacement, autoUpdate, shift } from "@floating-ui/dom";
import { ROOM_TYPE_DEFAULT, ROOM_TYPE_SPACE } from "./consts";
export default function App(): JSX.Element {
const params = useParams();
@ -28,6 +29,7 @@ export default function App(): JSX.Element {
globals.client.lists.subscribe("rooms", {
ranges: [[0, 99999]],
required_state: [
["m.room.create", ""],
["m.room.name", ""],
["m.room.topic", ""],
// ["m.room.member", globals.client.config.userId],
@ -46,10 +48,32 @@ export default function App(): JSX.Element {
function updateRooms(name: string, list: RoomList) {
if (name === "rooms") setRooms({ ...list });
if (name === "requests") console.log("update requests", list);
// if (name === "requests") console.log("update requests", list);
refetch();
}
function buildTree(rooms: Array<Room>): Map<RoomId | null, Array<Room>> {
const seen = new Set(rooms);
const spaces = new Map();
for (const room of rooms) {
if (room.getState("m.room.create")?.content.type !== ROOM_TYPE_SPACE) continue;
const children: Array<Room> = [];
spaces.set(room.id, children);
seen.delete(room);
for (const child of room.getAllState("m.space.child")) {
const childRoom = rooms.find(i => i.id === child.stateKey);
if (!childRoom) continue;
children.push(childRoom);
seen.delete(childRoom);
}
}
spaces.set(null, [...seen]);
return spaces;
}
const tree = () => buildTree(rooms().rooms);
const [space, setSpace] = createSignal<Room | null>(null);
globals.client.on("list", updateRooms);
onCleanup(() => globals.client.off("list", updateRooms));
@ -107,8 +131,15 @@ export default function App(): JSX.Element {
</main>
<nav id="nav-spaces" aria-label="Space List">
<ul>
<For each={new Array(10).fill(null)}>
{() => <li><a href="#"></a></li>}
<li>
<button onClick={() => setSpace(null)}>home</button>
</li>
<For each={rooms().rooms}>
{room => room.getState("m.room.create")!.content.type === ROOM_TYPE_SPACE && (
<li oncontextmenu={willHandleRoomContextMenu(room)}>
<button onClick={() => setSpace(room)}>{room.getState("m.room.name")?.content.name || "unnamed"}</button>
</li>
)}
</For>
</ul>
</nav>
@ -117,8 +148,8 @@ export default function App(): JSX.Element {
<li>
<A target="_self" href="/home">home</A>
</li>
<For each={rooms().rooms}>
{room => (
<For each={tree().get(space()?.id ?? null)}>
{room => room.getState("m.room.create")!.content.type !== ROOM_TYPE_SPACE && (
<li oncontextmenu={willHandleRoomContextMenu(room)}>
<A target="_self" href={`/rooms/${room.id}`}>{room.getState("m.room.name")?.content.name || "unnamed"}</A>
</li>
@ -168,10 +199,6 @@ export default function App(): JSX.Element {
)
}
// const ROOM_TYPE_CHAT = "jw.chat";
const ROOM_TYPE_CHAT = "jw.room";
const ROOM_TYPE_SPACE = "m.space";
export function Home(props: VoidProps<{ client: Client }>) {
/*
Menu items:
@ -179,12 +206,24 @@ export function Home(props: VoidProps<{ client: Client }>) {
- Requests: shows invites (incoming) and knocking (outgoing) requests
*/
function handleSubmit(e: SubmitEvent) {
function handleSubmitRoom(e: SubmitEvent) {
e.preventDefault();
const name = (e.target as HTMLFormElement).elements.namedItem("name")! as HTMLInputElement;
props.client.rooms.create({
creationContent: {
type: ROOM_TYPE_CHAT,
type: ROOM_TYPE_DEFAULT,
},
initialState: [{ type: "m.room.name", content: { name: name.value }, stateKey: ""}]
});
name.value = "";
}
function handleSubmitSpace(e: SubmitEvent) {
e.preventDefault();
const name = (e.target as HTMLFormElement).elements.namedItem("name")! as HTMLInputElement;
props.client.rooms.create({
creationContent: {
type: ROOM_TYPE_SPACE,
},
initialState: [{ type: "m.room.name", content: { name: name.value }, stateKey: ""}]
});
@ -221,10 +260,14 @@ export function Home(props: VoidProps<{ client: Client }>) {
</tbody>
</table>
<p>go to <a href="/help">help</a></p>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmitRoom}>
<input type="text" placeholder="name" name="name" />
<input type="submit" value="create room" />
</form>
<form onSubmit={handleSubmitSpace}>
<input type="text" placeholder="name" name="name" />
<input type="submit" value="create space" />
</form>
<Tooltip tip="hello world">some text here</Tooltip>
<Tooltip tip="hello world 2">some text here</Tooltip>
<br /><br /><br />

View file

@ -123,6 +123,7 @@ export function SpaceMenu(props: VoidProps<{ room: Room }>) {
// the context menu for rooms
export function RoomMenu(props: VoidProps<{ room: Room }>) {
const copyId = () => navigator.clipboard.writeText(props.room.id);
const edit = () => location.hash = `#/rooms/${props.room.id}/settings`;
const leave = () => {
if (confirm("really leave?")) props.room.leave();
};
@ -139,7 +140,7 @@ export function RoomMenu(props: VoidProps<{ room: Room }>) {
<Item>for 1 week</Item>
<Item>forever</Item>
</Submenu>
<Submenu content={"edit"}>
<Submenu content={"edit"} onClick={edit}>
<Item>general</Item>
<Item>log</Item>
<Item>permissions</Item>
@ -210,6 +211,7 @@ export function ThreadMenu(props: VoidProps<{ thread: Thread }>) {
// the context menu for messages
export function MessageMenu(props: VoidProps<{ event: Event }>) {
const copyId = () => navigator.clipboard.writeText(props.event.id);
const copyJSON = () => navigator.clipboard.writeText(JSON.stringify(props.event.content));
return (
<Menu>
<Item>mark unread</Item>
@ -223,7 +225,7 @@ export function MessageMenu(props: VoidProps<{ event: Event }>) {
<Item>redact</Item>
<Separator />
<Item onClick={copyId}>copy id</Item>
<Item>view source</Item>
<Item onClick={copyJSON}>view source</Item>
</Menu>
)
}

View file

@ -189,4 +189,10 @@
overflow: hidden;
text-overflow: ellipsis;
}
& > .sender {
flex: initial;
font-weight: bold;
margin-right: 4px;
}
}

View file

@ -1,4 +1,4 @@
import { MediaId } from "sdk/dist/src/api";
import { MediaId, UserId } from "sdk/dist/src/api";
import { For, JSX, Show, VoidProps, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { Match } from "solid-js";
import { Switch } from "solid-js";
@ -30,6 +30,7 @@ function sanitize(html: string): string {
"li": [],
"blockquote": [],
},
stripIgnoreTagBody: ["mx-reply"],
});
}
@ -68,36 +69,42 @@ export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fa
}
export function Message(props: VoidProps<{ event: Event, title?: boolean, classes?: Record<string, boolean> }>) {
const [globals] = useGlobals();
const [globals, action] = 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;
const getName = (userId: UserId) => props.event.room.getState("m.room.member", userId)?.content.displayname || userId;
const name = () => getName(props.event.sender);
const replyId = () => props.event.content["m.relations"]?.find((rel: any) => rel.rel_type === "m.reply")?.event_id;
const reply = () => replyId() ? props.event.room.events.get(replyId()) : undefined;
const willHandleMessageContextMenu = (e: MouseEvent) => {
e.preventDefault();
action({ type: "contextmenu.set", menu: { type: "message", event: props.event, x: e.clientX, y: e.clientY } });
}
if (props.event.type !== "m.message") {
console.warn("constructed Message with a non-m.message event");
}
return (
<div class="message" classList={{ title: title(), [compact() ? "compact" : "cozy"]: true, ...props.classes }} data-event-id={props.event.id}>
<div class="message" classList={{ title: title(), [compact() ? "compact" : "cozy"]: true, ...props.classes }} data-event-id={props.event.id} onContextMenu={willHandleMessageContextMenu}>
{title() && !compact() && <div class="avatar"></div>}
{compact() && <div class="name">{name()}</div>}
<div class="content">
<Show when={reply()}>
<div class="reply">
{reply()?.sender} &bull; <TextBlock text={reply()!.content.text} fallback={reply()!.content["m.files"] ? <em>{reply()!.content["m.files"].length} files</em> : <em>no content?</em>} />
<div class="sender">{getName(reply()!.sender)}</div> <TextBlock text={reply()!.content.text} fallback={reply()!.content["m.attachments"] ? <em>{reply()!.content["m.attachments"].length} files</em> : <em>no content?</em>} />
</div>
</Show>
{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>} />
<TextBlock text={props.event.content.text} fallback={props.event.content["m.attachments"] ? null : <em>no content?</em>} />
</div>
</Show>
<Show when={props.event.content["m.files"]}>
<Show when={props.event.content["m.attachments"]}>
<ul class="attachments">
<For each={props.event.content["m.files"]}>
<For each={props.event.content["m.attachments"]}>
{(file) => <li><File file={file} /></li>}
</For>
</ul>

View file

@ -8,6 +8,7 @@ import { Message, TextBlock } from "./Message";
import { useNavigate } from "@solidjs/router";
import { SetStoreFunction, createStore } from "solid-js/store";
import "./Room.scss";
import { EVENT_TYPE_THREAD_CHAT } from "./consts";
const Editor = lazy(() => import("./Editor"));
@ -321,11 +322,13 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread, timeline: ThreadT
<Text count={thread().messageCount}>message.count</Text> &bull; <Time ts={thread().latestEvent.originTs} />
</div>
</header>
<div class="preview">
<For each={preview()?.getEvents().slice(0, THREAD_PREVIEW_COUNT) || []}>
{(ev, idx) => <Message event={ev} title={shouldSplit(ev, preview()!.getEvents()![idx() - 1])} />}
</For>
</div>
<Show when={thread().baseEvent.type === EVENT_TYPE_THREAD_CHAT}>
<div class="preview">
<For each={preview()?.getEvents().slice(0, THREAD_PREVIEW_COUNT) || []}>
{(ev, idx) => <Message event={ev} title={shouldSplit(ev, preview()!.getEvents()![idx() - 1])} />}
</For>
</div>
</Show>
<Show when={remaining() && false}>
<footer>
<Text count={remaining()}>message.remaining</Text>
@ -350,7 +353,7 @@ function ThreadsBuilder(props: VoidProps<{ room: Room }>) {
if (!store.creating) return false;
if (!store.creating.title) return false;
if (!text) return false;
const thread = await props.room.sendEvent("m.thread.chat", {
const thread = await props.room.sendEvent(EVENT_TYPE_THREAD_CHAT, {
title: [{ body: store.creating.title }],
});
await props.room.sendEvent("m.message", {

View file

@ -6,7 +6,15 @@
& > nav {
width: 256px;
background: var(--background-2);
padding: 8px;
& > ul > li {
cursor: pointer;
padding: 4px 8px;
&:hover {
background: #fff2;
}
}
}
& > main {

View file

@ -1,95 +1,46 @@
import { Match, Switch, createEffect, createSignal, lazy } from "solid-js";
import { For, Match, Switch, VoidProps, createEffect, createSignal, lazy, onCleanup } from "solid-js";
import { Dropdown } from "./Atoms";
import { A, useParams } from "@solidjs/router";
import "./RoomSettings.scss";
import { createForm } from "@modular-forms/solid";
import { useGlobals } from "./Context";
import { Room } from "sdk";
import { ROOM_TYPE_SPACE } from "./consts";
import { createStore, unwrap } from "solid-js/store";
const Editor = lazy(() => import("./Editor"));
export default function RoomSettings() {
const tabs = ["general", "log", "permissions", "security", "integrations", "members"];
const tabs = ["general", "log", "permissions", "security", "integrations", "members", "rooms"];
const [selected, setSelected] = createSignal("general");
const params = useParams();
const [globals] = useGlobals();
const [room, setRoom] = createSignal(globals.client.rooms.get(params.roomId));
const resetRoom = () => {
setRoom(globals.client.rooms.get(params.roomId));
};
globals.client.on("roomInit", resetRoom);
onCleanup(() => globals.client.off("roomInit", resetRoom));
return (
<div class="room-settings">
<nav>
<ul>
<li><A target="_self" href={`/rooms/${params.roomId}`}>back</A></li>
{tabs.map(i => <li onClick={() => setSelected(i)}>{i}</li>)}
</ul>
</nav>
<main>
<Switch>
<Match when={selected() === "general"}>
<h1>general</h1>
<A target="_self" href={`/rooms/${params.roomId}`}>back</A> <br />
<input placeholder="name" />
<Editor onSubmit={() => {}} placeholder="topic" />
<button>invite</button>
<button>leave</button>
<hr />
notifications<br />
notify when new threads are created <input type="checkbox" /><br />
notify on all messages in watched threads <input type="checkbox" /><br />
mute room <Dropdown
options={[
{ item: "a", label: "unmuted" },
{ item: "b", label: "forever" },
{ item: "c", label: "for (insert time)" },
]}
/>
<hr />
room aliases
<ul>
<li>#foo:domain.tld <button>remove</button> [main]</li>
<li>#bar:domain.tld <button>remove</button> <button>set as main</button></li>
<li>#baz:domain.tld <button>remove</button> <button>set as main</button></li>
<li><input placeholder="add alias" /></li>
</ul>
<hr />
default thread type
<Dropdown
options={[
{ item: "a", label: "chat" },
{ item: "b", label: "forum" },
{ item: "c", label: "voice" },
]}
/>
<Match when={selected() === "general" && room()}>
<General room={room()!} />
</Match>
<Match when={selected() === "permissions"}>
<Permissions />
</Match>
<Match when={selected() === "security"}>
<h1>security and privacy</h1>
who can join
<Dropdown
selected="b"
options={[
{ item: "a", label: "nobody" },
{ item: "b", label: "invite only" },
{ item: "c", label: "people in these spaces" },
{ item: "d", label: "everyone" },
]}
/>
<br />
who can knock
<Dropdown
options={[
{ item: "a", label: "nobody" },
{ item: "b", label: "people in these spaces" },
{ item: "c", label: "everyone" },
]}
/>
<br />
who can view content
<Dropdown
options={[
{ item: "a", label: "members only (join or invite)" },
{ item: "b", label: "everyone" },
]}
/>
<br />
enable e2ee
<button>enable e2ee</button>
<Match when={selected() === "security" && room()}>
<Security room={room()!} />
</Match>
<Match when={selected() === "log"}>
<h1>audit log</h1>
@ -116,9 +67,22 @@ export default function RoomSettings() {
]}
/>
<ul>
<li>@foo:server.tld</li>
<li>@bar:server.tld</li>
<li>@baz:server.tld</li>
<For each={room()!.getAllState("m.room.member")}>
{(ev) => <li>{ev.stateKey}: {JSON.stringify(ev.content)}</li>}
</For>
</ul>
</Match>
<Match when={selected() === "rooms" && room()?.getState("m.room.create")?.content.type === ROOM_TYPE_SPACE}>
<h1>room list</h1>
<button onClick={() => {
const id = window.prompt("room id");
if (!id) return;
room()?.sendState("m.space.child", id, { via: ["localhost"] });
}}>add room</button>
<ul>
<For each={room()!.getAllState("m.space.child")}>
{(ev) => <li>{ev.stateKey}: {JSON.stringify(ev.content)}</li>}
</For>
</ul>
</Match>
</Switch>
@ -127,6 +91,67 @@ export default function RoomSettings() {
);
}
function General(props: VoidProps<{ room: Room }>) {
type FormData = { name: string };
const [form, { Form, Field }] = createForm<FormData>({
initialValues: {
name: props.room.getState("m.room.name")?.content.name || "",
},
});
async function handleSubmit(data: FormData) {
if (data.name !== (props.room.getState("m.room.name")?.content.name || "")) {
props.room.sendState("m.room.name", "", {
name: data.name,
});
console.log("change name", data.name);
}
}
return (
<>
<h1>general</h1>
<Form onSubmit={handleSubmit}>
<Field name="name">
{(store, props) => <input {...props} value={store.value} placeholder="name" />}
</Field>
{form.dirty && <input type="submit" value="save" />}
</Form>
<Editor onSubmit={() => {}} placeholder="topic" />
<button>invite</button>
<button>leave</button>
<hr />
notifications<br />
notify when new threads are created <input type="checkbox" /><br />
notify on all messages in watched threads <input type="checkbox" /><br />
mute room <Dropdown
options={[
{ item: "a", label: "unmuted" },
{ item: "b", label: "forever" },
{ item: "c", label: "for (insert time)" },
]}
/>
<hr />
room aliases
<ul>
<li>#foo:domain.tld <button>remove</button> [main]</li>
<li>#bar:domain.tld <button>remove</button> <button>set as main</button></li>
<li>#baz:domain.tld <button>remove</button> <button>set as main</button></li>
<li><input placeholder="add alias" /></li>
</ul>
<hr />
default thread type
<Dropdown
options={[
{ item: "a", label: "chat" },
{ item: "b", label: "forum" },
{ item: "c", label: "voice" },
]}
/>
</>
);
}
function Permissions() {
const [form, { Form, Field }] = createForm<{
thread: {
@ -296,3 +321,89 @@ function Permissions() {
manage_threads: 0,
}
*/
function Security(props: { room: Room }) {
type SecurityForm = {
join: "invite" | "restricted" | "public",
knock: "none" | "restricted" | "public",
view: "private" | "join" | "knock" | "public",
};
const initial: SecurityForm = {
join: props.room.getState("m.room.join_rules")?.content.join_rule ?? "invite",
knock: "none",
view: "private",
};
const [form, update] = createStore({ ...initial } as SecurityForm);
const isDirty = () => !deepEquals(initial, form);
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (initial.join !== form.join) {
props.room.sendState("m.room.join_rules", "", {
join_rule: form.join,
});
}
}
function reset(e: Event) {
e.preventDefault();
update({ ...initial } as SecurityForm);
}
return (<>
<h1>security and privacy</h1>
<form onSubmit={handleSubmit}>
who can join
<Dropdown
selected={form.join}
options={[
{ item: "invite", label: "invite only" },
{ item: "restricted", label: "people in these spaces" },
{ item: "public", label: "everyone" },
]}
required
onSelect={(selected) => update("join", selected! as any)}
/>
<input type="reset" value="reset" onClick={reset} />
<input type="submit" value="save" />
{isDirty() && "dirty!"}
</form>
<br />
who can knock
<Dropdown
options={[
{ item: "a", label: "nobody" },
{ item: "b", label: "people in these spaces" },
{ item: "c", label: "everyone" },
]}
/>
<br />
who can view content
<Dropdown
options={[
{ item: "a", label: "members only (join or invite)" },
{ item: "b", label: "anyone who can join" },
{ item: "c", label: "anyone who can knock" },
{ item: "d", label: "anyone" },
]}
/>
<br />
enable e2ee
<button>enable e2ee</button>
</>)
}
function deepEquals(a: any, b: any): boolean {
if (typeof a === "object" && typeof b === "object" && a && b) {
for (const key of new Set([...Object.getOwnPropertyNames(a), ...Object.getOwnPropertyNames(b)])) {
if (!deepEquals(a[key], b[key])) {
return false;
}
}
return true;
}
if (Array.isArray(a)) throw new Error("unimplemented");
return a === b;
}

View file

@ -9,86 +9,88 @@
// scrollbar-color: var(--background-1) var(--background-3);
overflow-anchor: none;
& > .spacer {
margin-top: auto;
min-height: 32px;
padding: 0 4px;
display: flex;
flex-direction: column;
align-items: start;
justify-content: end;
& > button {
& > li {
& > .spacer {
margin-top: auto;
min-height: 32px;
padding: 0 4px;
}
}
& > .spacer-bottom {
// margin-top: 72px; // same height as footer
margin-top: 80px;
}
& > .thread-title {
position: sticky;
top: -1px;
z-index: 999;
visibility: hidden;
& > * {
padding: 1px 8px 0;
width: 100%;
}
& > .fake {
visibility: hidden;
}
& > .real {
position: absolute;
border-bottom: solid var(--background-3) 0;
display: flex;
transition: all .2s;
visibility: visible;
& > div {
flex: 1;
}
flex-direction: column;
align-items: start;
justify-content: end;
& > button {
display: none;
font-size: 1rem;
margin: 0;
padding: 0 4px;
font-weight: initial;
}
}
&.stuck > .real {
background: var(--background-1);
border-bottom: solid var(--background-3) 1px;
font-size: 1.2rem;
padding: 8px;
& > .spacer-bottom {
// margin-top: 72px; // same height as footer
margin-top: 80px;
}
& > .thread-title {
position: sticky;
top: -1px;
z-index: 999;
visibility: hidden;
& > * {
padding: 1px 8px 0;
width: 100%;
}
& > .fake {
visibility: hidden;
}
& > .real {
position: absolute;
border-bottom: solid var(--background-3) 0;
display: flex;
transition: all .2s;
visibility: visible;
& > div {
flex: 1;
}
& > button {
display: none;
font-size: 1rem;
margin: 0;
padding: 0 4px;
font-weight: initial;
}
}
&.stuck > .real {
background: var(--background-1);
border-bottom: solid var(--background-3) 1px;
font-size: 1.2rem;
padding: 8px;
& > div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8px;
}
& > div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 8px;
}
& > button {
display: block;
& > button {
display: block;
}
}
}
}
& > header {
padding: 8px;
& > header {
padding: 8px;
& > p {
opacity: 1;
transition: all .2s;
& > p {
opacity: 1;
transition: all .2s;
}
}
}
}
@ -137,6 +139,22 @@
.comment {
border-left: solid var(--background-4) 1px;
& .content {
& p, li {
// white-space: pre-wrap;
}
& p + * {
margin-bottom: 1rem;
}
& ul, li {
// doesn't work?
// list-style-position: inside;
margin-left: 1rem;
}
}
&.collapsed {
}

View file

@ -1,4 +1,4 @@
import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense, createRenderEffect, on, Accessor, Switch, Match, createMemo } from "solid-js";
import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense, createRenderEffect, on, Accessor, Switch, Match, createMemo, JSX } from "solid-js";
import "./App.scss";
import { Client, Event, EventId, Room, Thread } from "sdk";
import { ThreadsItem } from "./Room";
@ -11,6 +11,7 @@ import { debounce } from "@solid-primitives/scheduled";
import { useGlobals } from "./Context";
import "./Thread.scss";
import { createProgress, delay, shouldSplit } from "./util";
import { createStore, reconcile } from "solid-js/store";
const Editor = lazy(() => import("./Editor"));
@ -26,11 +27,14 @@ const SLICE_COUNT = 100;
// const PAGINATE_COUNT = SLICE_COUNT * 3;
const PAGINATE_COUNT = SLICE_COUNT;
type TimelineItem =
{ type: "event", event: Event };
type TimelineItem = { key: string } & (
{ type: "info", header: boolean } |
{ type: "spacer" } |
{ type: "spacer-mini" } |
{ type: "message", event: Event, separate: boolean });
function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
const [items, setItems] = createSignal<Array<TimelineItem>>([]);
const [items, setItems] = createStore<Array<TimelineItem>>([]);
const [events, setEvents] = createSignal<Array<Event>>([]);
const [info, setInfo] = createSignal<SliceInfo | null>(null);
const [status, setStatus] = createSignal<TimelineStatus>("loading");
@ -39,6 +43,30 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
const [isAtEnd, setIsAtEnd] = createSignal(true);
const [isAutoscrolling, setIsAutoscrolling] = createSignal(true);
function updateItems() {
const { start, end } = info()!;
const items: Array<TimelineItem> = [];
items.push({ type: "info", key: "info", header: isAtBeginning() });
if (!isAtBeginning()) {
items.push({ type: "spacer", key: "space-begin" });
}
const events = timeline().getEvents();
for (let i = start; i < end; i++) {
items.push({
type: "message",
key: events[i].id,
event: events[i],
separate: shouldSplit(events[i], events[i - 1]),
});
}
if (!isAtEnd()) {
items.push({ type: "spacer", key: "space-end" });
} else {
items.push({ type: "spacer-mini", key: "space-end" });
}
setItems((old) => reconcile(items, { key: "key" })(old));
}
async function init() {
console.log("init");
@ -57,6 +85,7 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
setIsAutoscrolling(isAtEnd());
setStatus("update");
setEvents(timeline().getEvents().slice(newStart, newEnd));
updateItems();
setStatus("ready");
}
@ -71,9 +100,10 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
const newEnd = Math.min(newStart + SLICE_COUNT, timeline().getEvents().length);
setInfo({ start: newStart, end: newEnd });
setStatus("update");
setEvents(timeline().getEvents().slice(newStart, newEnd));
setIsAtBeginning(timeline().isAtBeginning && newStart === 0);
setIsAtEnd(timeline().isAtEnd && newEnd === timeline().getEvents().length - 1);
setEvents(timeline().getEvents().slice(newStart, newEnd));
updateItems();
setStatus("ready");
}
@ -88,9 +118,10 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
const newStart = Math.max(newEnd - SLICE_COUNT, 0);
setInfo({ start: newStart, end: newEnd });
setStatus("update");
setEvents(timeline().getEvents().slice(newStart, newEnd));
setIsAtBeginning(timeline().isAtBeginning && newStart === 0);
setIsAtEnd(timeline().isAtEnd && newEnd === timeline().getEvents().length);
setEvents(timeline().getEvents().slice(newStart, newEnd));
updateItems();
setStatus("ready");
}
@ -100,9 +131,11 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
// async function append(_event: Event) {
async function append() {
console.log("append", { status: status(), auto: isAutoscrolling() });
if (status() !== "ready") return;
if (!isAutoscrolling()) return;
const newEnd = timeline().getEvents().length;
const newStart = Math.max(newEnd - SLICE_COUNT, 0);
setInfo({ start: newStart, end: newEnd });
@ -110,6 +143,7 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
setEvents(timeline().getEvents().slice(newStart, newEnd));
setIsAtBeginning(timeline().isAtBeginning && newStart === 0);
setIsAtEnd(timeline().isAtEnd && newEnd === timeline().getEvents().length);
updateItems();
setStatus("ready");
}
@ -122,6 +156,7 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
oldTimeline?.off("timelineAppend", append);
oldTimeline?.off("timelineUpdate", append);
oldTimeline = timeline();
console.log("isLive", timeline() === timelineSet().live);
});
onCleanup(() => {
@ -130,6 +165,7 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
});
return {
items,
events,
status,
backwards,
@ -142,20 +178,92 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
}
}
// refactor
// interface Timeline2 {
// items: Array<TimelineItem>,
// setScrollHeight(height: number): void;
// setTimelines(set: ThreadTimelineSet, tl: ThreadTimeline, event?: Event): void;
// setAutoscrolling(isAuto: boolean): void;
// backwards(): void;
// forwards(): void;
// getStatus(): TimelineStatus;
// getLocation(): { atStart: boolean, atEnd: boolean };
// }
const AUTOSCROLL_MARGIN = 5;
const SCROLL_MARGIN = 100;
const PAGINATE_MARGIN = SCROLL_MARGIN + 50;
function createList<T>(options: {
items: Accessor<Array<T>>,
autoscroll?: Accessor<boolean>,
topPos?: Accessor<number>,
bottomPos?: Accessor<number>,
onPaginate?: (dir: "forwards" | "backwards") => void,
onUpdate?: () => void,
}) {
const [wrapperEl, setWrapperEl] = createSignal<HTMLElement>();
const [topEl, setTopEl] = createSignal<HTMLElement>();
const [bottomEl, setBottomEl] = createSignal<HTMLElement>();
function getRef(idx: number) {
switch (idx) {
case options.topPos?.() ?? 0: return setTopEl;
case options.bottomPos?.() ?? options.items().length - 1: return setBottomEl;
}
}
let topOffset: number | undefined;
let topHeight: number | undefined;
let topRef: HTMLElement | undefined;
let bottomOffset: number | undefined;
let bottomHeight: number | undefined;
let bottomRef: HTMLElement | undefined;
createEffect(() => {
if (topRef && wrapperEl()?.contains(topRef)) {
const newOffsetTop = topRef.offsetTop;
const newOffsetHeight = topRef.offsetHeight;
wrapperEl()?.scrollBy(0, (newOffsetTop - topOffset!) - (topHeight! - newOffsetHeight));
} else if (bottomRef && wrapperEl()?.contains(bottomRef)) {
const newOffsetBottom = bottomRef.offsetTop;
const newOffsetHeight = bottomRef.offsetHeight;
wrapperEl()?.scrollBy(0, (newOffsetBottom - bottomOffset!) - (bottomHeight! - newOffsetHeight));
}
topOffset = topEl()?.offsetTop;
topHeight = topEl()?.offsetHeight;
topRef = topEl();
bottomOffset = bottomEl()?.offsetTop;
bottomHeight = bottomEl()?.offsetHeight;
bottomRef = bottomEl();
options.onUpdate?.();
const el = wrapperEl()!;
if (options.autoscroll?.() && el.scrollHeight - el.offsetHeight - el.scrollTop < AUTOSCROLL_MARGIN) {
console.log("should autoscroll?");
el.scrollBy({ top: 9999, behavior: "instant" });
}
});
function handleScroll() {
const el = wrapperEl();
if (!el) return;
if (el.scrollTop < PAGINATE_MARGIN) {
options.onPaginate?.("backwards");
} else if (el.scrollHeight - el.offsetHeight - el.scrollTop < PAGINATE_MARGIN) {
options.onPaginate?.("forwards");
}
}
return {
scrollBy(pos: number, smooth = false) {
wrapperEl()?.scrollBy({
top: pos,
behavior: smooth ? "smooth" : "instant",
});
},
scrollTo() {},
List(props: { children: (item: T, idx: Accessor<number>) => JSX.Element }) {
return (
<ul class="scroll" ref={setWrapperEl} onScroll={handleScroll}>
<For each={options.items()}>
{(item, idx) => <li ref={getRef(idx())}>{props.children(item, idx)}</li>}
</For>
</ul>
);
},
};
}
export function ThreadView(props: VoidProps<{ thread: Thread }>) {
console.log(props.thread.baseEvent.type);
return (
<Switch fallback="bad thread?">
<Match when={props.thread.baseEvent.type === "m.thread.chat"} children={<ThreadViewChat thread={props.thread} />} />
@ -181,10 +289,10 @@ export function ThreadViewForum(props: VoidProps<{ thread: Thread }>) {
tree.set(event.id, []);
if (!tree.get(replyId)?.includes(event)) tree.get(replyId)?.push(event);
}
console.log(tl().getEvents(), tree)
setCommentTree(tree);
}));
// let scrollEl: HTMLDivElement;
let scrollEl: HTMLDivElement;
const binds = createKeybinds({
"PageUp": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "smooth" }),
@ -212,6 +320,7 @@ export function ThreadViewForum(props: VoidProps<{ thread: Thread }>) {
function Comment(props: VoidProps<{ event: Event, tree: Map<EventId, Array<Event>> }>) {
const [isCollapsed, setIsCollapsed] = createSignal(false);
const name = () => props.event.room.getState("m.room.member", props.event.sender)?.content.displayname || props.event.sender;
const childCount = createMemo(() => {
function countChildren(id: EventId): number {
@ -228,7 +337,7 @@ function Comment(props: VoidProps<{ event: Event, tree: Map<EventId, Array<Event
<Show when={isCollapsed()}>
<span>[{childCount()}]</span>
</Show>
<span class="author">{props.event.sender}</span>
<span class="author">{name()}</span>
<Show when={isCollapsed()}>
<span class="summary">
<TextBlock formatting={false} text={props.event.content.text} />
@ -254,92 +363,55 @@ export function ThreadViewChat(props: VoidProps<{ thread: Thread }>) {
const tl = createTimeline(() => props.thread.timelines);
createEffect(on(() => props.thread, () => {
queueMicrotask(() => {
tl.setIsAutoscrolling(true);
scrollEl?.scrollBy(0, 999999);
});
}));
let scrollEl: HTMLDivElement;
let dir = "backwards";
const AUTOSCROLL_MARGIN = 5;
const SCROLL_MARGIN = 500;
const PAGINATE_MARGIN = SCROLL_MARGIN + 100;
let offsetTop = 0, offsetHeight = 0, scrollRefEl: HTMLDivElement | undefined;
createEffect(on(tl.status, (status) => {
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 (tl.isAutoscrolling()) {
scrollEl?.scrollBy(0, 999999);
} else if (scrollRefEl) {
const newOffsetTop = scrollRefEl?.offsetTop || 0;
const newOffsetHeight = scrollRefEl?.offsetHeight || 0;
console.group("reset scroll");
console.log("scroll el ", scrollRefEl);
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?", tl.isAutoscrolling());
console.groupEnd();
scrollEl.scrollBy(0, (newOffsetTop - offsetTop) - (offsetHeight - newOffsetHeight));
const list = createList({
items: () => tl.items,
autoscroll: tl.isAutoscrolling,
topPos: () => tl.isAtBeginning() ? 1 : 2,
bottomPos: () => tl.isAtEnd() ? tl.items.length - 1 : tl.items.length - 2,
onPaginate(dir) {
if (tl.status() !== "ready") return;
if (dir === "forwards") {
tl.forwards();
} else {
tl.backwards();
}
}
}));
const handleScroll = async () => {
let isAutoscrolling = scrollEl.scrollTop + scrollEl.offsetHeight > scrollEl.scrollHeight - AUTOSCROLL_MARGIN && tl.isAtEnd();
tl.setIsAutoscrolling(isAutoscrolling);
if (tl.status() === "ready") {
if (scrollEl.scrollTop < PAGINATE_MARGIN) {
console.log("scroll backwards");
dir = "backwards";
await tl.backwards();
} else if (scrollEl.scrollHeight - scrollEl.offsetHeight - scrollEl.scrollTop < PAGINATE_MARGIN) {
console.log("scroll forwards");
dir = "forwards";
await tl.forwards();
}
}
};
},
});
const binds = createKeybinds({
"PageUp": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "smooth" }),
"PageDown": () => scrollEl.scrollBy({ top: scrollEl.offsetHeight / 2, behavior: "smooth" }),
// "PageUp": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "instant" }),
// "PageDown": () => scrollEl.scrollBy({ top: scrollEl.offsetHeight / 2, behavior: "instant" }),
// "PageUp": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "smooth" }),
// "PageDown": () => scrollEl.scrollBy({ top: scrollEl.offsetHeight / 2, behavior: "smooth" }),
"PageUp": () => list.scrollBy(-500, true),
"PageDown": () => list.scrollBy(500, true),
// "g Shift-N": () => scrollEl.scrollBy({ top: -scrollEl.offsetHeight / 2, behavior: "smooth" }),
"Escape": () => {
if (tl.isAutoscrolling()) {
action({ type: "sidebar.focus", item: null });
} else {
// tl.goToEnd();
scrollEl.scrollBy({ top: 9999999 });
list.scrollBy(9999999);
}
},
});
const thread = () => props.thread;
createEffect(on(thread, () => {
tl.setIsAutoscrolling(true);
list.scrollBy(99999);
}));
function getItem(item: TimelineItem) {
switch (item.type) {
case "message": return <Message event={item.event} title={item.separate} />;
case "info": return <ThreadInfo thread={thread()} showHeader={item.header} />;
case "spacer": return <div style={{ "min-height": `${SCROLL_MARGIN}px` }}></div>;
case "spacer-mini": return <div style={{ "min-height": `80px` }}></div>;
}
}
return (
<div class="thread-view" onKeyDown={binds}>
<div class="scroll" onScroll={handleScroll} ref={scrollEl!}>
{<ThreadInfo thread={props.thread} showHeader={tl.isAtBeginning()} />}
{/* FIXME: loading placeholders */}
{false && new Array(5).fill(0).map(() => <div style="min-height:1rem;margin:8px;background:var(--background-3)"></div>)}
{!tl.isAtBeginning() && <div style={`min-height:${SCROLL_MARGIN}px`}></div>}
<For each={tl.events()}>
{(ev, idx) => <Message event={ev} title={shouldSplit(ev, tl.events()![idx() - 1])} classes={{ first: idx() === 0, last: idx() === tl.events().length - 1 }} />}
</For>
{tl.isAtEnd() ? <div class="spacer-bottom"></div> : <div style={`min-height:${SCROLL_MARGIN}px`}></div>}
</div>
<list.List>{getItem}</list.List>
<Input thread={props.thread} />
</div>
);
@ -390,7 +462,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
meta = await new Promise((res) => {
aud.onloadedmetadata = () => {
res({
duration: Math.floor(aud.duration),
duration: Math.floor(aud.duration * 1000),
});
URL.revokeObjectURL(aud.src);
};
@ -403,7 +475,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
meta = await new Promise((res) => {
vid.onloadedmetadata = () => {
res({
duration: Math.floor(vid.duration),
duration: Math.floor(vid.duration * 1000),
height: vid.videoHeight,
width: vid.videoWidth,
});
@ -420,7 +492,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
info: {
name: file.name,
mimetype: file.type,
alt: `a file: ${file.name}`,
// alt: `a file: ${file.name}`,
size: file.size,
...meta,
},
@ -429,7 +501,7 @@ function Input(props: VoidProps<{ thread: Thread }>) {
await props.thread.room.sendEvent("m.message", {
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,
"m.attachments": contentFiles.length ? contentFiles : undefined,
});
setFiles([]);
setTempInputBlocking(false);
@ -486,6 +558,8 @@ function ThreadInfo(props: VoidProps<{ thread: Thread, showHeader: boolean }>) {
onCleanup(() => observer.unobserve(headerEl));
});
const name = () => props.thread.room.getState("m.room.member", props.thread.baseEvent.sender)?.content.displayname || props.thread.baseEvent.sender;
return (
<>
{props.showHeader && <div class="spacer">
@ -502,7 +576,7 @@ function ThreadInfo(props: VoidProps<{ thread: Thread, showHeader: boolean }>) {
</div>
<Show when={props.showHeader}>
<header>
<p>Created by {event().sender} <Time ts={event().originTs} /></p>
<p>Created by {name()} <Time ts={event().originTs} /></p>
<p style="color:var(--foreground-2)">Debug: event_id=<code style="user-select:all">{event().id}</code></p>
</header>
</Show>

3
src/UserSettings.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function UserSettings() {
return <h1>todo</h1>
}

10
src/consts.ts Normal file
View file

@ -0,0 +1,10 @@
export const ROOM_TYPE_DEFAULT = "jw.room";
export const ROOM_TYPE_SPACE = "m.space";
export const EVENT_TYPE_THREAD_CHAT = "m.thread.chat";
export const EVENT_TYPE_THREAD_FORUM = "m.thread.forum";
export const EVENT_TYPE_THREAD_VOICE = "m.thread.voice";
export const EVENT_TYPE_MESSAGE = "m.message";
export const STATE_TYPE_NAME = "m.room.name";
export const STATE_TYPE_TOPIC = "m.room.topic";
export const STATE_TYPE_JOIN_RULES = "m.room.join_rules";