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:
AlacrisDevs
2026-02-07 01:44:06 +02:00
parent e55881b38b
commit d1ce5d0951
62 changed files with 11432 additions and 41 deletions

834
src/lib/stores/matrix.ts Normal file
View 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();
}