feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)
- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps - Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/ - Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/ - Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList - Copy utils: emojiData, twemoji, twemojiGlobal - Replace lucide-svelte with Material Symbols in SyncRecoveryBanner - Extend Avatar with xs size and status indicator prop - Fix ui.ts store conflict: re-export toasts from toast.svelte.ts - Add migration 020_matrix_credentials for storing Matrix tokens per user/org - Add /api/matrix-credentials endpoint (GET/POST/DELETE) - Create [orgSlug]/chat page with Matrix login form + full chat UI - Add Chat to sidebar navigation
This commit is contained in:
834
src/lib/stores/matrix.ts
Normal file
834
src/lib/stores/matrix.ts
Normal file
@@ -0,0 +1,834 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user