Rework timeline pagination
This commit is contained in:
parent
68b7b08ec7
commit
d0ca242d1f
11 changed files with 321 additions and 220 deletions
|
@ -15,7 +15,6 @@
|
|||
"@solid-primitives/scheduled": "^1.4.1",
|
||||
"@solidjs/router": "^0.10.2",
|
||||
"@tanstack/solid-virtual": "^3.0.1",
|
||||
"@tanstack/virtual-core": "^3.0.1",
|
||||
"i18next": "^23.7.9",
|
||||
"marked": "^11.1.0",
|
||||
"nanoid": "^5.0.4",
|
||||
|
|
|
@ -26,9 +26,6 @@ dependencies:
|
|||
'@tanstack/solid-virtual':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(solid-js@1.8.7)
|
||||
'@tanstack/virtual-core':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
i18next:
|
||||
specifier: ^23.7.9
|
||||
version: 23.7.9
|
||||
|
@ -821,10 +818,6 @@ packages:
|
|||
resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==}
|
||||
dev: false
|
||||
|
||||
/@tanstack/virtual-core@3.0.1:
|
||||
resolution: {integrity: sha512-By6TTR3u6rmAWRD7STXqI8WP9q1jYrqVCz88lNTgOf/cUm5cNF6Uj7dej/1+LUj42KMwFusyxGS908HlGBhE2Q==}
|
||||
dev: false
|
||||
|
||||
/@types/babel__core@7.20.5:
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
dependencies:
|
||||
|
|
109
src/App.scss
109
src/App.scss
|
@ -18,7 +18,7 @@
|
|||
#main {
|
||||
background: var(--background-1);
|
||||
grid-area: main;
|
||||
contain: content;
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
|
||||
& > .timeline {
|
||||
|
@ -122,7 +122,7 @@
|
|||
grid-area: sidebar;
|
||||
width: 256px;
|
||||
overflow-y: auto;
|
||||
contain: content;
|
||||
contain: strict;
|
||||
|
||||
&.thread {
|
||||
width: 512px;
|
||||
|
@ -253,7 +253,7 @@
|
|||
--name-width: 144px;
|
||||
|
||||
&.title {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
& .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) {
|
||||
#sidebar.thread {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
box-shadow: -1px 0 5px #1114;
|
||||
|
||||
& > .thread-view > .scroll > .spacer {
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,10 +17,11 @@ function Wrapper() {
|
|||
|
||||
const auth = localStorage.getItem("auth");
|
||||
const client = auth ? new Client(JSON.parse(auth)) : null;
|
||||
const isLoggedOut = () => !client || client.state.state === "logout";
|
||||
|
||||
return (
|
||||
<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="/register" component={Auth} />
|
||||
<Route path="/home" component={Main} />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useLocation, A } from "@solidjs/router";
|
||||
import { useLocation, A, useNavigate } from "@solidjs/router";
|
||||
import "./Auth.scss";
|
||||
import { createForm, required } from "@modular-forms/solid";
|
||||
import { UserId } from "sdk/dist/src/api";
|
||||
|
@ -7,6 +7,9 @@ import { useGlobals } from "./Context";
|
|||
|
||||
export default function Auth() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [globals] = useGlobals();
|
||||
if (globals.client && globals.client.state.state !== "logout") navigate("/home");
|
||||
return (
|
||||
<>
|
||||
<div class="auth">
|
||||
|
@ -26,7 +29,7 @@ function Login() {
|
|||
const [_globals, change] = useGlobals();
|
||||
const [form, { Form, Field }] = createForm<LoginData>({
|
||||
initialValues: {
|
||||
baseUrl: "http://localhost:6167",
|
||||
baseUrl: "https://jw.celery.eu.org",
|
||||
userId: "@asdf:localhost",
|
||||
password: "1234",
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { keymap } from "prosemirror-keymap";
|
|||
import { autocomplete } from "prosemirror-autocomplete";
|
||||
|
||||
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 { UserId } from "sdk/dist/src/api";
|
||||
|
||||
|
@ -40,7 +40,9 @@ const schema = new Schema({
|
|||
function Autocomplete(props: VoidProps<{ options: Array<UserId> }>) {
|
||||
return (
|
||||
<ul>
|
||||
{props.options.map(i => <li>{i}</li>)}
|
||||
<For each={props.options}>
|
||||
{i => <li>{i}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
15
src/Main.tsx
15
src/Main.tsx
|
@ -6,11 +6,14 @@ import { Portal } from "solid-js/web";
|
|||
import { Time } from "./Atoms";
|
||||
import { useGlobals } from "./Context";
|
||||
import { ThreadView } from "./Thread";
|
||||
import { useParams, A } from "@solidjs/router";
|
||||
import { useParams, A, useNavigate } from "@solidjs/router";
|
||||
|
||||
export default function App() {
|
||||
const params = useParams();
|
||||
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 [room, { refetch }] = createResource(() => params.roomId, (id) => {
|
||||
|
@ -90,6 +93,7 @@ export function RoomView(props: VoidProps<{ room: Room }>) {
|
|||
|
||||
function Threads(props: VoidProps<{ room: Room }>) {
|
||||
const [threadChunk] = createResource(() => props.room, async (room: Room) => {
|
||||
console.log("fetch room threads")
|
||||
return room.threads.paginate();
|
||||
});
|
||||
|
||||
|
@ -97,7 +101,7 @@ function Threads(props: VoidProps<{ room: Room }>) {
|
|||
<div class="timeline threads">
|
||||
<div class="items">
|
||||
<RoomHeader room={props.room} />
|
||||
<Show when={!threadChunk.loading} fallback={"loading..."}>
|
||||
<Show when={!threadChunk.loading}>
|
||||
<For each={threadChunk()!.threads}>
|
||||
{(thread: Thread) => <ThreadsItem thread={thread} />}
|
||||
</For>
|
||||
|
@ -139,6 +143,11 @@ 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);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="timeline-create">
|
||||
<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>
|
||||
<div class="actions">
|
||||
<button onClick={() => alert("todo!")}>edit room</button>
|
||||
<button onClick={() => alert("todo!")}>invite people</button>
|
||||
<button onClick={invite}>invite people</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
31
src/Room.tsx
31
src/Room.tsx
|
@ -1,25 +1,38 @@
|
|||
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 { Text, Time } from "./Atoms";
|
||||
import { ThreadTimeline } from "sdk/dist/src/timeline";
|
||||
|
||||
export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
||||
const [globals, change] = useGlobals();
|
||||
|
||||
const THREAD_PREVIEW_COUNT = 10;
|
||||
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 }),
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
// console.log("timeline appended")
|
||||
mutate(preview());
|
||||
setThread(thread());
|
||||
};
|
||||
|
||||
props.thread.timelines.live.on("timelineAppend", refresh);
|
||||
onCleanup(() => props.thread.timelines.live.off("timelineAppend", refresh));
|
||||
|
||||
let oldTimeline: ThreadTimeline | undefined;
|
||||
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 remaining = () => preview() && (thread().messageCount - Math.min(preview()!.getEvents().length, THREAD_PREVIEW_COUNT ));
|
||||
|
@ -36,6 +49,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<article class="timeline-thread">
|
||||
<header onClick={willOpenThread("default")}>
|
||||
<div class="top">
|
||||
|
@ -49,7 +63,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
</header>
|
||||
<div class="preview">
|
||||
<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} />}
|
||||
</For>
|
||||
</Show>
|
||||
|
@ -60,6 +74,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
|
|||
</footer>
|
||||
</Show>
|
||||
</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>;
|
||||
}
|
||||
|
||||
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 title = () => props.title;
|
||||
const compact = () => globals!.settings.compact;
|
||||
|
@ -89,7 +104,7 @@ export function Message(props: VoidProps<{ event: any, title?: boolean }>) {
|
|||
}
|
||||
|
||||
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="name">{props.event.sender}</div>}
|
||||
<div class="content">
|
||||
|
|
104
src/Thread.scss
Normal file
104
src/Thread.scss
Normal 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;
|
||||
}
|
||||
}
|
253
src/Thread.tsx
253
src/Thread.tsx
|
@ -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 { Client, Event, Room, Thread } from "sdk";
|
||||
import { Client, Event, EventId, Room, Thread } 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";
|
||||
import { ThreadTimeline } from "sdk/dist/src/timeline";
|
||||
import { ThreadTimeline, ThreadTimelineSet } from "sdk/dist/src/timeline";
|
||||
import { debounce } from "@solid-primitives/scheduled";
|
||||
import { useGlobals } from "./Context";
|
||||
import { Virtualizer } from "@tanstack/virtual-core";
|
||||
import { createVirtualizer } from "@tanstack/solid-virtual";
|
||||
// import { createVirtualizer } from "@tanstack/solid-virtual";
|
||||
import "./Thread.scss";
|
||||
|
||||
const Editor = lazy(() => import("./Editor"));
|
||||
// const Editor = lazy(async () => {
|
||||
// await new Promise(res => setTimeout(res, 1000));
|
||||
// return import("./Editor");
|
||||
// });
|
||||
|
||||
type TimelineStatus = "loading" | "update" | "ready";
|
||||
|
||||
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 }>) {
|
||||
function handleSubmit(text: string) {
|
||||
|
@ -26,92 +134,57 @@ export function ThreadView(props: VoidProps<{ thread: Thread }>) {
|
|||
});
|
||||
}
|
||||
|
||||
// updates when the thread changes
|
||||
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 }),
|
||||
});
|
||||
const tl = createTimeline(props.thread.timelines);
|
||||
|
||||
let scrollEl: HTMLDivElement;
|
||||
let isAutoscrolling = false;
|
||||
const [isPaginating, setIsPaginating] = createSignal(false);
|
||||
const [isAtBeginning, setIsAtBeginning] = createSignal(false);
|
||||
let dir = "backwards";
|
||||
|
||||
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 PAGINATE_MARGIN = 500;
|
||||
|
||||
const paginate = async () => {
|
||||
isAutoscrolling = scrollEl.scrollTop + scrollEl.offsetHeight > scrollEl.scrollHeight - AUTOSCROLL_MARGIN;
|
||||
let offsetTop = 0, offsetHeight = 0, scrollRefEl: HTMLDivElement | undefined;
|
||||
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()) {
|
||||
const scrollRefEl = scrollEl.querySelector(".message") as HTMLElement | undefined;
|
||||
setIsPaginating(true);
|
||||
await timeline()?.paginate("b");
|
||||
// FIXME: jumps when paginating sometimes
|
||||
const oldOffsetTop = scrollRefEl?.offsetTop || 0;
|
||||
refresh();
|
||||
const newOffsetTop = scrollRefEl?.offsetTop || 0;
|
||||
console.log("scroll diff ", scrollRefEl, newOffsetTop, oldOffsetTop);
|
||||
console.log("element moved by px", newOffsetTop - oldOffsetTop);
|
||||
console.log("isAutoscrolling?", isAutoscrolling);
|
||||
scrollEl.scrollBy(0, newOffsetTop - oldOffsetTop);
|
||||
setIsPaginating(false);
|
||||
if (timeline()?.isAtBeginning) setIsAtBeginning(true);
|
||||
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 < 100) {
|
||||
console.log("scroll forwards");
|
||||
dir = "forwards";
|
||||
await tl.forwards();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
if (ev.sender !== prev.sender) return true;
|
||||
if (ev.originTs - prev.originTs > 1000 * 60 * 5) return true;
|
||||
|
@ -121,12 +194,12 @@ export function ThreadView(props: VoidProps<{ thread: Thread }>) {
|
|||
return (
|
||||
<div class="thread-view">
|
||||
<div class="scroll" onScroll={handleScroll} ref={scrollEl!}>
|
||||
<ThreadInfo thread={props.thread} showHeader={isAtBeginning()} />
|
||||
{<ThreadInfo thread={props.thread} showHeader={tl.isAtBeginning()} />}
|
||||
{/* 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>)}
|
||||
{!isAtBeginning() && <div style={`min-height:${PAGINATE_MARGIN}px`}></div>}
|
||||
<For each={events()}>
|
||||
{(ev, idx) => <Message event={ev} title={idx() === 0 || shouldSplit(ev, events()![idx() - 1])} />}
|
||||
{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:${PAGINATE_MARGIN}px`}></div>}
|
||||
<For each={tl.events()}>
|
||||
{(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>
|
||||
<div class="spacer-bottom"></div>
|
||||
</div>
|
||||
|
|
|
@ -3,4 +3,9 @@ import solid from 'vite-plugin-solid'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [solid()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/_matrix": "http://localhost:6167"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue