diff --git a/package.json b/package.json index 103e169..ef19bb2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468f782..2d191a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/App.scss b/src/App.scss index 1a6129e..3d5d4a8 100644 --- a/src/App.scss +++ b/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; - } } } diff --git a/src/App.tsx b/src/App.tsx index f34a240..509a126 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( {props.children}}> - } /> + } /> diff --git a/src/Auth.tsx b/src/Auth.tsx index 3a01d0b..1e9c4d4 100644 --- a/src/Auth.tsx +++ b/src/Auth.tsx @@ -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 ( <>
@@ -26,7 +29,7 @@ function Login() { const [_globals, change] = useGlobals(); const [form, { Form, Field }] = createForm({ initialValues: { - baseUrl: "http://localhost:6167", + baseUrl: "https://jw.celery.eu.org", userId: "@asdf:localhost", password: "1234", } diff --git a/src/Editor.tsx b/src/Editor.tsx index 6b737f8..7ee15f0 100644 --- a/src/Editor.tsx +++ b/src/Editor.tsx @@ -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 }>) { return (
    - {props.options.map(i =>
  • {i}
  • )} + + {i =>
  • {i}
  • } +
) } diff --git a/src/Main.tsx b/src/Main.tsx index b163499..e9d210b 100644 --- a/src/Main.tsx +++ b/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 }); 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 }>) {
- + {(thread: Thread) => } @@ -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 (

Welcome to {name()}!

@@ -146,7 +155,7 @@ function RoomHeader(props: VoidProps<{ room: Room }>) {

Debug: room_id={props.room.id}

- +
); diff --git a/src/Room.tsx b/src/Room.tsx index 0ca67c3..0bb454f 100644 --- a/src/Room.tsx +++ b/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 ( +
@@ -49,7 +63,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
- ev.type === "m.message").slice(0, THREAD_PREVIEW_COUNT)}> + ev.type === "m.message").slice(0, THREAD_PREVIEW_COUNT - 1) || []]}> {(ev, idx) => } @@ -60,6 +74,7 @@ export function ThreadsItem(props: VoidProps<{ thread: Thread }>) {
+
); } @@ -79,7 +94,7 @@ export function TextBlock(props: VoidProps<{ text: any, formatting?: boolean, fa return
; } -export function Message(props: VoidProps<{ event: any, title?: boolean }>) { +export function Message(props: VoidProps<{ event: any, title?: boolean, classes?: Record }>) { 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 ( -
+
{title() && !compact() &&
} {title() && compact() &&
{props.event.sender}
}
diff --git a/src/Thread.scss b/src/Thread.scss new file mode 100644 index 0000000..2bb048b --- /dev/null +++ b/src/Thread.scss @@ -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; + } +} diff --git a/src/Thread.tsx b/src/Thread.tsx index c0bd556..6823543 100644 --- a/src/Thread.tsx +++ b/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 } + +// 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>([]); + const [events, setEvents] = createSignal>([]); + const [info, setInfo] = createSignal(null); + const [status, setStatus] = createSignal("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()); - // }) - - // - // {items().map(it => { - // return
- // {it.index} - //
; - // })} - //
- 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 (
- + {} {/* FIXME: loading placeholders */} - {false && (timeline.loading || isPaginating()) && new Array(5).fill(0).map(() =>
)} - {!isAtBeginning() &&
} - - {(ev, idx) => } + {false && new Array(5).fill(0).map(() =>
)} + {!tl.isAtBeginning() &&
} + + {(ev, idx) => }
diff --git a/vite.config.ts b/vite.config.ts index 4095d9b..3e76db3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,4 +3,9 @@ import solid from 'vite-plugin-solid' export default defineConfig({ plugins: [solid()], + server: { + proxy: { + "/_matrix": "http://localhost:6167" + } + } })