Rework timeline pagination

This commit is contained in:
tezlm 2023-12-19 15:29:34 -08:00
parent 68b7b08ec7
commit d0ca242d1f
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
11 changed files with 321 additions and 220 deletions

View file

@ -15,7 +15,6 @@
"@solid-primitives/scheduled": "^1.4.1", "@solid-primitives/scheduled": "^1.4.1",
"@solidjs/router": "^0.10.2", "@solidjs/router": "^0.10.2",
"@tanstack/solid-virtual": "^3.0.1", "@tanstack/solid-virtual": "^3.0.1",
"@tanstack/virtual-core": "^3.0.1",
"i18next": "^23.7.9", "i18next": "^23.7.9",
"marked": "^11.1.0", "marked": "^11.1.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",

View file

@ -26,9 +26,6 @@ dependencies:
'@tanstack/solid-virtual': '@tanstack/solid-virtual':
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1(solid-js@1.8.7) version: 3.0.1(solid-js@1.8.7)
'@tanstack/virtual-core':
specifier: ^3.0.1
version: 3.0.1
i18next: i18next:
specifier: ^23.7.9 specifier: ^23.7.9
version: 23.7.9 version: 23.7.9
@ -821,10 +818,6 @@ packages:
resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==}
dev: false dev: false
/@tanstack/virtual-core@3.0.1:
resolution: {integrity: sha512-By6TTR3u6rmAWRD7STXqI8WP9q1jYrqVCz88lNTgOf/cUm5cNF6Uj7dej/1+LUj42KMwFusyxGS908HlGBhE2Q==}
dev: false
/@types/babel__core@7.20.5: /@types/babel__core@7.20.5:
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
dependencies: dependencies:

View file

@ -18,7 +18,7 @@
#main { #main {
background: var(--background-1); background: var(--background-1);
grid-area: main; grid-area: main;
contain: content; contain: strict;
overflow: hidden; overflow: hidden;
& > .timeline { & > .timeline {
@ -122,7 +122,7 @@
grid-area: sidebar; grid-area: sidebar;
width: 256px; width: 256px;
overflow-y: auto; overflow-y: auto;
contain: content; contain: strict;
&.thread { &.thread {
width: 512px; width: 512px;
@ -253,7 +253,7 @@
--name-width: 144px; --name-width: 144px;
&.title { &.title {
margin-top: 8px; padding-top: 8px;
} }
& .name { & .name {
@ -342,115 +342,12 @@
} }
} }
.thread-view {
height: 100%;
& > .scroll {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: scroll;
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 {
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;
& > div {
flex: 1;
}
& > button {
display: none;
font-size: 1rem;
margin: 0;
padding: 0 4px;
font-weight: initial;
}
}
&.setup > .real {
transition: all .2s;
visibility: visible;
}
&.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;
}
& > button {
display: block;
}
}
}
& > header {
padding: 8px;
& > p {
opacity: 1;
transition: all .2s;
}
}
}
}
@media (max-width: 1000px) { @media (max-width: 1000px) {
#sidebar.thread { #sidebar.thread {
position: fixed; position: fixed;
right: 0; right: 0;
height: 100%; height: 100%;
box-shadow: -1px 0 5px #1114; box-shadow: -1px 0 5px #1114;
& > .thread-view > .scroll > .spacer {
margin-bottom: 64px;
}
} }
} }

View file

