web: more file reorganization

This commit is contained in:
Tulir Asokan 2024-10-08 00:42:41 +03:00
parent 929bfbc882
commit da675fb578
12 changed files with 151 additions and 71 deletions

View file

@ -20,6 +20,7 @@ import Client from "./api/client.ts"
import WSClient from "./api/wsclient.ts" import WSClient from "./api/wsclient.ts"
import { LoginScreen, VerificationScreen } from "./ui/login" import { LoginScreen, VerificationScreen } from "./ui/login"
import MainScreen from "./ui/MainScreen.tsx" import MainScreen from "./ui/MainScreen.tsx"
import { ClientContext } from "./ui/ClientContext.ts"
function App() { function App() {
const client = useMemo(() => new Client(new WSClient("/_gomuks/websocket")), []) const client = useMemo(() => new Client(new WSClient("/_gomuks/websocket")), [])
@ -55,7 +56,7 @@ function App() {
} else if (!clientState.is_verified) { } else if (!clientState.is_verified) {
return <VerificationScreen client={client} clientState={clientState}/> return <VerificationScreen client={client} clientState={clientState}/>
} else { } else {
return <MainScreen client={client} /> return <ClientContext value={client}><MainScreen /></ClientContext>
} }
} }

View file

@ -13,12 +13,12 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { EventDispatcher } from "../util/eventdispatcher.ts" import { EventDispatcher, CachedEventDispatcher } from "../util/eventdispatcher.ts"
import { CancellablePromise } from "../util/promise.ts" import { CancellablePromise } from "../util/promise.ts"
import { RPCEvent } from "./types/hievents.ts" import { RPCEvent } from "./types/hievents.ts"
export interface RPCClient { export interface RPCClient {
connect: EventDispatcher<ConnectionEvent> connect: CachedEventDispatcher<ConnectionEvent>
event: EventDispatcher<RPCEvent> event: EventDispatcher<RPCEvent>
start(): void start(): void
stop(): void stop(): void

View file

@ -58,12 +58,14 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
} }
export class RoomStateStore { export class RoomStateStore {
readonly roomID: RoomID
readonly meta: NonNullCachedEventDispatcher<DBRoom> readonly meta: NonNullCachedEventDispatcher<DBRoom>
readonly timeline = new NonNullCachedEventDispatcher<TimelineRowTuple[]>([]) readonly timeline = new NonNullCachedEventDispatcher<TimelineRowTuple[]>([])
readonly eventsByRowID: Map<EventRowID, DBEvent> = new Map() readonly eventsByRowID: Map<EventRowID, DBEvent> = new Map()
readonly eventsByID: Map<EventID, DBEvent> = new Map() readonly eventsByID: Map<EventID, DBEvent> = new Map()
constructor(meta: DBRoom) { constructor(meta: DBRoom) {
this.roomID = meta.room_id
this.meta = new NonNullCachedEventDispatcher(meta) this.meta = new NonNullCachedEventDispatcher(meta)
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
import { createContext } from "react"
import type Client from "../api/client.ts"
export const ClientContext = createContext<Client | null>(null)

View file

@ -13,23 +13,19 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useState } from "react" import { useState, use } from "react"
import type Client from "../api/client.ts"
import type { RoomID } from "../api/types/hitypes.ts" 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 RoomView from "./RoomView.tsx"
import { ClientContext } from "./ClientContext.ts"
import "./MainScreen.css" import "./MainScreen.css"
export interface MainScreenProps { const MainScreen = () => {
client: Client
}
const MainScreen = ({ client }: MainScreenProps) => {
const [activeRoomID, setActiveRoomID] = useState<RoomID | null>(null) const [activeRoomID, setActiveRoomID] = useState<RoomID | null>(null)
const activeRoom = activeRoomID && client.store.rooms.get(activeRoomID) const activeRoom = activeRoomID && use(ClientContext)!.store.rooms.get(activeRoomID)
return <main className="matrix-main"> return <main className="matrix-main">
<RoomList client={client} setActiveRoom={setActiveRoomID} /> <RoomList setActiveRoom={setActiveRoomID} />
{activeRoom && <RoomView client={client} room={activeRoom} />} {activeRoom && <RoomView key={activeRoomID} room={activeRoom} />}
</main> </main>
} }

View file

@ -13,26 +13,20 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import Client from "../api/client.ts"
import { RoomStateStore } from "../api/statestore.ts" import { RoomStateStore } from "../api/statestore.ts"
import { useNonNullEventAsState } from "../util/eventdispatcher.ts" import { useNonNullEventAsState } from "../util/eventdispatcher.ts"
import "./RoomView.css" import "./RoomView.css"
import TimelineEvent from "./timeline/TimelineEvent.tsx" import TimelineView from "./timeline/TimelineView.tsx"
export interface RoomViewProps { interface RoomViewProps {
client: Client
room: RoomStateStore room: RoomStateStore
} }
const RoomView = ({ client, room }: RoomViewProps) => { const RoomView = ({ room }: RoomViewProps) => {
const roomMeta = useNonNullEventAsState(room.meta) const roomMeta = useNonNullEventAsState(room.meta)
const timeline = useNonNullEventAsState(room.timeline)
return <div className="room-view"> return <div className="room-view">
{roomMeta.room_id} {roomMeta.room_id}
<button onClick={() => client.loadMoreHistory(roomMeta.room_id)}>Load history</button> <TimelineView room={room} />
{timeline.map(entry => <TimelineEvent
key={entry.event_rowid} client={client} room={room} eventRowID={entry.event_rowid}
/>)}
</div> </div>
} }

View file

@ -13,47 +13,10 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { useMemo } from "react" import type { RoomListEntry } from "../../api/statestore.ts"
import Client from "../api/client.ts" import type { DBEvent } from "../../api/types/hitypes.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 <div className="room-list">
{reverseMap(roomList, room =>
<RoomEntry
key={room.room_id}
client={client}
room={room}
setActiveRoom={clickRoom}
/>,
)}
</div>
}
function reverseMap<T, O>(arg: T[], fn: (a: T) => O) {
return arg.map((_, i, arr) => fn(arr[arr.length - i - 1]))
}
export interface RoomListEntryProps { export interface RoomListEntryProps {
client: Client
room: RoomListEntry room: RoomListEntry
setActiveRoom: (evt: React.MouseEvent) => void setActiveRoom: (evt: React.MouseEvent) => void
} }
@ -85,7 +48,7 @@ const getAvatarURL = (avatar?: string): string | undefined => {
return `_gomuks/media/${match[1]}/${match[2]}` return `_gomuks/media/${match[1]}/${match[2]}`
} }
const RoomEntry = ({ room, setActiveRoom }: RoomListEntryProps) => { const Entry = ({ room, setActiveRoom }: RoomListEntryProps) => {
const previewText = makePreviewText(room.preview_event) const previewText = makePreviewText(room.preview_event)
return <div className="room-entry" onClick={setActiveRoom} data-room-id={room.room_id}> return <div className="room-entry" onClick={setActiveRoom} data-room-id={room.room_id}>
<div className="room-entry-left"> <div className="room-entry-left">
@ -98,4 +61,4 @@ const RoomEntry = ({ room, setActiveRoom }: RoomListEntryProps) => {
</div> </div>
} }
export default RoomList export default Entry

View file

@ -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 <https://www.gnu.org/licenses/>.
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 <div className="room-list">
{reverseMap(roomList, room =>
<Entry key={room.room_id} room={room} setActiveRoom={clickRoom}/>,
)}
</div>
}
function reverseMap<T, O>(arg: T[], fn: (a: T) => O) {
return arg.map((_, i, arr) => fn(arr[arr.length - i - 1]))
}
export default RoomList

View file

@ -13,10 +13,11 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RoomViewProps } from "../RoomView.tsx" import { RoomStateStore } from "../../api/statestore.ts"
import "./TimelineEvent.css" import "./TimelineEvent.css"
export interface TimelineEventProps extends RoomViewProps { export interface TimelineEventProps {
room: RoomStateStore
eventRowID: number eventRowID: number
} }

