Thread creation mockup and fixed preview timelines

This commit is contained in:
tezlm 2023-12-28 06:11:26 -08:00
parent 8abc0c0148
commit 7f6e719414
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
9 changed files with 509 additions and 316 deletions

View file

@ -20,66 +20,6 @@
grid-area: main;
contain: strict;
overflow: hidden;
& > .timeline {
height: 100%;
overflow-y: auto;
&.threads > .items {
padding-bottom: 80px;
}
& > .items {
display: flex;
flex-direction: column;
justify-content: end;
min-height: 100%;
padding-bottom: 80px;
}
}
& > .actions {
position: absolute;
bottom: 0;
width: 100%;
height: 72px;
// background: linear-gradient(to bottom, transparent, var(--background-2) 10%);
background: var(--background-2);
display: flex;
align-items: center;
padding: 0 8px;
gap: 8px;
border-top: solid var(--background-3) 1px;
& select {
background: var(--background-3);
border: solid var(--background-4) 1px;
&:hover {
background: #fcfcfc11;
}
}
& > .create-thread {
display: inline-flex;
gap: 2px;
& > div {
padding: 4px;
background: #46c;
&:first-child {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
&:last-child {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
}
}
}
}
#nav-rooms {
@ -136,104 +76,6 @@
grid-area: status;
}
.timeline-thread {
--title-font-size: calc(1rem * 1.2);
--info-font-size: calc(1rem * 1);
margin: 4px 32px;
background: var(--background-2);
border: solid var(--background-4) 1px;
// border-radius: 4px;
contain: content;
// &:hover {
// background: #00000022;
// }
& > header {
display: flex;
flex-direction: column;
padding: 4px 8px;
cursor: pointer;
background: var(--background-3);
border-bottom: solid var(--background-4) 1px;
line-height: 1;
& > .top {
display: flex;
align-items: center;
gap: 8px;
& > .title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
& > * {
display: inline;
}
}
& > .icon {
background: #34363b;
border-radius: 50%;
height: 16px;
width: 16px;
}
& > .title {
font-size: 1.2rem;
flex: 1;
}
& > .date {
color: var(--foreground-2);
align-self:flex-end;
}
}
& > .bottom {
align-self: start;
margin-top: 8px;
color: var(--foreground-2);
cursor: pointer;
&:hover {
color: var(--foreground-1);
text-decoration: underline;
}
}
}
& > .preview {
// padding-bottom: 2rem;
}
& > footer {
background: linear-gradient(to bottom, transparent, var(--background-3) 50%);
padding: 4px 8px;
text-align: center;
cursor: pointer;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
&:hover {
background: linear-gradient(to bottom, transparent, var(--background-4) 50%);
}
}
}
.timeline-create {
margin: 32px 32px 8px;
& > .actions {
display: flex;
gap: 4px;
margin-top: 8px;
}
}
.toggle {
padding: 4px;
background: #555;
@ -336,9 +178,9 @@ h1 {
.dropdown {
display: inline-block;
background: var(--background-2);
background: var(--background-3);
border: solid var(--background-4) 1px;
padding: 4px;
padding: 2px 4px;
outline: none;
cursor: pointer;
}

View file

@ -135,7 +135,7 @@ function createSelect<T>() {
};
}
export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean, options: Array<DropdownItem<T>> }>) {
export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean, onSelect?: (item: T | null) => void, options: Array<DropdownItem<T>> }>) {
const [shown, setShown] = createSignal(false);
const [inputEl, setInputEl] = createSignal<HTMLInputElement>();
const [dropdownEl, setDropdownEl] = createSignal<HTMLDivElement>();
@ -146,10 +146,10 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
placement: "bottom",
});
const select_ = createSelect<T>();
const selector = createSelect<T>();
createEffect(() => {
select_.setItems(props.options);
selector.setItems(props.options);
})
createEffect(() => {
@ -161,26 +161,26 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
if (!shown()) {
const idx = props.options.findIndex(i => i.item === selected());
const next = (props.options.length + idx - 1) % props.options.length;
setSelected(() => props.options[next]?.item);
select(props.options[next]?.item);
}
},
"ArrowDown": () => {
if (!shown()) {
const idx = props.options.findIndex(i => i.item === selected());
const next = (idx + 1) % props.options.length;
setSelected(() => props.options[next]?.item);
select(props.options[next]?.item);
}
},
"ArrowUp, Shift-Tab": (e) => {
if (shown()) {
e.preventDefault();
select_.prev();
selector.prev();
}
},
"ArrowDown, Tab": (e) => {
if (shown()) {
e.preventDefault();
select_.next();
selector.next();
}
},
"Escape": (e) => {
@ -192,7 +192,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
"Enter": (e) => {
e.preventDefault();
if (shown()) {
select(select_.getHovered()?.item ?? null);
select(selector.getHovered()?.item ?? null);
} else {
setShown(true);
}
@ -203,19 +203,19 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
e.preventDefault();
if (e.deltaY < 0) {
if (shown()) {
select_.prev();
selector.prev();
} else {
const idx = props.options.findIndex(i => i.item === selected());
const next = (props.options.length + idx - 1) % props.options.length;
setSelected(() => props.options[next]?.item);
select(props.options[next]?.item);
}
} else if (e.deltaY > 0) {
if (shown()) {
select_.next();
selector.next();
} else {
const idx = props.options.findIndex(i => i.item === selected());
const next = (idx + 1) % props.options.length;
setSelected(() => props.options[next]?.item);
select(props.options[next]?.item);
}
}
}
@ -223,6 +223,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
function select(item: T | null) {
setSelected(() => item);
setShown(false);
props.onSelect?.(item);
}
const [value, setValue] = createSignal<string | undefined>(undefined, { equals: false });
@ -246,7 +247,7 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
}}
onInput={(e) => {
const { value } = e.target;
select_.setFilter(e.target.value);
selector.setFilter(e.target.value);
if (value) setShown(true);
}}
onKeyDown={binds}
@ -268,11 +269,11 @@ export function Dropdown<T>(props: VoidProps<{ selected?: T, required?: boolean,
}}
>
<ul>
<For each={select_.getFiltered()} fallback={"no options"}>
<For each={selector.getFiltered()} fallback={"no options"}>
{(entry, idx) => <li
onMouseOver={() => select_.setHovered(entry.obj)}
onMouseOver={() => selector.setHovered(entry.obj)}
onMouseDown={() => select(entry.obj.item)}
classList={{ hovered: entry.obj.item === select_.getHovered()?.item, selected: idx() === selected() }}>
classList={{ hovered: entry.obj.item === selector.getHovered()?.item, selected: idx() === selected() }}>
{entry.obj.view ?? entry.obj.label}
</li>}
</For>

