Sketchy ui for creating rooms and threads

This commit is contained in:
tezlm 2023-12-10 08:57:52 -08:00
parent 7b334b3bc6
commit 6e3ede18bf
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
8 changed files with 316 additions and 57 deletions

View file

@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"@floating-ui/dom": "^1.5.3",
"@popperjs/core": "^2.11.8",
"i18next": "^23.7.8",
"sdk": "link:../sdk-ts",

View file

@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@floating-ui/dom':
specifier: ^1.5.3
version: 1.5.3
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@ -567,6 +570,23 @@ packages:
dev: true
optional: true
/@floating-ui/core@1.5.2:
resolution: {integrity: sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==}
dependencies:
'@floating-ui/utils': 0.1.6
dev: false
/@floating-ui/dom@1.5.3:
resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==}
dependencies:
'@floating-ui/core': 1.5.2
'@floating-ui/utils': 0.1.6
dev: false
/@floating-ui/utils@0.1.6:
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
dev: false
/@jridgewell/gen-mapping@0.3.3:
resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==}
engines: {node: '>=6.0.0'}

View file

@ -1,11 +1,12 @@
#root {
display: grid;
overflow: hidden;
height: 100vh;
width: 100vw;
grid-template-areas: "nav-spaces header header header"
"nav-spaces nav-rooms main sidebar"
"status status main sidebar";
grid-template-columns: 64px 256px 1fr 256px;
grid-template-columns: 64px 256px 1fr auto;
grid-template-rows: 64px 1fr 72px;
}
@ -18,13 +19,14 @@
background: var(--background-1);
grid-area: main;
contain: content;
overflow: hidden;
& > .timeline {
height: 100%;
overflow-y: auto;
&.threads > .items {
padding: 8px 0 80px;
padding-bottom: 80px;
}
& > .items {
@ -32,7 +34,7 @@
flex-direction: column;
justify-content: end;
min-height: 100%;
padding: 8px 0 80px;
padding-bottom: 80px;
}
}
@ -108,6 +110,13 @@
background: var(--background-2);
border-left: solid var(--background-3) 1px;
grid-area: sidebar;
width: 256px;
overflow-y: auto;
contain: content;
&.thread {
width: 512px;
}
}
#status {
@ -144,6 +153,16 @@
align-items: center;
gap: 8px;
& > .title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
& > * {
display: inline;
}
}
& > .icon {
background: #34363b;
border-radius: 50%;
@ -198,11 +217,6 @@
.timeline-create {
margin: 32px 32px 8px;
& > h1 {
line-height: 1.1;
margin-bottom: 4px;
}
& > .actions {
display: flex;
gap: 4px;
@ -292,6 +306,10 @@
}
.input {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 72px;
background: var(--background-2);
display: flex;
@ -310,3 +328,87 @@
padding: 4px;
}
}
.thread-view {
height: 100%;
& > .scroll {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: scroll;
scrollbar-color: var(--background-1) var(--background-3);
& > .spacer {
margin-top: auto;
margin-bottom: 32px;
}
& > .spacer-bottom {
margin-top: 72px;
}
& > .thread-title {
position: sticky;
top: -1px;
z-index: 999;
& > * {
padding: 1px 8px 0;
width: 100%;
}
& > .fake {
visibility: hidden;
}
& > .real {
position: absolute;
transition: all .2s;
border-bottom: solid var(--background-3) 0;
}
&.stuck > .real {
background: var(--background-1);
border-bottom: solid var(--background-3) 1px;
font-size: 1.2rem;
padding: 8px;
}
}
& > header {
padding: 8px;
& > p {
opacity: 1;
transition: all .2s;
}
}
}
}
@media (max-width: 1000px) {
#sidebar.thread {
position: fixed;
right: 0;
height: 100%;
box-shadow: -1px 0 5px #1114;
& > .thread-view > .scroll > .spacer {
margin-bottom: 64px;
}
}
}
:root {
scrollbar-color: var(--background-4) var(--background-1);
}
h1 {
line-height: 1.1;
margin-bottom: 4px;
}
.home {
padding: 8px;
}

View file