View file

@ -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 <https://www.gnu.org/licenses/>.
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 <div className="timeline-view">
<button onClick={loadHistory}>Load history</button>
{timeline.map(entry => <TimelineEvent
key={entry.event_rowid} room={room} eventRowID={entry.event_rowid}
/>)}
</div>
}
export default TimelineView

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useEffect, useState } from "react" import { useEffect, useState, useSyncExternalStore } from "react"
export function useEventAsState<T>(dispatcher?: EventDispatcher<T>): T | null { export function useEventAsState<T>(dispatcher?: EventDispatcher<T>): T | null {
const [state, setState] = useState<T | null>(null) const [state, setState] = useState<T | null>(null)
@ -21,16 +21,30 @@ export function useEventAsState<T>(dispatcher?: EventDispatcher<T>): T | null {
return state return state
} }
export function useCachedEventAsState<T>(dispatcher: CachedEventDispatcher<T>): T | null {
return useSyncExternalStore(
dispatcher.listenChange,
() => dispatcher.current,
)
}
export function useNonNullEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T { export function useNonNullEventAsState<T>(dispatcher: NonNullCachedEventDispatcher<T>): T {
const [state, setState] = useState<T>(dispatcher.current) return useSyncExternalStore(
useEffect(() => dispatcher.listen(setState), [dispatcher]) dispatcher.listenChange,
return state () => dispatcher.current,
)
} }
export class EventDispatcher<T> { export class EventDispatcher<T> {
#listeners: ((data: T) => void)[] = [] #listeners: ((data: T) => void)[] = []
listenChange = (listener: () => void) => this.#listen(listener)
listen(listener: (data: T) => void): () => void { listen(listener: (data: T) => void): () => void {
return this.#listen(listener)
}
#listen(listener: (data: T) => void): () => void {
this.#listeners.push(listener) this.#listeners.push(listener)
return () => { return () => {
const idx = this.#listeners.indexOf(listener) const idx = this.#listeners.indexOf(listener)