// 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 { getAvatarURL } from "@/api/media.ts" import { Preferences, getLocalStoragePreferences, getPreferenceProxy } from "@/api/types/preferences" import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { focused } from "@/util/focus.ts" import toSearchableString from "@/util/searchablestring.ts" import Subscribable, { MultiSubscribable, NoDataSubscribable } from "@/util/subscribable.ts" import { ContentURI, EventRowID, EventsDecryptedData, ImagePack, ImagePackRooms, MemDBEvent, RoomID, RoomStateGUID, SendCompleteData, SyncCompleteData, SyncRoom, TypingEventData, UnknownEventContent, UserID, roomStateGUIDToString, } from "../types" import { RoomStateStore } from "./room.ts" export interface RoomListEntry { room_id: RoomID dm_user_id?: UserID sorting_timestamp: number preview_event?: MemDBEvent preview_sender?: MemDBEvent name: string search_name: string avatar?: ContentURI unread_messages: number unread_notifications: number unread_highlights: number marked_unread: boolean } export interface GCSettings { interval: number, lastOpenedCutoff: number, } window.gcSettings ??= { // Run garbage collection every 15 minutes. interval: 15 * 60 * 1000, // Run garbage collection to rooms not opened in the past 30 minutes. lastOpenedCutoff: 30 * 60 * 1000, } export class StateStore { readonly rooms: Map = new Map() readonly roomList = new NonNullCachedEventDispatcher([]) currentRoomListFilter: string = "" readonly accountData: Map = new Map() readonly accountDataSubs = new MultiSubscribable() readonly emojiRoomsSub = new Subscribable() readonly preferences: Preferences = getPreferenceProxy(this) #frequentlyUsedEmoji: Map | null = null #emojiPackKeys: RoomStateGUID[] | null = null #watchedRoomEmojiPacks: Record | null = null #personalEmojiPack: CustomEmojiPack | null = null readonly preferenceSub = new NoDataSubscribable() readonly localPreferenceCache: Preferences = getLocalStoragePreferences("global_prefs", this.preferenceSub.notify) serverPreferenceCache: Preferences = {} switchRoom?: (roomID: RoomID | null) => void activeRoomID: RoomID | null = null imageAuthToken?: string getFilteredRoomList(): RoomListEntry[] { if (!this.currentRoomListFilter) { return this.roomList.current } return this.roomList.current.filter(entry => entry.search_name.includes(this.currentRoomListFilter)) } #shouldHideRoom(entry: SyncRoom): boolean { const cc = entry.meta.creation_content if ((cc?.type ?? "") !== "") { // The room is not a normal room return true } const replacementRoom = entry.meta.tombstone?.replacement_room if ( replacementRoom && this.rooms.get(replacementRoom)?.meta.current.creation_content?.predecessor?.room_id === entry.meta.room_id ) { // The room is tombstoned and the replacement room is valid. return true } // Otherwise don't hide the room. return false } #roomListEntryChanged(entry: SyncRoom, oldEntry: RoomStateStore): boolean { return entry.meta.sorting_timestamp !== oldEntry.meta.current.sorting_timestamp || entry.meta.unread_messages !== oldEntry.meta.current.unread_messages || entry.meta.unread_notifications !== oldEntry.meta.current.unread_notifications || entry.meta.unread_highlights !== oldEntry.meta.current.unread_highlights || entry.meta.marked_unread !== oldEntry.meta.current.marked_unread || 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 | null { if (!room) { room = this.rooms.get(entry.meta.room_id) } if (this.#shouldHideRoom(entry)) { if (room) { room.hidden = true } return null } if (room?.hidden) { room.hidden = false } 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, dm_user_id: entry.meta.lazy_load_summary?.heroes?.length === 1 ? entry.meta.lazy_load_summary.heroes[0] : undefined, sorting_timestamp: entry.meta.sorting_timestamp, preview_event, preview_sender, name, search_name: toSearchableString(name), avatar: entry.meta.avatar, unread_messages: entry.meta.unread_messages, unread_notifications: entry.meta.unread_notifications, unread_highlights: entry.meta.unread_highlights, marked_unread: entry.meta.marked_unread, } } applySync(sync: SyncCompleteData) { if (sync.clear_state && this.rooms.size > 0) { console.info("Clearing state store as sync told to reset and there are rooms in the store") this.clear() } 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) 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)) } if (!resyncRoomList) { // When we join a valid replacement room, hide the tombstoned room. const predecessorID = data.meta.creation_content?.predecessor?.room_id if ( isNewRoom && typeof predecessorID === "string" && this.rooms.get(predecessorID)?.meta.current.tombstone?.replacement_room === roomID) { changedRoomListEntries.set(predecessorID, null) } } if (window.Notification?.permission === "granted" && !focused.current) { for (const notification of data.notifications) { this.showNotification(room, notification.event_rowid, notification.sound) } } } for (const ad of Object.values(sync.account_data)) { if (ad.type === "io.element.recent_emoji") { this.#frequentlyUsedEmoji = null } else if (ad.type === "fi.mau.gomuks.preferences") { this.serverPreferenceCache = ad.content this.preferenceSub.notify() } this.accountData.set(ad.type, ad.content) this.accountDataSubs.notify(ad.type) } for (const roomID of sync.left_rooms) { if (this.activeRoomID === roomID) { this.switchRoom?.(null) } this.rooms.delete(roomID) changedRoomListEntries.set(roomID, null) } let updatedRoomList: RoomListEntry[] | undefined if (resyncRoomList) { updatedRoomList = Object.values(sync.rooms) .map(entry => this.#makeRoomListEntry(entry)) .filter(entry => entry !== null) 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 (!entry) { continue } 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) } } invalidateEmojiPackKeyCache() { this.#emojiPackKeys = null this.#watchedRoomEmojiPacks = null } invalidateEmojiPacksCache() { this.#watchedRoomEmojiPacks = null this.emojiRoomsSub.notify() } getPersonalEmojiPack(): CustomEmojiPack | null { if (this.#personalEmojiPack === null) { const pack = this.accountData.get("im.ponies.user_emotes") if (!pack || !pack.images) { return null } this.#personalEmojiPack = parseCustomEmojiPack(pack as ImagePack, "personal", "Personal pack") } return this.#personalEmojiPack } getEmojiPackKeys(): RoomStateGUID[] { if (this.#emojiPackKeys === null) { const emoteRooms = this.accountData.get("im.ponies.emote_rooms") as ImagePackRooms | undefined try { const emojiPacks: RoomStateGUID[] = [] for (const [roomID, packs] of Object.entries(emoteRooms?.rooms ?? {})) { for (const pack of Object.keys(packs)) { emojiPacks.push({ room_id: roomID, type: "im.ponies.room_emotes", state_key: pack }) } } this.#emojiPackKeys = emojiPacks } catch (err) { console.warn("Failed to parse emote rooms data", err, emoteRooms) this.#emojiPackKeys = [] } } return this.#emojiPackKeys } getRoomEmojiPacks() { if (this.#watchedRoomEmojiPacks === null) { this.#watchedRoomEmojiPacks = Object.fromEntries( this.getEmojiPackKeys() .map(key => { const room = this.rooms.get(key.room_id) if (!room) { console.warn("Failed to find room for emoji pack", key) return null } const pack = room.getEmojiPack(key.state_key) if (!pack) { console.warn("Failed to find pack", key) return null } return [roomStateGUIDToString(key), pack] }) .filter(pack => !!pack), ) } return this.#watchedRoomEmojiPacks ?? {} } get frequentlyUsedEmoji(): Map { if (this.#frequentlyUsedEmoji === null) { const emojiData = this.accountData.get("io.element.recent_emoji") try { const recentList = emojiData?.recent_emoji as [string, number][] | undefined this.#frequentlyUsedEmoji = new Map(recentList?.toSorted( ([, count1], [, count2]) => count2 - count1, )) } catch (err) { console.warn("Failed to parse recent emoji data", err, emojiData?.recent_emoji) this.#frequentlyUsedEmoji = new Map() } } return this.#frequentlyUsedEmoji } showNotification(room: RoomStateStore, rowid: EventRowID, sound: boolean) { const evt = room.eventsByRowID.get(rowid) if (!evt || typeof evt.content.body !== "string") { return } let body = evt.content.body if (body.length > 400) { body = body.slice(0, 350) + " […]" } const memberEvt = room.getStateEvent("m.room.member", evt.sender) const icon = `${getAvatarURL(evt.sender, memberEvt?.content)}&image_auth=${this.imageAuthToken}` const roomName = room.meta.current.name ?? "Unnamed room" const senderName = memberEvt?.content.displayname ?? evt.sender const title = senderName === roomName ? senderName : `${senderName} (${roomName})` if (sound) { (document.getElementById("default-notification-sound") as HTMLAudioElement)?.play() } const notif = new Notification(title, { body, icon, badge: "gomuks.png", // timestamp: evt.timestamp, // image: ..., silent: !sound, tag: rowid.toString(), }) room.openNotifications.set(rowid, notif) notif.onclose = () => room.openNotifications.delete(rowid) notif.onclick = () => this.onClickNotification(room.roomID) } onClickNotification(roomID: RoomID) { if (this.switchRoom) { this.switchRoom(roomID) } } 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) } } } applyTyping(typing: TypingEventData) { const room = this.rooms.get(typing.room_id) if (!room) { // TODO log or something? return } room.applyTyping(typing.user_ids) } doGarbageCollection() { const maxLastOpened = Date.now() - window.gcSettings.lastOpenedCutoff let deletedEvents = 0 let deletedState = 0 for (const room of this.rooms.values()) { if (room.roomID === this.activeRoomID || room.lastOpened > maxLastOpened) { continue } const [de, ds] = room.doGarbageCollection() deletedEvents += de deletedState += ds } return { deletedEvents, deletedState } as const } clear() { this.rooms.clear() this.roomList.emit([]) this.accountData.clear() this.currentRoomListFilter = "" this.#frequentlyUsedEmoji = null this.#emojiPackKeys = null this.#watchedRoomEmojiPacks = null this.#personalEmojiPack = null this.serverPreferenceCache = {} this.activeRoomID = null } }