diff --git a/web/src/App.tsx b/web/src/App.tsx index 850562a..ff4a1a4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,7 @@ import Client from "./api/client.ts" import WSClient from "./api/wsclient.ts" import { LoginScreen, VerificationScreen } from "./ui/login" import MainScreen from "./ui/MainScreen.tsx" +import { ClientContext } from "./ui/ClientContext.ts" function App() { const client = useMemo(() => new Client(new WSClient("/_gomuks/websocket")), []) @@ -55,7 +56,7 @@ function App() { } else if (!clientState.is_verified) { return } else { - return + return } } diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index d64cf40..61099f3 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -13,12 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { EventDispatcher } from "../util/eventdispatcher.ts" +import { EventDispatcher, CachedEventDispatcher } from "../util/eventdispatcher.ts" import { CancellablePromise } from "../util/promise.ts" import { RPCEvent } from "./types/hievents.ts" export interface RPCClient { - connect: EventDispatcher + connect: CachedEventDispatcher event: EventDispatcher start(): void stop(): void diff --git a/web/src/api/statestore.ts b/web/src/api/statestore.ts index a637312..50f8648 100644 --- a/web/src/api/statestore.ts +++ b/web/src/api/statestore.ts @@ -58,12 +58,14 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { } export class RoomStateStore { + readonly roomID: RoomID readonly meta: NonNullCachedEventDispatcher readonly timeline = new NonNullCachedEventDispatcher([]) readonly eventsByRowID: Map = new Map() readonly eventsByID: Map = new Map() constructor(meta: DBRoom) { + this.roomID = meta.room_id this.meta = new NonNullCachedEventDispatcher(meta) } diff --git a/web/src/ui/ClientContext.ts b/web/src/ui/ClientContext.ts new file mode 100644 index 0000000..3b87d9c --- /dev/null +++ b/web/src/ui/ClientContext.ts @@ -0,0 +1,19 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { createContext } from "react" +import type Client from "../api/client.ts" + +export const ClientContext = createContext(null) diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index afa5c69..f8b84cb 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -13,23 +13,19 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useState } from "react" -import type Client from "../api/client.ts" +import { useState, use } from "react" import type { RoomID } from "../api/types/hitypes.ts" -import RoomList from "./RoomList.tsx" +import RoomList from "./roomlist/RoomList.tsx" import RoomView from "./RoomView.tsx" +import { ClientContext } from "./ClientContext.ts" import "./MainScreen.css" -export interface MainScreenProps { - client: Client -} - -const MainScreen = ({ client }: MainScreenProps) => { +const MainScreen = () => { const [activeRoomID, setActiveRoomID] = useState(null) - const activeRoom = activeRoomID && client.store.rooms.get(activeRoomID) + const activeRoom = activeRoomID && use(ClientContext)!.store.rooms.get(activeRoomID) return
- - {activeRoom && } + + {activeRoom && }
} diff --git a/web/src/ui/RoomView.tsx b/web/src/ui/RoomView.tsx index e00b7a7..8cd560a 100644 --- a/web/src/ui/RoomView.tsx +++ b/web/src/ui/RoomView.tsx @@ -13,26 +13,20 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import Client from "../api/client.ts" import { RoomStateStore } from "../api/statestore.ts" import { useNonNullEventAsState } from "../util/eventdispatcher.ts" import "./RoomView.css" -import TimelineEvent from "./timeline/TimelineEvent.tsx" +import TimelineView from "./timeline/TimelineView.tsx" -export interface RoomViewProps { - client: Client +interface RoomViewProps { room: RoomStateStore } -const RoomView = ({ client, room }: RoomViewProps) => { +const RoomView = ({ room }: RoomViewProps) => { const roomMeta = useNonNullEventAsState(room.meta) - const timeline = useNonNullEventAsState(room.timeline) return
{roomMeta.room_id} - - {timeline.map(entry => )} +
} diff --git a/web/src/ui/RoomList.tsx b/web/src/ui/roomlist/Entry.tsx similarity index 62% rename from web/src/ui/RoomList.tsx rename to web/src/ui/roomlist/Entry.tsx index 26b549d..70cd1e0 100644 --- a/web/src/ui/RoomList.tsx +++ b/web/src/ui/roomlist/Entry.tsx @@ -13,47 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { useMemo } from "react" -import Client from "../api/client.ts" -import { DBEvent, RoomID } from "../api/types/hitypes.ts" -import { useNonNullEventAsState } from "../util/eventdispatcher.ts" -import { RoomListEntry } from "../api/statestore.ts" -import "./RoomList.css" - -export interface RoomListProps { - client: Client - setActiveRoom: (room_id: RoomID) => void -} - -const RoomList = ({ client, setActiveRoom }: RoomListProps) => { - const roomList = useNonNullEventAsState(client.store.roomList) - const clickRoom = useMemo(() => (evt: React.MouseEvent) => { - const roomID = evt.currentTarget.getAttribute("data-room-id") - if (roomID) { - setActiveRoom(roomID) - } else { - console.warn("No room ID :(", evt.currentTarget) - } - }, [setActiveRoom]) - - return
- {reverseMap(roomList, room => - , - )} -
-} - -function reverseMap(arg: T[], fn: (a: T) => O) { - return arg.map((_, i, arr) => fn(arr[arr.length - i - 1])) -} +import type { RoomListEntry } from "../../api/statestore.ts" +import type { DBEvent } from "../../api/types/hitypes.ts" export interface RoomListEntryProps { - client: Client room: RoomListEntry setActiveRoom: (evt: React.MouseEvent) => void } @@ -85,7 +48,7 @@ const getAvatarURL = (avatar?: string): string | undefined => { return `_gomuks/media/${match[1]}/${match[2]}` } -const RoomEntry = ({ room, setActiveRoom }: RoomListEntryProps) => { +const Entry = ({ room, setActiveRoom }: RoomListEntryProps) => { const previewText = makePreviewText(room.preview_event) return
@@ -98,4 +61,4 @@ const RoomEntry = ({ room, setActiveRoom }: RoomListEntryProps) => {
} -export default RoomList +export default Entry diff --git a/web/src/ui/RoomList.css b/web/src/ui/roomlist/RoomList.css similarity index 100% rename from web/src/ui/RoomList.css rename to web/src/ui/roomlist/RoomList.css diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx new file mode 100644 index 0000000..1a92548 --- /dev/null +++ b/web/src/ui/roomlist/RoomList.tsx @@ -0,0 +1,49 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import React, { use, useMemo } from "react" +import type { RoomID } from "../../api/types/hitypes.ts" +import { useNonNullEventAsState } from "../../util/eventdispatcher.ts" +import { ClientContext } from "../ClientContext.ts" +import Entry from "./Entry.tsx" +import "./RoomList.css" + +interface RoomListProps { + setActiveRoom: (room_id: RoomID) => void +} + +const RoomList = ({ setActiveRoom }: RoomListProps) => { + const roomList = useNonNullEventAsState(use(ClientContext)!.store.roomList) + const clickRoom = useMemo(() => (evt: React.MouseEvent) => { + const roomID = evt.currentTarget.getAttribute("data-room-id") + if (roomID) { + setActiveRoom(roomID) + } else { + console.warn("No room ID :(", evt.currentTarget) + } + }, [setActiveRoom]) + + return
+ {reverseMap(roomList, room => + , + )} +
+} + +function reverseMap(arg: T[], fn: (a: T) => O) { + return arg.map((_, i, arr) => fn(arr[arr.length - i - 1])) +} + +export default RoomList diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 3d2a36c..76f68c6 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -13,10 +13,11 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { RoomViewProps } from "../RoomView.tsx" +import { RoomStateStore } from "../../api/statestore.ts" import "./TimelineEvent.css" -export interface TimelineEventProps extends RoomViewProps { +export interface TimelineEventProps { + room: RoomStateStore eventRowID: number } diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx new file mode 100644 index 0000000..f1936e2 --- /dev/null +++ b/web/src/ui/timeline/TimelineView.tsx @@ -0,0 +1,41 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { use, useMemo } from "react" +import { RoomStateStore } from "../../api/statestore.ts" +import { useNonNullEventAsState } from "../../util/eventdispatcher.ts" +import { ClientContext } from "../ClientContext.ts" +import TimelineEvent from "./TimelineEvent.tsx" + +interface TimelineViewProps { + room: RoomStateStore +} + +const TimelineView = ({ room }: TimelineViewProps) => { + const timeline = useNonNullEventAsState(room.timeline) + const client = use(ClientContext)! + const loadHistory = useMemo(() => () => { + client.loadMoreHistory(room.roomID) + .catch(err => console.error("Failed to load history", err)) + }, [client, room.roomID]) + return
+ + {timeline.map(entry => )} +
+} + +export default TimelineView diff --git a/web/src/util/eventdispatcher.ts b/web/src/util/eventdispatcher.ts index 9daae59..b70f386 100644 --- a/web/src/util/eventdispatcher.ts +++ b/web/src/util/eventdispatcher.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useEffect, useState } from "react" +import { useEffect, useState, useSyncExternalStore } from "react" export function useEventAsState(dispatcher?: EventDispatcher): T | null { const [state, setState] = useState(null) @@ -21,16 +21,30 @@ export function useEventAsState(dispatcher?: EventDispatcher): T | null { return state } +export function useCachedEventAsState(dispatcher: CachedEventDispatcher): T | null { + return useSyncExternalStore( + dispatcher.listenChange, + () => dispatcher.current, + ) +} + export function useNonNullEventAsState(dispatcher: NonNullCachedEventDispatcher): T { - const [state, setState] = useState(dispatcher.current) - useEffect(() => dispatcher.listen(setState), [dispatcher]) - return state + return useSyncExternalStore( + dispatcher.listenChange, + () => dispatcher.current, + ) } export class EventDispatcher { #listeners: ((data: T) => void)[] = [] + listenChange = (listener: () => void) => this.#listen(listener) + listen(listener: (data: T) => void): () => void { + return this.#listen(listener) + } + + #listen(listener: (data: T) => void): () => void { this.#listeners.push(listener) return () => { const idx = this.#listeners.indexOf(listener)