Refactors and fixes
This commit is contained in:
parent
858f263292
commit
076ba1f9c1
15 changed files with 556 additions and 268 deletions
|
@ -112,6 +112,7 @@
|
|||
right: 0;
|
||||
height: 100%;
|
||||
box-shadow: -1px 0 5px #1114;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -73,7 +73,7 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
|
|||
|
||||
const [globals, update] = createStore({
|
||||
settings: {
|
||||
compact: false,
|
||||
compact: true,
|
||||
locale: "en",
|
||||
},
|
||||
global: {
|
||||
|
|
|
@ -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)]);
|
||||
}
|
||||
|
||||
|
|
71
src/Main.tsx
71
src/Main.tsx
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -189,4 +189,10 @@
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& > .sender {
|
||||
flex: initial;
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} • <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>
|
||||
|
|
15
src/Room.tsx
15
src/Room.tsx
|
@ -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> • <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", {
|
||||
|
|
|
@ -6,7 +6,15 @@
|
|||
& > nav {
|
||||
width: 256px;
|
||||
background: var(--background-2);
|
||||
padding: 8px;
|
||||
|
||||
& > ul > li {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
background: #fff2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > main {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
148
src/Thread.scss
148
src/Thread.scss
|
@ -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 {
|
||||
}
|
||||
|
|
270
src/Thread.tsx
270
src/Thread.tsx
|
@ -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
3
src/UserSettings.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function UserSettings() {
|
||||
return <h1>todo</h1>
|
||||
}
|
10
src/consts.ts
Normal file
10
src/consts.ts
Normal 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";
|
||||
|
Loading…
Reference in a new issue