View file

@ -96,7 +96,7 @@ export function Contextualizer(props: ParentProps<{ client: Client | null }>) {
if (globals.locale) {
i18n.changeLanguage(lng);
} else {
const locale = await i18n.init({ lng, debug: true });
const locale = await i18n.init({ lng, debug: false });
update({ locale });
}
if (!i18n.default.hasResourceBundle(lng, "translation") && !lngLoads.has(lng)) {

View file

@ -25,6 +25,10 @@
color: var(--foreground-link);
}
& .header {
font-weight: bold;
}
& .placeholder {
position: absolute;
}

View file

@ -18,8 +18,10 @@ const schema = new Schema({
},
paragraph: {
content: "inline*",
group: "block",
whitespace: "pre",
toDOM: () => ["p", 0],
parseDOM: ["p", "x-html-import"].map(tag => ({ tag, preserveWhitespace: "full" })),
},
mention: {
group: "inline",
@ -48,7 +50,7 @@ function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) {
)
}
export default function Editor(props: VoidProps<{ onSubmit: (data: { text: string, html: string }) => void }>) {
export default function Editor(props: VoidProps<{ onSubmit: (data: { text: string, html: string }) => any }>) {
let editorEl: HTMLDivElement;
const [autocompleteOptions, setAutocompleteOptions] = createSignal<null | Array<string>>(null);
@ -145,12 +147,12 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
text: state.doc.textContent,
html: marked(state.doc.textContent, { breaks: true }) as string,
})
props.onSubmit({
const res = props.onSubmit({
text: state.doc.textContent.trim(),
html: (marked(state.doc.textContent, { breaks: true }) as string).trim(),
});
dispatch?.(state.tr.deleteRange(0, state.doc.nodeSize - 2));
return true;
if (res !== false) dispatch?.(state.tr.deleteRange(0, state.doc.nodeSize - 2));
return !!res;
},
}),
],
@ -174,6 +176,11 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
break;
}
}
case "heading": {
decorations.push(Decoration.inline(pos + 1, pos + ast.depth + 1, { class: "syn" }));
decorations.push(Decoration.inline(pos + ast.depth + 1, pos + ast.raw.length + 1, { class: "header" }));
break;
}
case "em": {
const end = pos + ast.raw.length + 1;
decorations.push(Decoration.inline(pos + 1, pos + 2, { class: "syn" }));
@ -211,21 +218,26 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
}
case "paragraph": ast.tokens?.forEach(walk); return;
case "blockquote": {
// FIXME: breaks on multiline blockquotes "> foo\n> bar"
const synLen = ast.raw.length - ast.text.length;
decorations.push(Decoration.inline(pos, pos + synLen, { class: "syn" }));
pos += synLen;
ast.tokens?.forEach(walk);
return;
}
// case "list": {
// console.log(ast);
// for (const item of ast.items) {
// const synLen = item.raw.length - item.text.length;
// pos += synLen;
// item.tokens.forEach(walk);
// }
// return;
// }
case "list": {
ast.items.forEach(walk);
return;
}
case "list_item": {
const endLen = ast.raw.match(/\n+$/)?.[0].length ?? 0;
const startLen = ast.raw.length - ast.text.length - endLen;
decorations.push(Decoration.inline(pos, pos + startLen, { class: "syn" }));
pos += startLen;
ast.tokens?.forEach(walk);
pos += endLen;
return;
}
// default: console.log(ast);
}
pos += ast.raw.length;
@ -235,6 +247,7 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
return DecorationSet.create(state.doc, decorations);
},
handlePaste(view, _event, slice) {
console.log(slice);
const str = slice.content.textBetween(0, slice.size);
const tr = view.state.tr;
if (/^(https?:\/\/|mailto:)\S+$/i.test(str) && !tr.selection.empty) {
@ -249,7 +262,7 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
return true;
},
transformPastedHTML(html) {
const tmp = document.createElement("div");
const tmp = document.createElement("x-html-import");
tmp.innerHTML = html;
const replacements: Array<[string, (el: HTMLElement) => string]> = [
["b, bold, strong, [style*='font-weight:700']", (el) => `**${el.textContent}**`],
@ -257,18 +270,17 @@ export default function Editor(props: VoidProps<{ onSubmit: (data: { text: strin
["a", (el) => `[${el.textContent}](${el.getAttribute("href")})`],
["code", (el) => `\`${el.textContent}\``],
...[1, 2, 3, 4, 5, 6].map((level) => ["h" + level, (el: HTMLElement) => `${"#".repeat(level)} ${el.textContent}`]) as Array<any>,
["ul", (el) => [...el.children].map(i => "- " + i.textContent).join("\n")],
["ol", (el) => [...el.children].map((i, x) => (x + 1) + ". " + i.textContent).join("\n")],
["ul", (el) => "\n" + [...el.children].map(i => "- " + i.textContent).join("\n") + "\n"],
["ol", (el) => "\n" + [...el.children].map((i, x) => (x + 1) + ". " + i.textContent).join("\n") + "\n"],
["br", () => "\n"],
["p, span, div", (el) => el.textContent || ""],
];
for (const [selector, replacement] of replacements) {
for (const el of tmp.querySelectorAll(selector)) {
el.replaceWith(replacement(el as HTMLElement));
}
}
console.log({ html, md: tmp.innerHTML });
return tmp.innerHTML;
console.log({ html, md: tmp.outerHTML });
return tmp.outerHTML;
}
});
view.focus();

View file

@ -1,7 +1,7 @@
import { Component, For, JSX, Match, Show, Switch, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount } from "solid-js";
import "./App.scss";
import { Client, Room, RoomList, Thread } from "sdk";
import { ThreadsItem } from "./Room";
import { RoomView, ThreadsItem } from "./Room";
import { Portal } from "solid-js/web";
import { Dialog, Dropdown, Text, Time, Tooltip } from "./Atoms";
import { useGlobals } from "./Context";
@ -55,7 +55,7 @@ export default function App(): JSX.Element {
return (
<>
<header id="header"></header>
<main id="main">
<main id="main" class={room() ? "room" : "home"}>
<Switch>
<Match when={!params.roomId}><Home client={globals.client} /></Match>
<Match when={!room()}>please wait...</Match>
@ -100,44 +100,6 @@ export default function App(): JSX.Element {
)
}
export function RoomView(props: VoidProps<{ room: Room }>) {
return (
<>
<Threads room={props.room} />
<div class="actions">
<RoomActions room={props.room} />
</div>
</>
)
}
function Threads(props: VoidProps<{ room: Room }>) {
const [paginator, { mutate }] = createResource(() => props.room, async (room: Room) => {
console.log("fetch room threads")
return room.threads.paginate();
}, {
storage: (init) => createSignal(init, { equals: false}),
});
const threads = () => paginator()?.list ?? [];
return (
<div class="timeline threads">
<div class="items">
<RoomHeader room={props.room} />
<Show when={!paginator.loading}>
<Show when={!paginator()?.isAtEnd}>
<button onClick={() => paginator()?.paginate().then(() => mutate(paginator))}>load more</button>
</Show>
<For each={threads()}>
{(thread: Thread) => <ThreadsItem thread={thread} />}
</For>
</Show>
</div>
</div>
);
}
const ROOM_TYPE_CHAT = "jw.chat";
const ROOM_TYPE_SPACE = "m.space";
@ -189,71 +151,9 @@ export function Home(props: VoidProps<{ client: Client }>) {
<p>In the year 2050, the world has made significant advancements in technology and sustainability. Cities are now powered by renewable energy sources, and transportation relies heavily on electric and autonomous vehicles. The concept of "smart cities" has become a reality, with advanced AI systems managing traffic, waste management, and public</p>
</div>
</div>
<br />
<br />
<div style="height: 10000px;"></div>
</div>
);
}
function RoomHeader(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 || "";
function invite() {
const uid = prompt("user id");
if (uid) props.room.client.net.roomInvite(props.room.id, uid);
}
const navigate = useNavigate();
return (
<div class="timeline-create">
<h1><Text name={name()}>room.welcome</Text></h1>
<p><Text topic={topic()}>room.topic</Text></p>
<p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p>
<div class="actions">
<button onClick={() => navigate(`/rooms/${props.room.id}/settings`)}>edit room</button>
<button onClick={invite}>invite people</button>
</div>
</div>
);
}
function RoomActions(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 }],
});
await props.room.sendEvent("m.message", {
text: [{ body }],
"m.relations": [
{ rel_type: "m.thread", event_id: thread.id },
],
});
}
return <>
<select>
<option>default</option>
<option>include ignoring</option>
<option>only watching</option>
</select>
<Toggle enabled="[x] Unread" disabled="[ ] Unread" initial={false} />
<Toggle enabled="[x] Watching" disabled="[ ] Watching" initial={false} />
<div class="create-thread">
<div onclick={makeThread}>New thread</div>
<div>+</div>
</div>
</>
}
function Toggle(props: { enabled: string, disabled: string, initial: boolean }) {
const [disabled, setDisabled] = createSignal(props.initial);
return (
<div
class="toggle"
classList={{ disabled: disabled() }}
onClick={() => setDisabled(!disabled())}
>{disabled() ? props.enabled : props.disabled}</div>
)
}

