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",
|
"@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",
|
||||||
|
|
|
@ -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:
|
||||||
|
|
109
src/App.scss
109
src/App.scss
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
15
src/Main.tsx
15
src/Main.tsx
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
31
src/Room.tsx
31
src/Room.tsx
|
@ -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
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 "./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>
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue