forked from Mirrors/gomuks
web: more file reorganization
This commit is contained in:
parent
929bfbc882
commit
da675fb578
12 changed files with 151 additions and 71 deletions
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
19
web/src/ui/ClientContext.ts
Normal file
19
web/src/ui/ClientContext.ts
Normal 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)
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
49
web/src/ui/roomlist/RoomList.tsx
Normal file
49
web/src/ui/roomlist/RoomList.tsx
Normal 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
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
41
web/src/ui/timeline/TimelineView.tsx
Normal file
41
web/src/ui/timeline/TimelineView.tsx
Normal 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
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Reference in a new issue