168
src/Room.scss Normal file
View file

@ -0,0 +1,168 @@
.room {
& > .timeline {
height: 100%;
overflow-y: auto;
overflow-anchor: none;
&.threads > .items {
padding-bottom: 80px;
}
& > .items {
display: flex;
flex-direction: column;
justify-content: end;
min-height: 100%;
padding-bottom: 80px;
}
}
& > .actions {
position: absolute;
bottom: 0;
width: 100%;
height: 72px;
// background: linear-gradient(to bottom, transparent, var(--background-2) 10%);
background: var(--background-2);
display: flex;
align-items: center;
padding: 0 8px;
gap: 8px;
border-top: solid var(--background-3) 1px;
& select {
background: var(--background-3);
border: solid var(--background-4) 1px;
&:hover {
background: #fcfcfc11;
}
}
& > .create-thread {
display: inline-flex;
gap: 2px;
& > div {
padding: 4px;
background: #46c;
&:first-child {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
&:last-child {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
}
}
}
}
.timeline-thread {
--title-font-size: calc(1rem * 1.2);
--info-font-size: calc(1rem * 1);
margin: 4px 32px;
background: var(--background-2);
border: solid var(--background-4) 1px;
// border-radius: 4px;
contain: content;
// &:hover {
// background: #00000022;
// }
&.creating {
border: none;
& > .dropdown, input {
background: var(--background-1);
}
}
& > header {
display: flex;
flex-direction: column;
padding: 4px 8px;
cursor: pointer;
background: var(--background-3);
border-bottom: solid var(--background-4) 1px;
line-height: 1;
& > .top {
display: flex;
align-items: center;
gap: 8px;
& > .title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
& > * {
display: inline;
}
}
& > .icon {
background: #34363b;
border-radius: 50%;
height: 16px;
width: 16px;
}
& > .title {
font-size: 1.2rem;
flex: 1;
}
& > .date {
color: var(--foreground-2);
align-self:flex-end;
}
}
& > .bottom {
align-self: start;
margin-top: 8px;
color: var(--foreground-2);
cursor: pointer;
&:hover {
color: var(--foreground-1);
text-decoration: underline;
}
}
}
& > .preview {
// padding-bottom: 2rem;
}
& > footer {
background: linear-gradient(to bottom, transparent, var(--background-3) 50%);
padding: 4px 8px;
text-align: center;
cursor: pointer;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
&:hover {
background: linear-gradient(to bottom, transparent, var(--background-4) 50%);
}
}
}
.timeline-create {
margin: 32px 32px 8px;
& > .actions {
display: flex;
gap: 4px;
margin-top: 8px;
}
}

View file

@ -1,35 +1,248 @@
import { Thread } from "sdk";
import { For, JSX, Match, Show, Suspense, Switch, VoidProps, createEffect, createResource, createSignal, onCleanup } from "solid-js";
import { Room, Thread, ThreadPaginator } from "sdk";
import { Accessor, For, JSX, Match, Show, Suspense, Switch, VoidProps, createContext, createEffect, createResource, createSignal, lazy, on, onCleanup, onMount, useContext } from "solid-js";
import { useGlobals } from "./Context";
import { Text, Time } from "./Atoms";
import { Dropdown, Text, Time } from "./Atoms";
import { ThreadTimeline } from "sdk/dist/src/timeline";
import { shouldSplit } from "./util";
import { MediaId } from "sdk/dist/src/api";
import { Message, TextBlock } from "./Message";
import { useNavigate } from "@solidjs/router";
import { SetStoreFunction, createStore } from "solid-js/store";
import "./Room.scss";
const Editor = lazy(() => import("./Editor"));
type CreatingThread = {
type: "chat" | "voice",
title: string,
message: string,
attachments?: Array<File>,
};
interface RoomState {
creating?: CreatingThread,
scrollBy?: (x: number, y: number) => void,
}
const RoomContext = createContext<[RoomState, SetStoreFunction<RoomState>]>();
export function RoomView(props: VoidProps<{ room: Room }>) {
const [store, setStore] = createStore<RoomState>({});
return (
<RoomContext.Provider value={[store, setStore]}>
<Threads room={props.room} />
<div class="actions">
<RoomActions room={props.room} />
</div>
</RoomContext.Provider>
)
}
function RoomActions(props: VoidProps<{ room: Room }>) {
const [_store, setStore] = useContext(RoomContext)!;
async function makeThread() {
setStore({
creating: {
type: "chat",
title: "",
message: "",
attachments: [],
},
});
}
return (
<>
<Dropdown options={[
{ item: "default", label: "default" },
{ item: "ignoring", label: "include ignoring" },
{ item: "watching", label: "only watching" },
]} />
<Toggle enabled="[x] Unread" disabled="[ ] Unread" initial={false} />
<Toggle enabled="[x] Watching" disabled="[ ] Watching" initial={false} />
<div class="create-thread">
<div onclick={makeThread}>New thread</div>
<div>+</div>
</div>
</>
);
}
function RoomHeader(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 || "";
function invite() {
const uid = prompt("user id");
if (uid) props.room.client.net.roomInvite(props.room.id, uid);
}
const navigate = useNavigate();
return (
<div class="timeline-create">
<h1><Text name={name()}>room.welcome</Text></h1>
<p><Text topic={topic()}>room.topic</Text></p>
<p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p>
<div class="actions">
<button onClick={() => navigate(`/rooms/${props.room.id}/settings`)}>edit room</button>
<button onClick={invite}>invite people</button>
</div>
</div>
);
}
function Toggle(props: { enabled: string, disabled: string, initial: boolean }) {
const [disabled, setDisabled] = createSignal(props.initial);
return (
<div
class="toggle"
classList={{ disabled: disabled() }}
onClick={() => setDisabled(!disabled())}
>{disabled() ? props.enabled : props.disabled}</div>
)
}
type LoadingStatus = "loading" | "update" | "ready";
function createThreads(initialRoom: Room) {
const [room, setRoom] = createSignal(initialRoom);
const [threads, setThreads] = createSignal<Array<Thread>>([]);
const [status, setStatus] = createSignal<LoadingStatus>("loading");
const [location, setLocation] = createSignal({ atStart: false, atEnd: false });
let paginator: ThreadPaginator;
// FIXME: thread mangling when switching rooms quickly
async function init() {
paginator = await room().threads.paginate();
setStatus("update");
setThreads(paginator.list);
setLocation({ atStart: paginator.isAtEnd, atEnd: true });
setStatus("ready");
}
async function backwards() {
if (status() !== "ready") return;
setStatus("loading");
await paginator.paginate();
await Promise.all(paginator.list.map(t => t.timelines.fetch("end", 1)));
await Promise.all(paginator.list.map(t => t.timelines.fetch("start", 10)));
setStatus("update");
setThreads([...paginator.list]);
setLocation({ atStart: paginator.isAtEnd, atEnd: true });
setStatus("ready");
}
function forwards() {
if (status() !== "ready") return;
// TODO: virtual scrolling?
}
function handleThread(_thread: Thread) {
// TODO
}
let oldRoom: Room;
createEffect(() => {
oldRoom?.off("thread", handleThread);
room().on("thread", handleThread);
oldRoom = room();
setStatus("loading");
init();
});
return {
getThreads: threads,
getStatus: status,
getLocation: location,
setRoom,
backwards,
forwards,
};
}
function Threads(props: VoidProps<{ room: Room }>) {
const [store, setStore] = useContext(RoomContext)!;
const th = createThreads(props.room);
let scrollEl: HTMLDivElement;
createEffect(() => {
th.setRoom(props.room);
scrollEl?.scrollBy(0, 999999);
});
let scrollRefEl: HTMLDivElement | null = null;
let offsetTop: number;
createEffect(on(th.getStatus, (status) => {
if (status === "update") {
scrollRefEl = scrollEl.querySelector(".timeline-thread") as HTMLDivElement | null;
offsetTop = scrollRefEl?.offsetTop || 0;
} else if (status === "ready") {
setTimeout(() => {
const newOffsetTop = scrollRefEl?.offsetTop || 0;
scrollEl.scrollBy(0, newOffsetTop - offsetTop);
});
}
}));
onMount(() => {
setStore({ scrollBy: scrollEl.scrollBy.bind(scrollEl) });
});
return (
<div class="timeline threads" ref={scrollEl!}>
<div class="items">
<Show when={th.getLocation().atStart}>
<RoomHeader room={props.room} />
</Show>
<Show when={!th.getLocation().atStart}>
<button onClick={() => th.backwards()}>backwards</button>
</Show>
<Show when={!th.getLocation().atEnd}>
<button onClick={() => th.forwards()}>forwards</button>
</Show>
<For each={th.getThreads()}>
{(thread: Thread) => <ThreadsItem thread={thread} />}
</For>
<Show when={th.getLocation().atEnd && store.creating}>
<ThreadsBuilder room={props.room} />
</Show>
</div>
</div>
);
}
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
const [globals, change] = useGlobals();
const [roomContext, _setStore] = useContext(RoomContext)!;
const THREAD_PREVIEW_COUNT = 10;
const [preview, { mutate }] = createResource(props.thread, async (thread) => {
return await thread.timelines.fetch("start");
await thread.timelines.fetch("end", 1);
const tl = await thread.timelines.fetch("start", 10);
return tl;
}, {
storage: (init) => createSignal(init, { equals: false }),
});
let wrapperEl: HTMLElement;
const refresh = () => {
// console.log("timeline appended")
let offsetHeight = wrapperEl.offsetHeight;
mutate(preview());
setThread(thread());
roomContext.scrollBy?.(0, wrapperEl.offsetHeight - offsetHeight);
};
let oldTimeline: ThreadTimeline | undefined;
createEffect(() => {
// console.log("set listeners", oldTimeline, preview());
oldTimeline?.off("timelineAppend", refresh);
preview()?.on("timelineAppend", refresh);
const replace = () => console.log("replace");
oldTimeline?.off("timelineReplace", replace);
preview()?.on("timelineReplace", replace);
oldTimeline = preview();
}, preview());
});
const [thread, setThread] = createSignal(props.thread, { equals: false });
const remaining = () => preview() && (thread().messageCount - Math.min(preview()!.getEvents().length, THREAD_PREVIEW_COUNT ));
@ -37,6 +250,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
const willOpenThread = (target: "default" | "latest") => (e: MouseEvent) => {
// TODO: target
const isSame = (globals.global.sidebar as Thread)?.id === props.thread.id;
if (!isSame) props.thread.timelines.fetch("end", 50);
change({
type: "sidebar.focus",
item: isSame ? null : props.thread,
@ -46,8 +260,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
};
return (
<Suspense>
<article class="timeline-thread">
<article class="timeline-thread" ref={wrapperEl!}>
<header onClick={willOpenThread("default")}>
<div class="top">
<div class="icon"></div>
@ -71,6 +284,54 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
</footer>
</Show>
</article>
</Suspense>
);
}
function ThreadsBuilder(props: VoidProps<{ room: Room }>) {
const [store, setStore] = useContext(RoomContext)!;
function handleSelect(item: string | null) {
setStore({ creating: item ? { ...store.creating, type: item } as CreatingThread : undefined });
}
function handleTitle(title: string) {
setStore({ creating: { ...store.creating, title } as CreatingThread });
}
async function handleSubmit({ text, html }: { text: string, html: string }) {
if (!store.creating) return false;
if (!store.creating.title) return false;
if (!text) return false;
const thread = await props.room.sendEvent("m.thread.message", {
title: [{ body: store.creating.title }],
});
await props.room.sendEvent("m.message", {
text: [
{ body: text },
{ body: html, type: "text/html" },
],
"m.relations": [
{ rel_type: "m.thread", event_id: thread.id },
],
});
setStore({ creating: undefined });
}
return (
<div class="timeline-thread creating">
<header>create a thread</header>
<div class="preview" style="min-height:100px;padding:8px;">
<button onClick={() => setStore({ creating: undefined })}>cancel</button>
<h3>thread type</h3>
<Dropdown required onSelect={handleSelect} selected={store.creating?.type} options={[
{ item: "chat", label: "message" },
{ item: "voice", label: "voice" },
]} />
<h3>thread title</h3>
<input onInput={(e) => handleTitle(e.target.value)} autofocus />
<h3>thread body</h3>
<Editor onSubmit={handleSubmit} />
</div>
</div>
);
}

View file

@ -100,12 +100,12 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
throw new Error("todo!");
}
async function append(_event: Event) {
// async function append(_event: Event) {
async function append() {
if (status() !== "ready") return;
if (!isAutoscrolling()) return;
const currentInfo = info()!;
const newEnd = Math.min(currentInfo.end + 1, timeline().getEvents().length);
const newEnd = timeline().getEvents().length;
const newStart = Math.max(newEnd - SLICE_COUNT, 0);
setInfo({ start: newStart, end: newEnd });
setStatus("update");
@ -118,11 +118,16 @@ function createTimeline(timelineSet: Accessor<ThreadTimelineSet>) {
let oldTimeline: ThreadTimeline;
createEffect(() => {
timeline().on("timelineAppend", append);
oldTimeline?.off("timelineAppend", append)
timeline().on("timelineUpdate", append);
oldTimeline?.off("timelineAppend", append);
oldTimeline?.off("timelineUpdate", append);
oldTimeline = timeline();
});
onCleanup(() => oldTimeline?.off("timelineAppend", append));
onCleanup(() => {
oldTimeline?.off("timelineAppend", append);
oldTimeline?.off("timelineUpdate", append);
});
return {
events,