@ -1,7 +1,7 @@
import { Show, VoidProps, createEffect, createSignal, onCleanup } from "solid-js";
import { Match, Show, Switch, VoidProps, createSignal, onCleanup } from "solid-js";
import "./App.scss";
import { Client, Room } from "sdk";
import { EventTimeline, RoomView } from "./Main";
import { Home, RoomView, ThreadView } from "./Main";
import { Contextualizer, useGlobals } from "./Context";
function Wrapper() {
@ -27,13 +27,6 @@ function App(props: VoidProps<{ client: Client }>) {
const [globals, change] = useGlobals();
const [rooms, setRooms] = createSignal({ count: 0, rooms: [] as Array<Room> });
setTimeout(() => {
change({
type: "focusRoom",
room: props.client.rooms.get("!T7ihGmUDiIW0AckfcL:localhost")!,
});
}, 1000)
Object.assign(globalThis, { client: props.client });
props.client.lists.subscribe("rooms", {
@ -46,16 +39,14 @@ function App(props: VoidProps<{ client: Client }>) {
if (name === "rooms") setRooms({ ...list });
});
createEffect(() => {
console.log(globals.roomState.focusedThread());
})
const thread = () => globals.roomState.focusedThread();
return (
<>
<header id="header">
</header>
<main id="main">
<Show when={globals.globalState.focusedRoom()}>
<Show when={globals.globalState.focusedRoom()} fallback={<Home client={props.client} />}>
<RoomView room={globals.globalState.focusedRoom()!} />
</Show>
</main>
@ -66,11 +57,15 @@ function App(props: VoidProps<{ client: Client }>) {
</ul>
</nav>
<nav id="nav-spaces"></nav>
<div id="sidebar">
<EventTimeline timeline={globals.roomState.focusedThread()?.timeline} />
<div class="input">
<textarea placeholder="input text here"></textarea>
</div>
<div id="sidebar" classList={{ thread: !!thread() }}>
<Switch>
<Match when={thread()}>
<ThreadView thread={thread()!} />
</Match>
<Match when={!thread()}>
empty sidebar
</Match>
</Switch>
</div>
<footer id="status"></footer>
</>

View file

@ -48,8 +48,8 @@ export function Contextualizer(props: ParentProps) {
translation: {
"message.count_one": "{{count}} message",
"message.count_other": "{{count}} messages",
"message.remaining_one": "{{remaining}} more message",
"message.remaining_other": "{{remaining}} more messages",
"message.remaining_one": "{{count}} more message",
"message.remaining_other": "{{count}} more messages",
"timeago": "{{}}",
}
},

View file

@ -1,14 +1,17 @@
import { Show, VoidProps, createEffect, createResource, createSignal } from "solid-js";
import { ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount } from "solid-js";
import "./App.scss";
import { Room, Timeline } from "sdk";
import { Message, ThreadsItem } from "./Room";
import { Client, Room, Thread, Timeline } from "sdk";
import { Message, TextBlock, ThreadsItem } from "./Room";
import { computePosition, shift, offset, autoUpdate } from "@floating-ui/dom";
import { Portal } from "solid-js/web";
import { Time } from "./Atoms";
export function RoomView(props: VoidProps<{ room: Room }>) {
return (
<>
<Threads room={props.room} />
<div class="actions">
<TimelineActions />
<TimelineActions room={props.room} />
</div>
</>
)
@ -24,39 +27,177 @@ function Threads(props: VoidProps<{ room: Room }>) {
<div class="items">
<ThreadsHeader room={props.room} />
<Show when={!threadChunk.loading} fallback={"loading..."}>
{threadChunk().threads.map((thread) => <ThreadsItem thread={thread} />)}
{threadChunk()!.threads.map((thread: Thread) => <ThreadsItem thread={thread} />)}
</Show>
</div>
</div>
);
}
export function EventTimeline(props: VoidProps<{ timeline: Timeline | undefined }>) {
export function Tooltip(props: ParentProps<{ tip: string }>) {
let tipEl: HTMLDivElement;
let contentEl: HTMLDivElement;
onMount(() => queueMicrotask(async () => {
const cleanup = autoUpdate(contentEl, tipEl, () => {
computePosition(contentEl, tipEl, {
middleware: [shift(), offset()],
}).then(pos => {
tipEl.style.translate = `${pos.x}px ${pos.y}px`;
});
});
onCleanup(() => cleanup());
}));
return (
<div class="timeline">
<div class="items">
{props.timeline?.events.map((ev, idx) => <Message event={ev} title={idx === 0} />)}
<>
<span ref={contentEl!}>{props.children}</span>
<Portal>
<div ref={tipEl!} style="position:fixed;top:0;left:0">{props.tip}</div>
</Portal>
</>
)
}
export function ThreadView(props: VoidProps<{ thread: Thread }>) {
let textareaEl: HTMLTextAreaElement;
console.log(props.thread)
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.stopPropagation();
e.preventDefault();
props.thread.room.sendEvent("m.message", {
text: [{ body: textareaEl.value }],
"m.relations": [{ rel_type: "m.thread", event_id: props.thread.id }],
});
textareaEl.value = "";
}
}
const [timeline, { mutate }] = createResource(() => props.thread.timeline, (tl) => tl, {
storage: (init) => createSignal(init, { equals: false }),
});
const refresh = () => mutate(props.thread.timeline);
let oldThread: Thread | undefined;
createEffect(() => {
oldThread?.room.off("timeline", refresh);
props.thread.room.on("timeline", refresh);
oldThread = props.thread;
});
onCleanup(() => oldThread?.room.off("timeline", refresh));
return (
<div class="thread-view">
<div class="scroll">
<ThreadHeader thread={props.thread} />
{timeline() && <EventTimeline timeline={timeline()!} />}
<div class="spacer-bottom"></div>
</div>
<div class="input">
<textarea placeholder="input text here" ref={textareaEl!} onKeyDown={handleKeyDown}></textarea>
</div>
</div>
);
}
function ThreadHeader(props: VoidProps<{ thread: Thread }>) {
const event = () => props.thread.baseEvent;
let headerEl: HTMLHeadingElement;
const [stuck, setStuck] = createSignal(false);
onMount(() => {
const observer = new IntersectionObserver(([e]) => setStuck(!e.isIntersecting), {
threshold: 1,
root: headerEl.parentElement,
});
observer.observe(headerEl);
onCleanup(() => observer.unobserve(headerEl));
});
return (
<>
<div class="spacer"></div>
<div class="thread-title" ref={headerEl!} classList={{ stuck: stuck() }}>
<h1 class="real">
<TextBlock text={event().content.title} fallback="Untitled thread" />
</h1>
<h1 class="fake" aria-hidden="true">
<TextBlock text={event().content.title} fallback="Untitled thread" />
</h1>
</div>
<header>
<p>Created by {event().sender} <Time ts={event().originTs} /></p>
<p style="color:var(--foreground-2)">Debug: event_id=<code style="user-select:all">{event().id}</code></p>
</header>
<Message event={event()} title={true} />
</>
);
}
export function Home(props: VoidProps<{ client: Client }>) {
function handleSubmit(e) {
e.preventDefault();
const { name } = e.target.elements;
props.client.rooms.create({ initialState: [{ type: "m.room.name", content: { name: name.value }, stateKey: ""}] });
name.value = "";
}
return (
<div class="home">
<h1>welcome home</h1>
<form onSubmit={handleSubmit}>
<input type="text" placeholder="name" name="name" />
<input type="submit" value="create room" />
</form>
</div>
);
}
export function EventTimeline(props: VoidProps<{ timeline: Timeline }>) {
return (
<>
{props.timeline.events.filter(ev => ev.type === "m.message").map((ev, idx) => <Message event={ev} title={idx === 0} />)}
</>
);
}
function ThreadsHeader(props: VoidProps<{ room: Room }>) {
const name = () => props.room.getState("m.room.name")?.content.name || "unnamed room";
const topic = () => props.room.getState("m.room.topic")?.content.topic || "";
return (
<div class="timeline-create">
<h1>Welcome to {name()}!</h1>
<p>This is the beginning of this room. Insert room topic here.</p>
<p>This is the beginning of this room. {topic()}</p>
<p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p>
<div class="actions">
<button>edit room</button>
<button>invite people</button>
<button onClick={() => alert("todo!")}>edit room</button>
<button onClick={() => alert("todo!")}>invite people</button>
</div>
</div>
);
}
function TimelineActions() {
function TimelineActions(props: VoidProps<{ room: Room }>) {
async function makeThread() {
const title = prompt("title");
const body = prompt("body");
const thread = await props.room.sendEvent("m.thread.message", {
title: [{ body: title }],
text: [{ body }],
});
await props.room.sendEvent("m.dummy", {
"m.relations": [
{ rel_type: "m.thread", event_id: thread.id },
],
});
}
return <>
<select>
<option>default</option>
@ -66,7 +207,7 @@ function TimelineActions() {
<Toggle enabled="[x] Unread" disabled="[ ] Unread" initial={false} />
<Toggle enabled="[x] Watching" disabled="[ ] Watching" initial={false} />
<div class="create-thread">
<div>New thread</div>
<div onclick={makeThread}>New thread</div>
<div>+</div>
</div>
</>

View file

@ -1,30 +1,30 @@
import { Thread } from "sdk";
import { Show, VoidProps, createResource, onCleanup } from "solid-js";
import { Show, VoidProps, createResource, createSignal, onCleanup } from "solid-js";
import { useGlobals } from "./Context";
import { Text, Time } from "./Atoms";
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
const [_globals, change] = useGlobals();
const [globals, change] = useGlobals();
const [preview, { mutate }] = createResource(props.thread, async (t) => {
await t.timeline.paginate("f");
await t.timeline.paginate("f", 1000);
return t.timeline.events;
}, {
storage: (init) => createSignal(init, { equals: false }),
});
const refresh = () => {
console.log("refresh");
mutate([...props.thread.timeline.events]);
}
const refresh = () => mutate(props.thread.timeline.events);
props.thread.room.on("timeline", refresh);
onCleanup(() => props.thread.room.off("timeline", refresh));
const info = () => props.thread.baseEvent.unsigned["m.relations"]["m.thread"];
const remaining = () => preview() && (info().count - preview()!.length);
const remaining = () => preview() && (info().count - Math.min(preview()!.length, 10));
const willOpenThread = (target: "default" | "latest") => (e: MouseEvent) => {
// TODO: target
const isSame = globals.roomState.focusedThread()?.id === props.thread.id;
change({
type: "focusThread",
thread: props.thread,
thread: isSame ? null : props.thread,
});
console.log("open thread", target);
e.stopPropagation();
@ -35,7 +35,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
<header onClick={willOpenThread("default")}>
<div class="top">
<div class="icon"></div>
<div class="title"><TextBlock text={props.thread.baseEvent.content.title} fallback="Untitled thread" formatting /></div>
<div class="title"><TextBlock text={props.thread.baseEvent.content.title} fallback="Untitled thread" /></div>
<div class="date"><Time ts={props.thread.baseEvent.originTs} /></div>
</div>
<div class="bottom" onClick={willOpenThread("latest")}>
@ -44,19 +44,19 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
</header>
<div class="preview">
<Show when={!preview.loading}>
{preview()?.filter(ev => ev.type === "m.message").map((ev, idx) => <Message event={ev} title={idx === 0} />)}
{preview()?.filter(ev => ev.type === "m.message").slice(0, 10).map((ev, idx) => <Message event={ev} title={idx === 0} />)}
</Show>
</div>
<Show when={remaining()}>
<footer>
<Text remaining={remaining()}>message.remaining</Text>
<Text count={remaining()}>message.remaining</Text>
</footer>
</Show>
</article>
);
}
function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fallback?: string }>) {
export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fallback?: string }>) {
function sanitize(str: string) {
return str
.replace(/&/g, "&amp;")
@ -88,7 +88,7 @@ export function Message(props: VoidProps<{ event: any, title?: boolean }>) {
<div class="content">
{title() && !compact() && <div class="name">{props.event.sender}</div>}
<div class="body">
<TextBlock text={props.event.content.text} />
<TextBlock text={props.event.content.text ?? props.event.content.body} />
</div>
</div>
</div>

View file

@ -43,7 +43,7 @@ button, input, textarea, select {
font: inherit;
}
button, select {
button, input, select {
background: var(--background-2);
border: solid var(--background-3) 1px;
padding: 4px 8px;