842 lines
24 KiB
TypeScript
842 lines
24 KiB
TypeScript
/**
|
|
* 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<AuthState>(initialAuthState);
|
|
|
|
// ============================================================================
|
|
// Sync State
|
|
// ============================================================================
|
|
|
|
export const syncState = writable<SyncState>('STOPPED');
|
|
export const syncError = writable<string | null>(null);
|
|
|
|
// ============================================================================
|
|
// Rooms (Normalized Store Architecture)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* PRIMARY STORE: Normalized Map<roomId, Room>
|
|
* All room operations are O(1) - no secondary index needed
|
|
*/
|
|
const _roomsById = writable<Map<string, Room>>(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<string, Room>();
|
|
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<string | null>(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<string>;
|
|
lastActivityMap: Map<string, number>;
|
|
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<string, string>): 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<string, string> {
|
|
const spaceChildMap = new Map<string, string>();
|
|
|
|
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<string>();
|
|
const lastActivityMap = new Map<string, number>();
|
|
|
|
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<Map<string, Message[]>>(new Map());
|
|
|
|
// Secondary index: roomId -> eventId -> array index (for O(1) lookup)
|
|
const messageIndexByRoom = new Map<string, Map<string, number>>();
|
|
|
|
/**
|
|
* Rebuild the message index for a room
|
|
*/
|
|
function rebuildMessageIndex(roomId: string, messages: Message[]): void {
|
|
const indexMap = new Map<string, number>();
|
|
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<Map<string, string[]>>(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<string, { message: Message; timestamp: number }>();
|
|
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<string, Map<string, string>>();
|
|
|
|
// 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<string, Map<string, Map<string, string>>>();
|
|
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<string, string>();
|
|
// 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<string, number>();
|
|
|
|
// 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<string, number>();
|
|
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<string, string>();
|
|
|
|
// 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<void> {
|
|
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<Map<string, PresenceState>>(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();
|
|
}
|