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