/** * Matrix Stores * * Reactive Svelte stores that sync with Matrix client state. * These stores are the single source of truth for Matrix data in the UI. */ import { writable, derived, get, readable } from 'svelte/store'; import type { Room, MatrixEvent, MatrixClient } from 'matrix-js-sdk'; import type { SyncState, RoomSummary, Message, TypingInfo, MediaInfo } from '$lib/matrix/types'; import { getClient, isClientInitialized, isRoomEncrypted } from '$lib/matrix/client'; import { getMessageType, stripReplyFallback } from '$lib/matrix/messageUtils'; import { initCache, cacheMessages, getCachedMessages, cacheRooms, getCachedRooms, isCacheAvailable, } from '$lib/cache'; // ============================================================================ // Auth State // ============================================================================ export interface AuthState { isLoggedIn: boolean; userId: string | null; homeserverUrl: string | null; accessToken: string | null; deviceId: string | null; } const initialAuthState: AuthState = { isLoggedIn: false, userId: null, homeserverUrl: null, accessToken: null, deviceId: null, }; export const auth = writable(initialAuthState); // ============================================================================ // Sync State // ============================================================================ export const syncState = writable('STOPPED'); export const syncError = writable(null); // ============================================================================ // Rooms (Normalized Store Architecture) // ============================================================================ /** * PRIMARY STORE: Normalized Map * All room operations are O(1) - no secondary index needed */ const _roomsById = writable>(new Map()); /** * DERIVED: Array view for iteration (computed from Map) * Used by components that need to iterate over rooms */ export const rooms = derived(_roomsById, ($map) => [...$map.values()]); /** * O(1) room lookup by ID - direct Map access */ export function getRoom(roomId: string): Room | undefined { return get(_roomsById).get(roomId); } /** * O(1) upsert - no index maintenance required */ export function upsertRoom(room: Room): void { _roomsById.update(map => { const newMap = new Map(map); newMap.set(room.roomId, room); return newMap; }); } /** * O(1) remove - no rebuild required */ export function removeRoom(roomId: string): void { _roomsById.update(map => { if (!map.has(roomId)) return map; const newMap = new Map(map); newMap.delete(roomId); return newMap; }); } /** * Bulk set rooms from SDK - used on initial sync */ function setRoomsFromSDK(): void { if (!isClientInitialized()) return; const client = getClient(); const joinedRooms = client.getRooms().filter(r => r.getMyMembership() === 'join'); const roomMap = new Map(); joinedRooms.forEach(room => roomMap.set(room.roomId, room)); _roomsById.set(roomMap); } /** * @deprecated Use targeted update functions instead * Kept for backward compatibility during migration */ export function refreshRooms(): void { setRoomsFromSDK(); } /** * Sync rooms from SDK for a specific event type * All operations are O(1) */ export function syncRoomsFromEvent(eventType: 'join' | 'leave' | 'update', roomId?: string): void { if (!isClientInitialized()) return; const client = getClient(); switch (eventType) { case 'join': { if (roomId) { const room = client.getRoom(roomId); if (room && room.getMyMembership() === 'join') { upsertRoom(room); } } break; } case 'leave': { if (roomId) { removeRoom(roomId); } break; } case 'update': { if (roomId) { const room = client.getRoom(roomId); if (room && room.getMyMembership() === 'join') { upsertRoom(room); } } else { setRoomsFromSDK(); } break; } } } export const selectedRoomId = writable(null); /** * O(1) selected room lookup - uses Map directly */ export const selectedRoom = derived( [_roomsById, selectedRoomId], ([$roomsById, $selectedRoomId]) => { if (!$selectedRoomId) return null; return $roomsById.get($selectedRoomId) ?? null; } ); // ============================================================================ // Room Summaries (Memoized Derived Store) // ============================================================================ /** * Memoization cache for room summaries * Only recomputes when room IDs change or activity timestamps change */ interface RoomSummaryCache { roomIds: Set; lastActivityMap: Map; summaries: RoomSummary[]; } let _summaryCache: RoomSummaryCache | null = null; /** * Check if cache is valid (room set unchanged and no activity changes) */ function isSummaryCacheValid(currentRooms: Room[]): boolean { if (!_summaryCache) return false; // Check if room count changed if (currentRooms.length !== _summaryCache.roomIds.size) return false; // Check if any room was added/removed or activity changed for (const room of currentRooms) { if (!_summaryCache.roomIds.has(room.roomId)) return false; const lastEvent = room.timeline[room.timeline.length - 1]; const lastActivity = lastEvent?.getTs() || 0; const cachedActivity = _summaryCache.lastActivityMap.get(room.roomId) || 0; if (lastActivity !== cachedActivity) return false; } return true; } /** * Transform a Room to RoomSummary */ function roomToSummary(room: Room, spaceChildMap: Map): RoomSummary { const lastEvent = room.timeline[room.timeline.length - 1]; const createEvent = room.currentState.getStateEvents('m.room.create', '') as MatrixEvent | null; const roomType = createEvent?.getContent()?.type; const isSpace = roomType === 'm.space'; return { roomId: room.roomId, name: room.name || 'Unnamed Room', avatarUrl: room.getAvatarUrl(getClient()?.baseUrl || '', 40, 40, 'crop') || null, topic: (room.currentState.getStateEvents('m.room.topic', '') as MatrixEvent | null)?.getContent()?.topic || null, isDirect: room.getDMInviter() !== undefined, isEncrypted: room.hasEncryptionStateEvent(), isSpace, parentSpaceId: spaceChildMap.get(room.roomId) || null, memberCount: room.getJoinedMemberCount(), unreadCount: room.getUnreadNotificationCount() || 0, lastMessage: lastEvent ? eventToMessage(lastEvent, room) : null, lastActivity: lastEvent?.getTs() || 0, }; } /** * Build space-child mapping for parent space detection */ function buildSpaceChildMap(rooms: Room[]): Map { const spaceChildMap = new Map(); for (const room of rooms) { const createEvent = room.currentState.getStateEvents('m.room.create', '') as MatrixEvent | null; const roomType = createEvent?.getContent()?.type; if (roomType === 'm.space') { const childEvents = room.currentState.getStateEvents('m.space.child'); if (Array.isArray(childEvents)) { for (const event of childEvents) { const childId = (event as MatrixEvent).getStateKey(); if (childId && (event as MatrixEvent).getContent()?.via) { spaceChildMap.set(childId, room.roomId); } } } } } return spaceChildMap; } /** * MEMOIZED room summaries - only recomputes on actual changes * Avoids O(n log n) sort on every sync event */ export const roomSummaries = derived(rooms, ($rooms): RoomSummary[] => { // Fast path: return cached if valid if (isSummaryCacheValid($rooms)) { return _summaryCache!.summaries; } // Slow path: recompute summaries const spaceChildMap = buildSpaceChildMap($rooms); const summaries = $rooms .map(room => roomToSummary(room, spaceChildMap)) .sort((a, b) => b.lastActivity - a.lastActivity); // Update cache const roomIds = new Set(); const lastActivityMap = new Map(); for (const room of $rooms) { roomIds.add(room.roomId); const lastEvent = room.timeline[room.timeline.length - 1]; lastActivityMap.set(room.roomId, lastEvent?.getTs() || 0); } _summaryCache = { roomIds, lastActivityMap, summaries }; return summaries; }); /** * Total unread count across all rooms (for nav badge) */ export const totalUnreadCount = derived(roomSummaries, ($summaries): number => { return $summaries.reduce((sum, room) => sum + (room.isSpace ? 0 : room.unreadCount), 0); }); // ============================================================================ // Messages // ============================================================================ // Map of roomId -> messages array export const messagesByRoom = writable>(new Map()); // Secondary index: roomId -> eventId -> array index (for O(1) lookup) const messageIndexByRoom = new Map>(); /** * Rebuild the message index for a room */ function rebuildMessageIndex(roomId: string, messages: Message[]): void { const indexMap = new Map(); messages.forEach((msg, idx) => indexMap.set(msg.eventId, idx)); messageIndexByRoom.set(roomId, indexMap); } /** * Get message index by eventId (O(1) lookup) */ function getMessageIndex(roomId: string, eventId: string): number { return messageIndexByRoom.get(roomId)?.get(eventId) ?? -1; } // Derived: messages for selected room export const currentMessages = derived( [messagesByRoom, selectedRoomId], ([$messagesByRoom, $selectedRoomId]) => { if (!$selectedRoomId) return []; return $messagesByRoom.get($selectedRoomId) || []; } ); // ============================================================================ // Typing Indicators // ============================================================================ export const typingByRoom = writable>(new Map()); export const currentTyping = derived( [typingByRoom, selectedRoomId], ([$typingByRoom, $selectedRoomId]) => { if (!$selectedRoomId) return []; return $typingByRoom.get($selectedRoomId) || []; } ); // ============================================================================ // Helpers // ============================================================================ /** * LRU Cache for memoizing message transformations * Prevents O(n) re-processing of timeline events */ class MessageCache { private cache = new Map(); private maxSize: number; private maxAge: number; // in milliseconds constructor(maxSize = 500, maxAgeMs = 5 * 60 * 1000) { this.maxSize = maxSize; this.maxAge = maxAgeMs; } get(eventId: string): Message | null { const entry = this.cache.get(eventId); if (!entry) return null; // Check if entry is stale if (Date.now() - entry.timestamp > this.maxAge) { this.cache.delete(eventId); return null; } // Move to end (most recently used) this.cache.delete(eventId); this.cache.set(eventId, entry); return entry.message; } set(eventId: string, message: Message): void { // Delete if exists to update position if (this.cache.has(eventId)) { this.cache.delete(eventId); } else if (this.cache.size >= this.maxSize) { // Delete oldest entry const firstKey = this.cache.keys().next().value; if (firstKey) this.cache.delete(firstKey); } this.cache.set(eventId, { message, timestamp: Date.now() }); } invalidate(eventId: string): void { this.cache.delete(eventId); } clear(): void { this.cache.clear(); } } const messageCache = new MessageCache(); /** * Convert a MatrixEvent to our Message type * Uses memoization to prevent redundant transformations */ function eventToMessage(event: MatrixEvent, room?: Room | null, skipCache = false): Message | null { if (event.getType() !== 'm.room.message') return null; const eventId = event.getId(); if (!eventId) return null; // Check cache first (unless skipCache is set for edited messages) if (!skipCache) { const cached = messageCache.get(eventId); if (cached) return cached; } // Check if this is an edit (m.replace relation) - skip it as standalone message const relatesTo = event.getContent()['m.relates_to']; if (relatesTo?.rel_type === 'm.replace') return null; // Get the actual content (use replacement if edited) const replacingEvent = event.replacingEvent(); const content = replacingEvent ? replacingEvent.getContent()['m.new_content'] || event.getContent() : event.getContent(); const sender = event.getSender(); if (!sender) return null; // Get sender info let senderName = sender; let senderAvatar: string | null = null; if (isClientInitialized()) { const client = getClient(); const roomObj = room || client.getRoom(event.getRoomId() || ''); if (roomObj) { const member = roomObj.getMember(sender); if (member) { senderName = member.name || sender; senderAvatar = member.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null; } } } // Determine message type const type = getMessageType(content.msgtype); // Extract media info for image/video/audio/file messages let media: MediaInfo | undefined; if (['image', 'video', 'audio', 'file'].includes(type) && content.url) { const client = isClientInitialized() ? getClient() : null; const info = content.info || {}; media = { url: content.url, httpUrl: client ? (client.mxcUrlToHttp(content.url) || undefined) : undefined, mimetype: info.mimetype, size: info.size, width: info.w, height: info.h, filename: content.filename || content.body, thumbnailUrl: info.thumbnail_url && client ? (client.mxcUrlToHttp(info.thumbnail_url) || undefined) : undefined, }; } // Aggregate reactions from related events // Using nested Map: emoji -> userId -> reactionEventId const reactions = new Map>(); // Strip Matrix reply fallback from content const hasReply = !!content['m.relates_to']?.['m.in_reply_to']; const messageContent = stripReplyFallback(content.body || '', hasReply); const message: Message = { eventId, roomId: event.getRoomId() || '', sender, senderName, senderAvatar, content: messageContent, timestamp: event.getTs(), type, isEdited: !!replacingEvent, isRedacted: event.isRedacted(), replyTo: content['m.relates_to']?.['m.in_reply_to']?.event_id, reactions, media, }; // Cache the transformed message messageCache.set(eventId, message); return message; } /** * Invalidate cached message (call when message is edited) */ export function invalidateMessageCache(eventId: string): void { messageCache.invalidate(eventId); } // ============================================================================ // Actions // ============================================================================ /** * Load messages for a room */ export function loadRoomMessages(roomId: string): void { if (!isClientInitialized()) return; const client = getClient(); const room = client.getRoom(roomId); if (!room) return; const timeline = room.getLiveTimeline(); const events = timeline.getEvents(); // First, collect all reaction events // Using nested Map: messageEventId -> emoji -> userId -> reactionEventId const reactionsByEventId = new Map>>(); for (const event of events) { if (event.getType() === 'm.reaction' && !event.isRedacted()) { const content = event.getContent(); const relatesTo = content['m.relates_to']; if (relatesTo?.rel_type === 'm.annotation') { const targetEventId = relatesTo.event_id; const emoji = relatesTo.key; const sender = event.getSender(); const reactionEventId = event.getId(); if (targetEventId && emoji && sender && reactionEventId) { if (!reactionsByEventId.has(targetEventId)) { reactionsByEventId.set(targetEventId, new Map()); } const emojiMap = reactionsByEventId.get(targetEventId)!; const userMap = emojiMap.get(emoji) ?? new Map(); // O(1) check and set if (!userMap.has(sender)) { userMap.set(sender, reactionEventId); emojiMap.set(emoji, userMap); } } } } } const messages = events .filter(e => e.getType() === 'm.room.message') .map(e => eventToMessage(e, room)) .filter((m): m is Message => m !== null) .map(m => { // Attach reactions to messages const reactions = reactionsByEventId.get(m.eventId); if (reactions) { m.reactions = reactions; } return m; }); messagesByRoom.update(map => { map.set(roomId, messages); return new Map(map); }); // Rebuild O(1) lookup index rebuildMessageIndex(roomId, messages); // Cache messages in background if (isCacheAvailable()) { cacheMessages(roomId, messages).catch(() => { // Silently ignore cache errors }); } } /** * Add a single message to a room * Uses O(1) index lookup for deduplication - no linear scan fallback * Index integrity is maintained by all mutation functions */ export function addMessage(roomId: string, message: Message): void { messagesByRoom.update(map => { const existing = map.get(roomId) || []; const roomIndex = messageIndexByRoom.get(roomId) ?? new Map(); // O(1) deduplication check - index is authoritative if (roomIndex.has(message.eventId)) { return map; } // Check for pending message match using index scan of pending messages // This is O(p) where p = pending messages, typically 0-2 let pendingMatchIndex = -1; for (let i = existing.length - 1; i >= 0 && i >= existing.length - 10; i--) { const m = existing[i]; if ( m.isPending && m.sender === message.sender && m.content === message.content && Math.abs(m.timestamp - message.timestamp) < 30000 ) { pendingMatchIndex = i; break; } } if (pendingMatchIndex !== -1) { // Replace pending message with confirmed one const pendingMessage = existing[pendingMatchIndex]; const updatedMessages = [...existing]; updatedMessages[pendingMatchIndex] = { ...message, isPending: false }; map.set(roomId, updatedMessages); // Update index: remove pending eventId, add real eventId roomIndex.delete(pendingMessage.eventId); roomIndex.set(message.eventId, pendingMatchIndex); messageIndexByRoom.set(roomId, roomIndex); return new Map(map); } // Append new message const newMessages = [...existing, message]; map.set(roomId, newMessages); // Update index roomIndex.set(message.eventId, newMessages.length - 1); messageIndexByRoom.set(roomId, roomIndex); // Cache in background if (isCacheAvailable()) { cacheMessages(roomId, [message]).catch(() => { }); } return new Map(map); }); } /** * Add a pending message (optimistic update before send completes) * Maintains index integrity for O(1) lookups */ export function addPendingMessage(roomId: string, message: Message): void { messagesByRoom.update(map => { const existing = map.get(roomId) || []; const pendingMessage = { ...message, isPending: true }; const newMessages = [...existing, pendingMessage]; map.set(roomId, newMessages); // Add to index const roomIndex = messageIndexByRoom.get(roomId) ?? new Map(); roomIndex.set(pendingMessage.eventId, newMessages.length - 1); messageIndexByRoom.set(roomId, roomIndex); return new Map(map); }); } /** * Update a pending message with real event ID after send completes * Maintains index integrity */ export function confirmPendingMessage(roomId: string, tempEventId: string, realEventId: string): void { messagesByRoom.update(map => { const messages = map.get(roomId); if (!messages) return map; const roomIndex = messageIndexByRoom.get(roomId); const messageIdx = roomIndex?.get(tempEventId) ?? -1; if (messageIdx === -1) return map; const updatedMessages = [...messages]; updatedMessages[messageIdx] = { ...updatedMessages[messageIdx], eventId: realEventId, isPending: false, }; map.set(roomId, updatedMessages); // Update index: remove temp, add real if (roomIndex) { roomIndex.delete(tempEventId); roomIndex.set(realEventId, messageIdx); } return new Map(map); }); } /** * Remove a pending message (if send fails) * Rebuilds index after removal to maintain integrity */ export function removePendingMessage(roomId: string, tempEventId: string): void { messagesByRoom.update(map => { const messages = map.get(roomId); if (!messages) return map; const filteredMessages = messages.filter(m => m.eventId !== tempEventId); map.set(roomId, filteredMessages); // Rebuild index for this room rebuildMessageIndex(roomId, filteredMessages); return new Map(map); }); } /** * Add a reaction to a message * Uses nested Map structure: emoji -> userId -> reactionEventId for O(1) access * Uses O(1) index lookup for message finding */ export function addReaction(roomId: string, eventId: string, emoji: string, userId: string, reactionEventId: string): void { messagesByRoom.update(map => { const messages = map.get(roomId); if (!messages) return map; // O(1) lookup using index const messageIndex = getMessageIndex(roomId, eventId); if (messageIndex === -1) return map; const message = messages[messageIndex]; const reactions = new Map(message.reactions); // Get or create the user map for this emoji const userMap = reactions.get(emoji) ?? new Map(); // O(1) check and set if (!userMap.has(userId)) { userMap.set(userId, reactionEventId); reactions.set(emoji, userMap); } const updatedMessages = [...messages]; updatedMessages[messageIndex] = { ...message, reactions }; map.set(roomId, updatedMessages); return new Map(map); }); } /** * Remove a reaction from a message * Uses nested Map structure for O(1) access * Uses O(1) index lookup for message finding */ export function removeReaction(roomId: string, eventId: string, emoji: string, userId: string): void { messagesByRoom.update(map => { const messages = map.get(roomId); if (!messages) return map; // O(1) lookup using index const messageIndex = getMessageIndex(roomId, eventId); if (messageIndex === -1) return map; const message = messages[messageIndex]; const reactions = new Map(message.reactions); const userMap = reactions.get(emoji); if (userMap) { // O(1) delete userMap.delete(userId); if (userMap.size === 0) { reactions.delete(emoji); } else { reactions.set(emoji, userMap); } } const updatedMessages = [...messages]; updatedMessages[messageIndex] = { ...message, reactions }; map.set(roomId, updatedMessages); return new Map(map); }); } /** * Select a room and load its messages * Loads from cache first for instant display, then fetches fresh data */ export async function selectRoom(roomId: string | null): Promise { selectedRoomId.set(roomId); if (roomId) { // Load cached messages first for instant display if (isCacheAvailable()) { const cached = await getCachedMessages(roomId); if (cached.length > 0) { messagesByRoom.update(map => { map.set(roomId, cached); return new Map(map); }); } } // Then load fresh messages (will update/replace cached) loadRoomMessages(roomId); } } // ============================================================================ // Presence // ============================================================================ export type PresenceState = 'online' | 'offline' | 'unavailable'; // Map of userId -> presence state export const userPresence = writable>(new Map()); /** * Update a user's presence */ export function updatePresence(userId: string, presence: PresenceState): void { userPresence.update(map => { map.set(userId, presence); return new Map(map); }); } /** * Clear all state (on logout) */ export function clearState(): void { auth.set(initialAuthState); syncState.set('STOPPED'); syncError.set(null); _roomsById.set(new Map()); selectedRoomId.set(null); messagesByRoom.set(new Map()); typingByRoom.set(new Map()); userPresence.set(new Map()); messageCache.clear(); }