diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 39ba557..a20b545 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import RPCClient, { SendMessageParams } from "./rpc.ts" -import { StateStore } from "./statestore.ts" +import { StateStore } from "./statestore" import type { ClientState, EventRowID, diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts new file mode 100644 index 0000000..81f4012 --- /dev/null +++ b/web/src/api/statestore/hooks.ts @@ -0,0 +1,25 @@ +// 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 { useSyncExternalStore } from "react" +import type { MemDBEvent } from "../types" +import { RoomStateStore } from "./room.ts" + +export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { + return useSyncExternalStore( + room.subscribeTimeline, + () => room.timelineCache, + ) +} diff --git a/web/src/api/statestore/index.ts b/web/src/api/statestore/index.ts new file mode 100644 index 0000000..3bbe512 --- /dev/null +++ b/web/src/api/statestore/index.ts @@ -0,0 +1,3 @@ +export * from "./main.ts" +export * from "./room.ts" +export * from "./hooks.ts" diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts new file mode 100644 index 0000000..3b1a625 --- /dev/null +++ b/web/src/api/statestore/main.ts @@ -0,0 +1,148 @@ +// 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 unhomoglyph from "unhomoglyph" +import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" +import type { + ContentURI, + EventsDecryptedData, + MemDBEvent, + RoomID, + SendCompleteData, + SyncCompleteData, + SyncRoom, +} from "../types" +import { RoomStateStore } from "./room.ts" + +export interface RoomListEntry { + room_id: RoomID + sorting_timestamp: number + preview_event?: MemDBEvent + preview_sender?: MemDBEvent + name: string + search_name: string + avatar?: ContentURI +} + +// eslint-disable-next-line no-misleading-character-class +const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g + +export function toSearchableString(str: string): string { + return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, "")) + .toLowerCase() + .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") + .toLowerCase() +} + +export class StateStore { + readonly rooms: Map = new Map() + readonly roomList = new NonNullCachedEventDispatcher([]) + + #roomListEntryChanged(entry: SyncRoom, oldEntry: RoomStateStore): boolean { + return entry.meta.sorting_timestamp !== oldEntry.meta.current.sorting_timestamp || + entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid || + entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1 + } + + #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry { + if (!room) { + room = this.rooms.get(entry.meta.room_id) + } + const preview_event = room?.eventsByRowID.get(entry.meta.preview_event_rowid) + const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender) + const name = entry.meta.name ?? "Unnamed room" + return { + room_id: entry.meta.room_id, + sorting_timestamp: entry.meta.sorting_timestamp, + preview_event, + preview_sender, + name, + search_name: toSearchableString(name), + avatar: entry.meta.avatar, + } + } + + applySync(sync: SyncCompleteData) { + const resyncRoomList = this.roomList.current.length === 0 + const changedRoomListEntries = new Map() + for (const [roomID, data] of Object.entries(sync.rooms)) { + let isNewRoom = false + let room = this.rooms.get(roomID) + if (!room) { + room = new RoomStateStore(data.meta) + this.rooms.set(roomID, room) + isNewRoom = true + } + const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room)) + room.applySync(data) + if (roomListEntryChanged) { + changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room)) + } + } + + let updatedRoomList: RoomListEntry[] | undefined + if (resyncRoomList) { + updatedRoomList = Object.values(sync.rooms).map(entry => this.#makeRoomListEntry(entry)) + updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) + } else if (changedRoomListEntries.size > 0) { + updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) + for (const entry of changedRoomListEntries.values()) { + if (updatedRoomList.length === 0 || entry.sorting_timestamp >= + updatedRoomList[updatedRoomList.length - 1].sorting_timestamp) { + updatedRoomList.push(entry) + } else if (entry.sorting_timestamp <= 0 || + entry.sorting_timestamp < updatedRoomList[0]?.sorting_timestamp) { + updatedRoomList.unshift(entry) + } else { + const indexToPushAt = updatedRoomList.findLastIndex(val => + val.sorting_timestamp <= entry.sorting_timestamp) + updatedRoomList.splice(indexToPushAt + 1, 0, entry) + } + } + } + if (updatedRoomList) { + this.roomList.emit(updatedRoomList) + } + } + + applySendComplete(data: SendCompleteData) { + const room = this.rooms.get(data.event.room_id) + if (!room) { + // TODO log or something? + return + } + room.applySendComplete(data.event) + } + + applyDecrypted(decrypted: EventsDecryptedData) { + const room = this.rooms.get(decrypted.room_id) + if (!room) { + // TODO log or something? + return + } + room.applyDecrypted(decrypted) + if (decrypted.preview_event_rowid) { + const idx = this.roomList.current.findIndex(entry => entry.room_id === decrypted.room_id) + if (idx !== -1) { + const updatedRoomList = [...this.roomList.current] + updatedRoomList[idx] = { + ...updatedRoomList[idx], + preview_event: room.eventsByRowID.get(decrypted.preview_event_rowid), + } + this.roomList.emit(updatedRoomList) + } + } + } +} diff --git a/web/src/api/statestore.ts b/web/src/api/statestore/room.ts similarity index 57% rename from web/src/api/statestore.ts rename to web/src/api/statestore/room.ts index 25e6ac0..2d7d607 100644 --- a/web/src/api/statestore.ts +++ b/web/src/api/statestore/room.ts @@ -13,11 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useSyncExternalStore } from "react" -import unhomoglyph from "unhomoglyph" -import { NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts" +import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import type { - ContentURI, DBRoom, EncryptedEventContent, EventID, @@ -28,11 +25,9 @@ import type { MemDBEvent, RawDBEvent, RoomID, - SendCompleteData, - SyncCompleteData, SyncRoom, TimelineRowTuple, -} from "./types" +} from "../types" function arraysAreEqual(arr1?: T[], arr2?: T[]): boolean { if (!arr1 || !arr2) { @@ -65,13 +60,6 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { meta1.has_member_list === meta2.has_member_list } -export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { - return useSyncExternalStore( - room.subscribeTimeline, - () => room.timelineCache, - ) -} - type SubscribeFunc = (callback: () => void) => () => void export class RoomStateStore { @@ -214,124 +202,3 @@ export class RoomStateStore { } } } - -export interface RoomListEntry { - room_id: RoomID - sorting_timestamp: number - preview_event?: MemDBEvent - preview_sender?: MemDBEvent - name: string - search_name: string - avatar?: ContentURI -} - -// eslint-disable-next-line no-misleading-character-class -const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g - -export function toSearchableString(str: string): string { - return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, "")) - .toLowerCase() - .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "") - .toLowerCase() -} - -export class StateStore { - readonly rooms: Map = new Map() - readonly roomList = new NonNullCachedEventDispatcher([]) - - #roomListEntryChanged(entry: SyncRoom, oldEntry: RoomStateStore): boolean { - return entry.meta.sorting_timestamp !== oldEntry.meta.current.sorting_timestamp || - entry.meta.preview_event_rowid !== oldEntry.meta.current.preview_event_rowid || - entry.events.findIndex(evt => evt.rowid === entry.meta.preview_event_rowid) !== -1 - } - - #makeRoomListEntry(entry: SyncRoom, room?: RoomStateStore): RoomListEntry { - if (!room) { - room = this.rooms.get(entry.meta.room_id) - } - const preview_event = room?.eventsByRowID.get(entry.meta.preview_event_rowid) - const preview_sender = preview_event && room?.getStateEvent("m.room.member", preview_event.sender) - const name = entry.meta.name ?? "Unnamed room" - return { - room_id: entry.meta.room_id, - sorting_timestamp: entry.meta.sorting_timestamp, - preview_event, - preview_sender, - name, - search_name: toSearchableString(name), - avatar: entry.meta.avatar, - } - } - - applySync(sync: SyncCompleteData) { - const resyncRoomList = this.roomList.current.length === 0 - const changedRoomListEntries = new Map() - for (const [roomID, data] of Object.entries(sync.rooms)) { - let isNewRoom = false - let room = this.rooms.get(roomID) - if (!room) { - room = new RoomStateStore(data.meta) - this.rooms.set(roomID, room) - isNewRoom = true - } - const roomListEntryChanged = !resyncRoomList && (isNewRoom || this.#roomListEntryChanged(data, room)) - room.applySync(data) - if (roomListEntryChanged) { - changedRoomListEntries.set(roomID, this.#makeRoomListEntry(data, room)) - } - } - - let updatedRoomList: RoomListEntry[] | undefined - if (resyncRoomList) { - updatedRoomList = Object.values(sync.rooms).map(entry => this.#makeRoomListEntry(entry)) - updatedRoomList.sort((r1, r2) => r1.sorting_timestamp - r2.sorting_timestamp) - } else if (changedRoomListEntries.size > 0) { - updatedRoomList = this.roomList.current.filter(entry => !changedRoomListEntries.has(entry.room_id)) - for (const entry of changedRoomListEntries.values()) { - if (updatedRoomList.length === 0 || entry.sorting_timestamp >= - updatedRoomList[updatedRoomList.length - 1].sorting_timestamp) { - updatedRoomList.push(entry) - } else if (entry.sorting_timestamp <= 0 || - entry.sorting_timestamp < updatedRoomList[0]?.sorting_timestamp) { - updatedRoomList.unshift(entry) - } else { - const indexToPushAt = updatedRoomList.findLastIndex(val => - val.sorting_timestamp <= entry.sorting_timestamp) - updatedRoomList.splice(indexToPushAt + 1, 0, entry) - } - } - } - if (updatedRoomList) { - this.roomList.emit(updatedRoomList) - } - } - - applySendComplete(data: SendCompleteData) { - const room = this.rooms.get(data.event.room_id) - if (!room) { - // TODO log or something? - return - } - room.applySendComplete(data.event) - } - - applyDecrypted(decrypted: EventsDecryptedData) { - const room = this.rooms.get(decrypted.room_id) - if (!room) { - // TODO log or something? - return - } - room.applyDecrypted(decrypted) - if (decrypted.preview_event_rowid) { - const idx = this.roomList.current.findIndex(entry => entry.room_id === decrypted.room_id) - if (idx !== -1) { - const updatedRoomList = [...this.roomList.current] - updatedRoomList[idx] = { - ...updatedRoomList[idx], - preview_event: room.eventsByRowID.get(decrypted.preview_event_rowid), - } - this.roomList.emit(updatedRoomList) - } - } - } -} diff --git a/web/src/ui/MessageComposer.tsx b/web/src/ui/MessageComposer.tsx index d28e615..f2f22ab 100644 --- a/web/src/ui/MessageComposer.tsx +++ b/web/src/ui/MessageComposer.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { use, useCallback, useRef, useState } from "react" -import { RoomStateStore } from "@/api/statestore.ts" +import { RoomStateStore } from "@/api/statestore" import { MemDBEvent, Mentions } from "@/api/types" import { ClientContext } from "./ClientContext.ts" import ReplyBody from "./timeline/ReplyBody.tsx" diff --git a/web/src/ui/RoomView.tsx b/web/src/ui/RoomView.tsx index 9f01b5c..06753f1 100644 --- a/web/src/ui/RoomView.tsx +++ b/web/src/ui/RoomView.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . import { use, useCallback, useState } from "react" import { getMediaURL } from "@/api/media.ts" -import { RoomStateStore } from "@/api/statestore.ts" +import { RoomStateStore } from "@/api/statestore" import { MemDBEvent } from "@/api/types" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { LightboxContext } from "./Lightbox.tsx" diff --git a/web/src/ui/roomlist/Entry.tsx b/web/src/ui/roomlist/Entry.tsx index 18ed7ef..5ec9be9 100644 --- a/web/src/ui/roomlist/Entry.tsx +++ b/web/src/ui/roomlist/Entry.tsx @@ -14,9 +14,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { use } from "react" -import { getMediaURL } from "../../api/media.ts" -import type { RoomListEntry } from "../../api/statestore.ts" -import type { MemDBEvent, MemberEventContent } from "../../api/types" +import { getMediaURL } from "@/api/media.ts" +import type { RoomListEntry } from "@/api/statestore" +import type { MemDBEvent, MemberEventContent } from "@/api/types" import { ClientContext } from "../ClientContext.ts" export interface RoomListEntryProps { diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index bf6cbd0..6d95555 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { use, useCallback, useRef, useState } from "react" -import { toSearchableString } from "@/api/statestore.ts" +import { toSearchableString } from "@/api/statestore" import type { RoomID } from "@/api/types" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { ClientContext } from "../ClientContext.ts" diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index 66647de..a65f63e 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { getAvatarURL } from "@/api/media.ts" -import type { RoomStateStore } from "@/api/statestore.ts" +import type { RoomStateStore } from "@/api/statestore" import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types" import { TextMessageBody } from "./content/MessageBody.tsx" import CloseButton from "@/icons/close.svg?react" diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 2a00a63..988221d 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . import React, { use, useCallback } from "react" import { getAvatarURL } from "@/api/media.ts" -import { RoomStateStore } from "@/api/statestore.ts" +import { RoomStateStore } from "@/api/statestore" import { MemDBEvent, MemberEventContent } from "@/api/types" import { ClientContext } from "../ClientContext.ts" import { LightboxContext } from "../Lightbox.tsx" diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 51babb1..1af5fb9 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { use, useCallback, useEffect, useRef } from "react" -import { RoomStateStore, useRoomTimeline } from "@/api/statestore.ts" +import { RoomStateStore, useRoomTimeline } from "@/api/statestore" import { MemDBEvent } from "@/api/types" import { ClientContext } from "../ClientContext.ts" import TimelineEvent from "./TimelineEvent.tsx" diff --git a/web/src/ui/timeline/content/props.ts b/web/src/ui/timeline/content/props.ts index 2321702..64701c4 100644 --- a/web/src/ui/timeline/content/props.ts +++ b/web/src/ui/timeline/content/props.ts @@ -13,8 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { RoomStateStore } from "../../../api/statestore.ts" -import { MemDBEvent } from "../../../api/types" +import { RoomStateStore } from "@/api/statestore" +import { MemDBEvent } from "@/api/types" export interface EventContentProps { room: RoomStateStore