@ -17,10 +17,11 @@ function Wrapper() {
const auth = localStorage.getItem("auth"); const auth = localStorage.getItem("auth");
const client = auth ? new Client(JSON.parse(auth)) : null; const client = auth ? new Client(JSON.parse(auth)) : null;
const isLoggedOut = () => !client || client.state.state === "logout";
return ( return (
<HashRouter root={(props) => <Contextualizer client={client}>{props.children}</Contextualizer>}> <HashRouter root={(props) => <Contextualizer client={client}>{props.children}</Contextualizer>}>
<Route path="/" component={() => <Navigate href={client?.state.state !== "logout" ? "/home" : "/login"} />} /> <Route path="/" component={() => <Navigate href={isLoggedOut() ? "/login" : "/home"} />} />
<Route path="/login" component={Auth} /> <Route path="/login" component={Auth} />
<Route path="/register" component={Auth} /> <Route path="/register" component={Auth} />
<Route path="/home" component={Main} /> <Route path="/home" component={Main} />

View file

@ -1,4 +1,4 @@
import { useLocation, A } from "@solidjs/router"; import { useLocation, A, useNavigate } from "@solidjs/router";
import "./Auth.scss"; import "./Auth.scss";
import { createForm, required } from "@modular-forms/solid"; import { createForm, required } from "@modular-forms/solid";
import { UserId } from "sdk/dist/src/api"; import { UserId } from "sdk/dist/src/api";
@ -7,6 +7,9 @@ import { useGlobals } from "./Context";
export default function Auth() { export default function Auth() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const [globals] = useGlobals();
if (globals.client && globals.client.state.state !== "logout") navigate("/home");
return ( return (
<> <>
<div class="auth"> <div class="auth">
@ -26,7 +29,7 @@ function Login() {
const [_globals, change] = useGlobals(); const [_globals, change] = useGlobals();
const [form, { Form, Field }] = createForm<LoginData>({ const [form, { Form, Field }] = createForm<LoginData>({
initialValues: { initialValues: {
baseUrl: "http://localhost:6167", baseUrl: "https://jw.celery.eu.org",
userId: "@asdf:localhost", userId: "@asdf:localhost",
password: "1234", password: "1234",
} }

View file

@ -6,7 +6,7 @@ import { keymap } from "prosemirror-keymap";
import { autocomplete } from "prosemirror-autocomplete"; import { autocomplete } from "prosemirror-autocomplete";
import "./Editor.scss"; import "./Editor.scss";
import { VoidProps, createSignal, onCleanup, onMount } from "solid-js"; import { For, VoidProps, createSignal, onCleanup, onMount } from "solid-js";
import { Token, marked } from "marked"; import { Token, marked } from "marked";
import { UserId } from "sdk/dist/src/api"; import { UserId } from "sdk/dist/src/api";
@ -40,7 +40,9 @@ const schema = new Schema({
function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) { function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) {
return ( return (
<ul> <ul>
{props.options.map(i => <li>{i}</li>)} <For each={props.options}>
{i => <li>{i}</li>}
</For>
</ul> </ul>
) )
} }

View file

@ -6,11 +6,14 @@ import { Portal } from "solid-js/web";
import { Time } from "./Atoms"; import { Time } from "./Atoms";
import { useGlobals } from "./Context"; import { useGlobals } from "./Context";
import { ThreadView } from "./Thread"; import { ThreadView } from "./Thread";
import { useParams, A } from "@solidjs/router"; import { useParams, A, useNavigate } from "@solidjs/router";
export default function App() { export default function App() {
const params = useParams(); const params = useParams();
const [globals, change] = useGlobals(); const [globals, change] = useGlobals();
const navigate = useNavigate();
if (!globals.client || globals.client.state.state === "logout") return navigate("/login");
const [rooms, setRooms] = createSignal(globals.client.lists.get("rooms") ?? { count: 0, rooms: [] as Array<Room> }); const [rooms, setRooms] = createSignal(globals.client.lists.get("rooms") ?? { count: 0, rooms: [] as Array<Room> });
const [room, { refetch }] = createResource(() => params.roomId, (id) => { const [room, { refetch }] = createResource(() => params.roomId, (id) => {
@ -90,6 +93,7 @@ export function RoomView(props: VoidProps<{ room: Room }>) {
function Threads(props: VoidProps<{ room: Room }>) { function Threads(props: VoidProps<{ room: Room }>) {
const [threadChunk] = createResource(() => props.room, async (room: Room) => { const [threadChunk] = createResource(() => props.room, async (room: Room) => {
console.log("fetch room threads")
return room.threads.paginate(); return room.threads.paginate();
}); });
@ -97,7 +101,7 @@ function Threads(props: VoidProps<{ room: Room }>) {
<div class="timeline threads"> <div class="timeline threads">
<div class="items"> <div class="items">
<RoomHeader room={props.room} /> <RoomHeader room={props.room} />
<Show when={!threadChunk.loading} fallback={"loading..."}> <Show when={!threadChunk.loading}>
<For each={threadChunk()!.threads}> <For each={threadChunk()!.threads}>
{(thread: Thread) => <ThreadsItem thread={thread} />} {(thread: Thread) => <ThreadsItem thread={thread} />}
</For> </For>
@ -139,6 +143,11 @@ function RoomHeader(props: VoidProps<{ room: Room }>) {
const name = () => props.room.getState("m.room.name")?.content.name || "unnamed room"; const name = () => props.room.getState("m.room.name")?.content.name || "unnamed room";
const topic = () => props.room.getState("m.room.topic")?.content.topic || ""; 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);
}
return ( return (
<div class="timeline-create"> <div class="timeline-create">
<h1>Welcome to {name()}!</h1> <h1>Welcome to {name()}!</h1>
@ -146,7 +155,7 @@ function RoomHeader(props: VoidProps<{ room: Room }>) {
<p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p> <p style="color:var(--foreground-2)">Debug: room_id=<code style="user-select:all">{props.room.id}</code></p>
<div class="actions"> <div class="actions">
<button onClick={() => alert("todo!")}>edit room</button> <button onClick={() => alert("todo!")}>edit room</button>
<button onClick={() => alert("todo!")}>invite people</button> <button onClick={invite}>invite people</button>
</div> </div>
</div> </div>
); );

View file

@ -1,25 +1,38 @@
import { Thread } from "sdk"; import { Thread } from "sdk";
import { For, Show, VoidProps, createEffect, createResource, createSignal, onCleanup } from "solid-js"; import { For, Show, Suspense, VoidProps, createEffect, createResource, createSignal, onCleanup } from "solid-js";
import { useGlobals } from "./Context"; import { useGlobals } from "./Context";
import { Text, Time } from "./Atoms"; import { Text, Time } from "./Atoms";
import { ThreadTimeline } from "sdk/dist/src/timeline";
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) { export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
const [globals, change] = useGlobals(); const [globals, change] = useGlobals();
const THREAD_PREVIEW_COUNT = 10; const THREAD_PREVIEW_COUNT = 10;
const [preview, { mutate }] = createResource(props.thread, async (thread) => { const [preview, { mutate }] = createResource(props.thread, async (thread) => {
return thread.timelines.fetch("start"); console.log("fetchthread");
await thread.timelines.fetch("end");
await thread.timelines.fetch("start");
const tl = await thread.timelines.fetch("start");
await thread.timelines.fetch("end");
return tl;
}, { }, {
storage: (init) => createSignal(init, { equals: false }), storage: (init) => createSignal(init, { equals: false }),
}); });
const refresh = () => { const refresh = () => {
// console.log("timeline appended")
mutate(preview()); mutate(preview());
setThread(thread()); setThread(thread());
}; };
props.thread.timelines.live.on("timelineAppend", refresh); let oldTimeline: ThreadTimeline | undefined;
onCleanup(() => props.thread.timelines.live.off("timelineAppend", refresh)); createEffect(() => {
// console.log("set listeners", oldTimeline, preview());
oldTimeline?.off("timelineAppend", refresh);
preview()?.on("timelineAppend", refresh);
oldTimeline = preview();
}, preview());
const [thread, setThread] = createSignal(props.thread, { equals: false }); const [thread, setThread] = createSignal(props.thread, { equals: false });
const remaining = () => preview() && (thread().messageCount - Math.min(preview()!.getEvents().length, THREAD_PREVIEW_COUNT )); const remaining = () => preview() && (thread().messageCount - Math.min(preview()!.getEvents().length, THREAD_PREVIEW_COUNT ));
@ -36,6 +49,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
}; };
return ( return (
<Suspense>
<article class="timeline-thread"> <article class="timeline-thread">
<header onClick={willOpenThread("default")}> <header onClick={willOpenThread("default")}>
<div class="top"> <div class="top">
@ -49,7 +63,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
</header> </header>
<div class="preview"> <div class="preview">
<Show when={!preview.loading}> <Show when={!preview.loading}>
<For each={preview()?.getEvents().filter(ev => ev.type === "m.message").slice(0, THREAD_PREVIEW_COUNT)}> <For each={[props.thread.baseEvent, ...preview()?.getEvents().filter(ev => ev.type === "m.message").slice(0, THREAD_PREVIEW_COUNT - 1) || []]}>
{(ev, idx) => <Message event={ev} title={idx() === 0} />} {(ev, idx) => <Message event={ev} title={idx() === 0} />}
</For> </For>
</Show> </Show>
@ -60,6 +74,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
</footer> </footer>
</Show> </Show>
</article> </article>
</Suspense>
); );
} }
@ -79,7 +94,7 @@ export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fa
return <div innerHTML={body() ? sanitize(body()) : props.fallback}></div>; return <div innerHTML={body() ? sanitize(body()) : props.fallback}></div>;
} }
export function Message(props: VoidProps<{ event: any, title?: boolean }>) { export function Message(props: VoidProps<{ event: any, title?: boolean, classes?: Record<string, boolean> }>) {
const [globals] = useGlobals(); const [globals] = useGlobals();
const title = () => props.title; const title = () => props.title;
const compact = () => globals!.settings.compact; const compact = () => globals!.settings.compact;
@ -89,7 +104,7 @@ export function Message(props: VoidProps<{ event: any, title?: boolean }>) {
} }
return ( return (
<div class="message" classList={{ title: title(), [compact() ? "compact" : "cozy"]: true }} data-event-id={props.event.id}> <div class="message" classList={{ title: title(), [compact() ? "compact" : "cozy"]: true, ...props.classes }} data-event-id={props.event.id}>
{title() && !compact() && <div class="avatar"></div>} {title() && !compact() && <div class="avatar"></div>}
{title() && compact() && <div class="name">{props.event.sender}</div>} {title() && compact() && <div class="name">{props.event.sender}</div>}
<div class="content"> <div class="content">

104
src/Thread.scss Normal file
View file

@ -0,0 +1,104 @@
.thread-view {
height: 100%;
& > .scroll {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: scroll;
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 {
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;
& > div {
flex: 1;
}
& > button {
display: none;
font-size: 1rem;
margin: 0;
padding: 0 4px;
font-weight: initial;
}
}
&.setup > .real {
transition: all .2s;
visibility: visible;
}
&.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;
}
& > button {
display: block;
}
}
}
& > header {
padding: 8px;
& > p {
opacity: 1;
transition: all .2s;
}
}
}
}
@media (max-width: 1000px) {
.thread-view > .scroll > .spacer {
margin-bottom: 64px;
}
}

View file

@ -1,22 +1,130 @@
import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense } from "solid-js"; import { For, ParentProps, Show, VoidProps, createEffect, createResource, createSignal, onCleanup, onMount, lazy, Suspense, createRenderEffect, on } from "solid-js";
import "./App.scss"; import "./App.scss";
import { Client, Event, Room, Thread } from "sdk"; import { Client, Event, EventId, Room, Thread } from "sdk";
import { Message, TextBlock, ThreadsItem } from "./Room"; import { Message, TextBlock, ThreadsItem } from "./Room";
// import { computePosition, shift, offset, autoUpdate } from "@floating-ui/dom"; // import { computePosition, shift, offset, autoUpdate } from "@floating-ui/dom";
// import { Portal } from "solid-js/web"; // import { Portal } from "solid-js/web";
import { Time } from "./Atoms"; import { Time } from "./Atoms";
import { ThreadTimeline } from "sdk/dist/src/timeline"; import { ThreadTimeline, ThreadTimelineSet } from "sdk/dist/src/timeline";
import { debounce } from "@solid-primitives/scheduled"; import { debounce } from "@solid-primitives/scheduled";
import { useGlobals } from "./Context"; import { useGlobals } from "./Context";
import { Virtualizer } from "@tanstack/virtual-core"; import "./Thread.scss";
import { createVirtualizer } from "@tanstack/solid-virtual";
// import { createVirtualizer } from "@tanstack/solid-virtual";
const Editor = lazy(() => import("./Editor")); const Editor = lazy(() => import("./Editor"));
// const Editor = lazy(async () => {
// await new Promise(res => setTimeout(res, 1000)); type TimelineStatus = "loading" | "update" | "ready";
// return import("./Editor");
// }); type SliceInfo = {
start: number,
end: number,
};
// type TimelineItem =
// { type: "event", event: Event } |
// { type: "events", events: Array<Event> }
// TODO: dynamically calculate how many events are needed
const SLICE_COUNT = 72;
// const PAGINATE_COUNT = SLICE_COUNT * 3;
const PAGINATE_COUNT = SLICE_COUNT;
function createTimeline(timelineSet: ThreadTimelineSet, initialTimeline = timelineSet.live) {
// const [items, setEvents] = createSignal<Array<TimelineItem>>([]);
const [events, setEvents] = createSignal<Array<Event>>([]);
const [info, setInfo] = createSignal<SliceInfo | null>(null);
const [status, setStatus] = createSignal<TimelineStatus>("loading");
const [isAtBeginning, setIsAtBeginning] = createSignal(initialTimeline.isAtBeginning && initialTimeline.getEvents().length < SLICE_COUNT);
const [isAtEnd, setIsAtEnd] = createSignal(initialTimeline === timelineSet.live);
const [isAutoscrolling, setIsAutoscrolling] = createSignal(isAtEnd());
let timeline = initialTimeline;
async function init() {
if (timeline.getEvents().length === 0) {
await timeline.paginate("b", 30);
}
const totalEvents = timeline.getEvents().length;
const newStart = Math.max(totalEvents - SLICE_COUNT, 0);
const newEnd = Math.min(newStart + SLICE_COUNT, totalEvents);
setInfo({ start: newStart, end: newEnd });
setStatus("update");
setEvents(timeline.getEvents().slice(newStart, newEnd));
setStatus("ready");
}
init();
async function backwards() {
if (status() !== "ready") return;
if (isAtBeginning()) return;
setStatus("loading");
const currentInfo = info()!;
const count = currentInfo.start < SLICE_COUNT ? await timeline.paginate("b", PAGINATE_COUNT) : 0;
const newStart = Math.max(currentInfo.start + count - SLICE_COUNT / 2, 0);
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);
setStatus("ready");
}
async function forwards() {
if (status() !== "ready") return;
if (isAtEnd()) return;
setStatus("loading");
const currentInfo = info()!;
// const count = currentInfo.start < SLICE_COUNT ? await timeline.paginate("b", PAGINATE_COUNT) : 0;
const count = await timeline.paginate("f", PAGINATE_COUNT);
const newEnd = Math.min(currentInfo.end + count + SLICE_COUNT / 2, timeline.getEvents().length);
const newStart = Math.max(newEnd - SLICE_COUNT, 0);
// const newStart = Math.max(currentInfo.start + count - SLICE_COUNT / 2, 0);
// 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);
setStatus("ready");
}
async function toEvent(_eventId: EventId) {
throw new Error("todo!");
}
async function append(_event: Event) {
if (status() !== "ready") return;
if (!isAutoscrolling()) return;
const currentInfo = info()!;
const newEnd = Math.min(currentInfo.end + 1, timeline.getEvents().length);
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 - 1);
setStatus("ready");
}
timelineSet.live.on("timelineAppend", append);
onCleanup(() => timelineSet.live.off("timelineAppend", append))
return {
events,
status,
backwards,
forwards,
toEvent,
isAtBeginning,
isAtEnd,
isAutoscrolling,
setIsAutoscrolling,
}
}
export function ThreadView(props: VoidProps<{ thread: Thread }>) { export function ThreadView(props: VoidProps<{ thread: Thread }>) {
function handleSubmit(text: string) { function handleSubmit(text: string) {
@ -26,92 +134,57 @@ export function ThreadView(props: VoidProps<{ thread: Thread }>) {
}); });
} }
// updates when the thread changes const tl = createTimeline(props.thread.timelines);
const [timelineReal] = createResource(() => props.thread, async (thread) => {
return thread.timelines.fetch("end", 50);
});
// updates every time there's new events
const [timeline, { mutate }] = createResource(() => timelineReal(), (t) => t, {
storage: (init) => createSignal(init, { equals: false }),
});
let scrollEl: HTMLDivElement; let scrollEl: HTMLDivElement;
let isAutoscrolling = false; let isAutoscrolling = false;
const [isPaginating, setIsPaginating] = createSignal(false); let dir = "backwards";
const [isAtBeginning, setIsAtBeginning] = createSignal(false);
const refresh = () => {
mutate(timeline());
if (isAutoscrolling) scrollEl.scrollBy(0, 999999);
};
let oldTimeline: ThreadTimeline | undefined;
createEffect(() => {
oldTimeline?.off("timelineAppend", refresh);
timelineReal()?.on("timelineAppend", refresh);
oldTimeline = timelineReal();
setIsAtBeginning(timelineReal()?.isAtBeginning || false);
scrollEl.scrollBy(0, 999999);
// paginate();
}, timelineReal);
onCleanup(() => {
oldTimeline?.off("timelineAppend", refresh);
});
const AUTOSCROLL_MARGIN = 5; const AUTOSCROLL_MARGIN = 5;
const PAGINATE_MARGIN = 500; const PAGINATE_MARGIN = 500;
const paginate = async () => { let offsetTop = 0, offsetHeight = 0, scrollRefEl: HTMLDivElement | undefined;
isAutoscrolling = scrollEl.scrollTop + scrollEl.offsetHeight > scrollEl.scrollHeight - AUTOSCROLL_MARGIN; createEffect(on(tl.status, (status) => {
console.log("timeline state", status);
if (status === "update") {
scrollRefEl = (dir === "backwards" ? scrollEl.querySelector(".message.first") : scrollEl.querySelector(".message.last")) as HTMLDivElement | undefined;
offsetTop = scrollRefEl?.offsetTop || 0;
offsetHeight = scrollRefEl?.offsetHeight || 0;
} else if (status === "ready") {
if (isAutoscrolling) {
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?", isAutoscrolling);
console.groupEnd();
scrollEl.scrollBy(0, (newOffsetTop - offsetTop) - (offsetHeight - newOffsetHeight));
}
}
}));
const handleScroll = async () => {
isAutoscrolling = scrollEl.scrollTop + scrollEl.offsetHeight > scrollEl.scrollHeight - AUTOSCROLL_MARGIN && tl.isAtEnd();
tl.setIsAutoscrolling(isAutoscrolling);
if (scrollEl.scrollTop < PAGINATE_MARGIN && !isPaginating() && !isAtBeginning()) { if (tl.status() === "ready") {
const scrollRefEl = scrollEl.querySelector(".message") as HTMLElement | undefined; if (scrollEl.scrollTop < PAGINATE_MARGIN) {
setIsPaginating(true); console.log("scroll backwards");
await timeline()?.paginate("b"); dir = "backwards";
// FIXME: jumps when paginating sometimes await tl.backwards();
const oldOffsetTop = scrollRefEl?.offsetTop || 0; } else if (scrollEl.scrollHeight - scrollEl.offsetHeight - scrollEl.scrollTop < 100) {
refresh(); console.log("scroll forwards");
const newOffsetTop = scrollRefEl?.offsetTop || 0; dir = "forwards";
console.log("scroll diff ", scrollRefEl, newOffsetTop, oldOffsetTop); await tl.forwards();
console.log("element moved by px", newOffsetTop - oldOffsetTop); }
console.log("isAutoscrolling?", isAutoscrolling);
scrollEl.scrollBy(0, newOffsetTop - oldOffsetTop);
setIsPaginating(false);
if (timeline()?.isAtBeginning) setIsAtBeginning(true);
} }
}; };
const handleScroll = () => paginate();
const events = () => timeline()?.getEvents().filter(ev => ev.type === "m.message");
// const eventsLength = () => events()?.length || 0;
onMount(() => {
console.log(scrollEl);
const virt = createVirtualizer({
count: 100,
estimateSize: () => 50,
getScrollElement: () => scrollEl,
});
createEffect(() => console.log(virt.getVirtualItems()));
});
// const items = () => virt.getVirtualItems();
// onMount(() => {
// console.log(items());
// })
// <Show when={timeline() && !timeline.loading}>
// {items().map(it => {
// return <div style={`position:fixed;translate:0 ${it.start}px`}>
// {it.index}
// </div>;
// })}
// </Show>
function shouldSplit(ev: Event, prev: Event) { function shouldSplit(ev: Event, prev: Event) {
if (ev.sender !== prev.sender) return true; if (ev.sender !== prev.sender) return true;
if (ev.originTs - prev.originTs > 1000 * 60 * 5) return true; if (ev.originTs - prev.originTs > 1000 * 60 * 5) return true;
@ -121,12 +194,12 @@ export function ThreadView(props: VoidProps<{ thread: Thread }>) {
return ( return (
<div class="thread-view"> <div class="thread-view">
<div class="scroll" onScroll={handleScroll} ref={scrollEl!}> <div class="scroll" onScroll={handleScroll} ref={scrollEl!}>
<ThreadInfo thread={props.thread} showHeader={isAtBeginning()} /> {<ThreadInfo thread={props.thread} showHeader={tl.isAtBeginning()} />}
{/* FIXME: loading placeholders */} {/* FIXME: loading placeholders */}
{false && (timeline.loading || isPaginating()) && new Array(5).fill(0).map(() => <div style="min-height:1rem;margin:8px;background:var(--background-3)"></div>)} {false && new Array(5).fill(0).map(() => <div style="min-height:1rem;margin:8px;background:var(--background-3)"></div>)}
{!isAtBeginning() && <div style={`min-height:${PAGINATE_MARGIN}px`}></div>} {!tl.isAtBeginning() && <div style={`min-height:${PAGINATE_MARGIN}px`}></div>}
<For each={events()}> <For each={tl.events()}>
{(ev, idx) => <Message event={ev} title={idx() === 0 || shouldSplit(ev, events()![idx() - 1])} />} {(ev, idx) => <Message event={ev} title={true && (idx() === 0 || shouldSplit(ev, tl.events()![idx() - 1]))} classes={{ first: idx() === 0, last: idx() === tl.events().length - 1 }} />}
</For> </For>
<div class="spacer-bottom"></div> <div class="spacer-bottom"></div>
</div> </div>

View file

@ -3,4 +3,9 @@ import solid from 'vite-plugin-solid'
export default defineConfig({ export default defineConfig({
plugins: [solid()], plugins: [solid()],
server: {
proxy: {
"/_matrix": "http://localhost:6167"
}
}
}) })