// 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)
}
}
}
}