/** * Matrix Client Wrapper * * This module provides a singleton MatrixClient instance and utilities * for interacting with the Matrix protocol via matrix-js-sdk. */ import * as sdk from 'matrix-js-sdk'; import { Preset, Visibility } from 'matrix-js-sdk'; import type { MatrixClient, Room, MatrixEvent, ICreateClientOpts, IContent } from 'matrix-js-sdk'; import { sendTypedStateEvent, getTypedStateEvent, addTypedPushRule, deleteTypedPushRule, type PinnedEventsContent, type RoomAvatarContent, } from './sdk-types'; // Matrix message content types interface MessageContent extends IContent { msgtype: string; body: string; format?: string; formatted_body?: string; 'm.relates_to'?: { 'm.in_reply_to'?: { event_id: string }; rel_type?: string; event_id?: string; key?: string; }; 'm.new_content'?: { msgtype: string; body: string; }; } // Push rule types for notification settings interface PushRuleCondition { kind: string; key?: string; pattern?: string; } interface PushRule { rule_id: string; actions: string[]; conditions?: PushRuleCondition[]; } // File message content type interface FileMessageContent extends IContent { msgtype: string; body: string; url: string; info?: { mimetype?: string; size?: number; w?: number; h?: number; }; } let client: MatrixClient | null = null; export interface LoginCredentials { homeserverUrl: string; userId: string; accessToken: string; deviceId?: string; } export interface LoginWithPasswordParams { homeserverUrl: string; username: string; password: string; } /** * Initialize the Matrix client with existing credentials */ export async function initMatrixClient(credentials: LoginCredentials): Promise { if (client) { console.warn('Matrix client already initialized, stopping existing client'); await stopClient(); } const opts: ICreateClientOpts = { baseUrl: credentials.homeserverUrl, accessToken: credentials.accessToken, userId: credentials.userId, deviceId: credentials.deviceId, timelineSupport: true, cryptoCallbacks: {}, }; // Create client client = sdk.createClient(opts); // Initialize crypto (E2EE) - optional, app works without it // Note: Rust crypto requires WASM which may not load in all environments try { // Check if crypto module is available before trying to init if (typeof client.initRustCrypto === 'function') { await client.initRustCrypto(); console.log('E2EE crypto initialized successfully'); } } catch (e) { // This is expected in dev mode - WASM loading can be problematic console.info('Crypto not available - encrypted rooms will show encrypted messages'); } // Start the client (begins sync loop) // Note: pendingEventOrdering is supported but not in SDK types await client.startClient({ initialSyncLimit: 50, lazyLoadMembers: true, pendingEventOrdering: 'detached', } as Parameters[0]); return client; } /** * Check if a room is encrypted */ export function isRoomEncrypted(roomId: string): boolean { if (!client) return false; const room = client.getRoom(roomId); return room?.hasEncryptionStateEvent() ?? false; } /** * Get encryption status for display */ export function getCryptoStatus(): { initialized: boolean; deviceId: string | null } { if (!client) return { initialized: false, deviceId: null }; const crypto = client.getCrypto(); return { initialized: !!crypto, deviceId: client.getDeviceId() || null, }; } /** * Login with username and password, returns credentials */ export async function loginWithPassword(params: LoginWithPasswordParams): Promise { const tempClient = sdk.createClient({ baseUrl: params.homeserverUrl, }); const response = await tempClient.login('m.login.password', { user: params.username, password: params.password, initial_device_display_name: 'Root v2 Web', }); const credentials: LoginCredentials = { homeserverUrl: params.homeserverUrl, userId: response.user_id, accessToken: response.access_token, deviceId: response.device_id, }; // Stop temp client tempClient.stopClient(); return credentials; } /** * Get the current Matrix client instance */ export function getClient(): MatrixClient { if (!client) { throw new Error('Matrix client not initialized. Call initMatrixClient first.'); } return client; } /** * Check if client is initialized */ export function isClientInitialized(): boolean { return client !== null; } /** * Stop the Matrix client and clean up */ export async function stopClient(): Promise { if (client) { client.stopClient(); client = null; } } /** * Logout and clear credentials */ export async function logout(): Promise { if (client) { try { await client.logout(); } catch (e) { console.error('Error during logout:', e); } await stopClient(); } } /** * Get all joined rooms */ export function getRooms(): Room[] { if (!client) return []; return client.getRooms().filter(room => room.getMyMembership() === 'join'); } /** * Get a specific room by ID */ export function getRoom(roomId: string): Room | null { if (!client) return null; return client.getRoom(roomId); } /** * Send a text message to a room */ export async function sendMessage(roomId: string, body: string, replyToEventId?: string): Promise<{ event_id: string }> { const c = getClient(); const content: MessageContent = { msgtype: 'm.text', body, }; // Add reply relation if replying to a message if (replyToEventId) { const replyEvent = c.getRoom(roomId)?.findEventById(replyToEventId); const replyBody = replyEvent?.getContent()?.body || ''; const replySender = replyEvent?.getSender() || ''; content.body = `> <${replySender}> ${replyBody}\n\n${body}`; content.format = 'org.matrix.custom.html'; content.formatted_body = `
In reply to ${replySender}
${replyBody}
${body}`; content['m.relates_to'] = { 'm.in_reply_to': { event_id: replyToEventId, }, }; } const response = await c.sendEvent(roomId, 'm.room.message', content); return response; } /** * Send a reaction to a message */ export async function sendReaction(roomId: string, eventId: string, emoji: string): Promise { const c = getClient(); await c.sendEvent(roomId, 'm.reaction', { 'm.relates_to': { rel_type: 'm.annotation', event_id: eventId, key: emoji, }, }); } /** * Remove a reaction from a message by redacting the reaction event */ export async function removeReaction(roomId: string, reactionEventId: string): Promise { const c = getClient(); await c.redactEvent(roomId, reactionEventId); } /** * Edit a message */ export async function editMessage(roomId: string, eventId: string, newBody: string): Promise { const c = getClient(); await c.sendEvent(roomId, 'm.room.message', { msgtype: 'm.text', body: `* ${newBody}`, 'm.new_content': { msgtype: 'm.text', body: newBody, }, 'm.relates_to': { rel_type: 'm.replace', event_id: eventId, }, }); } /** * Delete (redact) a message */ export async function deleteMessage(roomId: string, eventId: string, reason?: string): Promise { const c = getClient(); await c.redactEvent(roomId, eventId, undefined, { reason }); } /** * Create a new room */ export async function createRoom(name: string, isDirect = false): Promise<{ room_id: string }> { const c = getClient(); return await c.createRoom({ name, visibility: Visibility.Private, preset: isDirect ? Preset.TrustedPrivateChat : Preset.PrivateChat, is_direct: isDirect, }); } /** * Create a new Space (organization container for rooms) */ export async function createSpace( name: string, options: { topic?: string; isPublic?: boolean; parentSpaceId?: string; } = {} ): Promise<{ room_id: string }> { const c = getClient(); const initialState: Array<{ type: string; state_key: string; content: Record }> = []; // Add topic if provided if (options.topic) { initialState.push({ type: 'm.room.topic', state_key: '', content: { topic: options.topic }, }); } const result = await c.createRoom({ name, visibility: options.isPublic ? Visibility.Public : Visibility.Private, preset: options.isPublic ? Preset.PublicChat : Preset.PrivateChat, creation_content: { type: 'm.space', }, initial_state: initialState, power_level_content_override: { events: { 'm.space.child': 50, // Moderators can add/remove rooms }, }, }); // If parent space provided, add this as a child of that space if (options.parentSpaceId) { await addRoomToSpace(options.parentSpaceId, result.room_id); } return result; } /** * Add a room or space as a child of a space */ export async function addRoomToSpace( spaceId: string, childRoomId: string, options: { suggested?: boolean; order?: string; } = {} ): Promise { const c = getClient(); // Get the homeserver from the room ID for the 'via' field const homeserver = childRoomId.split(':')[1]; await c.sendStateEvent(spaceId, 'm.space.child', { via: [homeserver], suggested: options.suggested ?? false, ...(options.order ? { order: options.order } : {}), }, childRoomId); } /** * Remove a room from a space */ export async function removeRoomFromSpace(spaceId: string, childRoomId: string): Promise { const c = getClient(); // Setting empty content removes the child await c.sendStateEvent(spaceId, 'm.space.child', {}, childRoomId); } /** * Get all child rooms/spaces of a space */ export function getSpaceChildren(spaceId: string): Array<{ roomId: string; suggested: boolean; order?: string }> { const c = getClient(); const room = c.getRoom(spaceId); if (!room) return []; const childEvents = room.currentState.getStateEvents('m.space.child'); const children: Array<{ roomId: string; suggested: boolean; order?: string }> = []; for (const event of childEvents) { const childId = event.getStateKey(); const content = event.getContent(); // Only include if it has 'via' (empty content means removed) if (childId && content.via && Array.isArray(content.via) && content.via.length > 0) { children.push({ roomId: childId, suggested: content.suggested ?? false, order: content.order, }); } } // Sort by order if present return children.sort((a, b) => { if (a.order && b.order) return a.order.localeCompare(b.order); if (a.order) return -1; if (b.order) return 1; return 0; }); } /** * Get all spaces the user is a member of */ export function getSpaces(): Array<{ roomId: string; name: string; avatarUrl: string | null }> { const c = getClient(); const rooms = c.getRooms(); return rooms .filter(room => { const createEvent = room.currentState.getStateEvents('m.room.create', ''); return createEvent?.getContent()?.type === 'm.space'; }) .map(room => ({ roomId: room.roomId, name: room.name || 'Unnamed Space', avatarUrl: room.getAvatarUrl(c.baseUrl, 96, 96, 'crop') || null, })); } /** * Join a room by ID or alias */ export async function joinRoom(roomIdOrAlias: string): Promise<{ room_id: string }> { const c = getClient(); const room = await c.joinRoom(roomIdOrAlias); return { room_id: room.roomId }; } /** * Leave a room */ export async function leaveRoom(roomId: string): Promise { const c = getClient(); await c.leave(roomId); } /** * Update room name */ export async function setRoomName(roomId: string, name: string): Promise { const c = getClient(); await c.setRoomName(roomId, name); } /** * Update room topic */ export async function setRoomTopic(roomId: string, topic: string): Promise { const c = getClient(); await c.setRoomTopic(roomId, topic); } /** * Update room avatar */ export async function setRoomAvatar(roomId: string, file: File): Promise { const c = getClient(); const uploadResponse = await c.uploadContent(file, { type: file.type }); await sendTypedStateEvent(c, roomId, 'm.room.avatar', { url: uploadResponse.content_uri }); } /** * Get room notification level */ export type NotificationLevel = 'all' | 'mentions' | 'mute'; export function getRoomNotificationLevel(roomId: string): NotificationLevel { if (!client) return 'all'; const room = client.getRoom(roomId); if (!room) return 'all'; // Check push rules for this room const pushRules = client.pushRules; if (!pushRules) return 'all'; // Check room-specific override rules const overrideRules = (pushRules.global?.override || []) as PushRule[]; const roomRule = overrideRules.find((r: PushRule) => r.conditions?.some((c: PushRuleCondition) => c.kind === 'event_match' && c.key === 'room_id' && c.pattern === roomId) ); if (roomRule?.actions?.includes('dont_notify')) { return 'mute'; } // Check room rules const roomRules = (pushRules.global?.room || []) as PushRule[]; const specificRule = roomRules.find((r: PushRule) => r.rule_id === roomId); if (specificRule?.actions?.includes('dont_notify')) { return 'mute'; } return 'all'; } /** * Set room notification level */ export async function setRoomNotificationLevel(roomId: string, level: NotificationLevel): Promise { if (!client) return; try { if (level === 'mute') { // Add a room rule to mute notifications await addTypedPushRule(client, 'global', 'room', roomId, { actions: ['dont_notify'], }); } else { // Remove any mute rules try { await deleteTypedPushRule(client, 'global', 'room', roomId); } catch { // Rule might not exist, ignore } } } catch (e) { console.error('Failed to set notification level:', e); throw e; } } /** * Set typing indicator */ export async function setTyping(roomId: string, isTyping: boolean, timeoutMs = 30000): Promise { const c = getClient(); await c.sendTyping(roomId, isTyping, timeoutMs); } /** * Get pinned message event IDs for a room */ export function getPinnedMessages(roomId: string): string[] { if (!client) return []; const room = client.getRoom(roomId); if (!room) return []; const pinnedEvent = getTypedStateEvent(room, 'm.room.pinned_events'); if (!pinnedEvent) return []; const content = pinnedEvent.getContent(); return content?.pinned || []; } /** * Pin a message */ export async function pinMessage(roomId: string, eventId: string): Promise { if (!client) return; const currentPinned = getPinnedMessages(roomId); if (currentPinned.includes(eventId)) return; const newPinned = [...currentPinned, eventId]; await sendTypedStateEvent(client, roomId, 'm.room.pinned_events', { pinned: newPinned }); } /** * Unpin a message */ export async function unpinMessage(roomId: string, eventId: string): Promise { if (!client) return; const currentPinned = getPinnedMessages(roomId); const newPinned = currentPinned.filter(id => id !== eventId); await sendTypedStateEvent(client, roomId, 'm.room.pinned_events', { pinned: newPinned }); } /** * Load more messages (pagination) for a room * Returns: { hasMore: boolean, loaded: number } */ export async function loadMoreMessages(roomId: string, limit = 50): Promise<{ hasMore: boolean; loaded: number }> { const c = getClient(); const room = c.getRoom(roomId); if (!room) return { hasMore: false, loaded: 0 }; const timeline = room.getLiveTimeline(); const beforeCount = timeline.getEvents().length; try { // Paginate backwards to load older messages const hasMore = await c.paginateEventTimeline(timeline, { backwards: true, limit }); const afterCount = timeline.getEvents().length; const loaded = afterCount - beforeCount; // Refresh the message store after pagination const { loadRoomMessages } = await import('$lib/stores/matrix'); loadRoomMessages(roomId); return { hasMore, loaded }; } catch (e) { console.error('Failed to load more messages:', e); return { hasMore: false, loaded: 0 }; } } /** * Mark room as read */ export async function markRoomAsRead(roomId: string): Promise { const c = getClient(); const room = c.getRoom(roomId); if (room) { const timeline = room.getLiveTimeline(); const events = timeline.getEvents(); if (events.length > 0) { const lastEvent = events[events.length - 1]; await c.sendReadReceipt(lastEvent); } } } /** * Upload a file to the Matrix content repository */ export async function uploadFile(file: File): Promise { const c = getClient(); const response = await c.uploadContent(file, { name: file.name, type: file.type, }); return response.content_uri; } /** * Send a file/image message to a room */ export async function sendFileMessage( roomId: string, file: File, contentUri: string ): Promise<{ event_id: string }> { const c = getClient(); const isImage = file.type.startsWith('image/'); const isVideo = file.type.startsWith('video/'); const isAudio = file.type.startsWith('audio/'); let msgtype = 'm.file'; if (isImage) msgtype = 'm.image'; else if (isVideo) msgtype = 'm.video'; else if (isAudio) msgtype = 'm.audio'; const content: any = { msgtype, body: file.name, url: contentUri, info: { mimetype: file.type, size: file.size, }, }; // For images, try to get dimensions if (isImage) { try { const dimensions = await getImageDimensions(file); content.info.w = dimensions.width; content.info.h = dimensions.height; } catch (e) { console.warn('Failed to get image dimensions:', e); } } const response = await c.sendEvent(roomId, 'm.room.message', content); return response; } /** * Helper to get image dimensions */ function getImageDimensions(file: File): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { resolve({ width: img.width, height: img.height }); URL.revokeObjectURL(img.src); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); } /** * Get the HTTP URL for a Matrix content URI (mxc://) */ export function getMediaUrl(mxcUrl: string, width?: number, height?: number): string | null { if (!client || !mxcUrl || !mxcUrl.startsWith('mxc://')) return null; if (width && height) { return client.mxcUrlToHttp(mxcUrl, width, height, 'scale'); } return client.mxcUrlToHttp(mxcUrl); } // Cache for blob URLs to avoid re-fetching const mediaBlobCache = new Map(); /** * Fetch media with authentication and return a blob URL * This is needed because Matrix media requires auth headers */ export async function getAuthenticatedMediaUrl(mxcUrl: string): Promise { if (!client || !mxcUrl || !mxcUrl.startsWith('mxc://')) return null; // Check cache first if (mediaBlobCache.has(mxcUrl)) { return mediaBlobCache.get(mxcUrl)!; } try { // Parse mxc:// URL const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); if (!match) return null; const [, serverName, mediaId] = match; const accessToken = client.getAccessToken(); // Use the authenticated media endpoint const url = `${client.baseUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`; const response = await fetch(url, { headers: { 'Authorization': `Bearer ${accessToken}`, }, }); if (!response.ok) { // Fallback to legacy endpoint without auth (some servers still support it) const legacyUrl = client.mxcUrlToHttp(mxcUrl); if (legacyUrl) { mediaBlobCache.set(mxcUrl, legacyUrl); return legacyUrl; } return null; } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); // Cache the blob URL mediaBlobCache.set(mxcUrl, blobUrl); return blobUrl; } catch (e) { console.error('Failed to fetch authenticated media:', e); // Fallback to unauthenticated URL return client.mxcUrlToHttp(mxcUrl); } } /** * Get authenticated thumbnail URL */ export async function getAuthenticatedThumbnailUrl( mxcUrl: string, width: number, height: number ): Promise { if (!client || !mxcUrl || !mxcUrl.startsWith('mxc://')) return null; const cacheKey = `${mxcUrl}_${width}x${height}`; if (mediaBlobCache.has(cacheKey)) { return mediaBlobCache.get(cacheKey)!; } try { const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/); if (!match) return null; const [, serverName, mediaId] = match; const accessToken = client.getAccessToken(); const url = `${client.baseUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=${width}&height=${height}&method=scale`; const response = await fetch(url, { headers: { 'Authorization': `Bearer ${accessToken}`, }, }); if (!response.ok) { // Fallback const legacyUrl = client.mxcUrlToHttp(mxcUrl, width, height, 'scale'); if (legacyUrl) { mediaBlobCache.set(cacheKey, legacyUrl); return legacyUrl; } return null; } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); mediaBlobCache.set(cacheKey, blobUrl); return blobUrl; } catch (e) { console.error('Failed to fetch authenticated thumbnail:', e); return client.mxcUrlToHttp(mxcUrl, width, height, 'scale'); } } /** * Get members of a room */ export function getRoomMembers(roomId: string): Array<{ userId: string; name: string; avatarUrl: string | null; membership: 'join' | 'invite' | 'leave' | 'ban'; powerLevel: number; }> { if (!client) return []; const room = client.getRoom(roomId); if (!room) return []; const members = room.getJoinedMembers(); const powerLevels = room.currentState.getStateEvents('m.room.power_levels', '')?.getContent(); const userPowerLevels = powerLevels?.users || {}; const defaultPowerLevel = powerLevels?.users_default || 0; return members.map(member => ({ userId: member.userId, name: member.name || member.userId, avatarUrl: member.getAvatarUrl(client!.baseUrl, 40, 40, 'crop', true, true) || null, membership: member.membership as 'join' | 'invite' | 'leave' | 'ban', powerLevel: userPowerLevels[member.userId] ?? defaultPowerLevel, })); } /** * Create or get existing DM room with a user */ export async function createDirectMessage(userId: string): Promise { if (!client) throw new Error('Client not initialized'); // Check if we already have a DM with this user const existingDM = findExistingDM(userId); if (existingDM) return existingDM; // Create new DM room const response = await client.createRoom({ preset: Preset.TrustedPrivateChat, is_direct: true, invite: [userId], initial_state: [], }); // Mark as DM in account data const dmMap = client.getAccountData('m.direct')?.getContent() || {}; if (!dmMap[userId]) { dmMap[userId] = []; } dmMap[userId].push(response.room_id); await client.setAccountData('m.direct', dmMap); return response.room_id; } /** * Find existing DM room with a user */ export function findExistingDM(userId: string): string | null { if (!client) return null; const dmMap = client.getAccountData('m.direct')?.getContent() || {}; const dmRoomIds = dmMap[userId] || []; // Find a room that exists and we're joined to for (const roomId of dmRoomIds) { const room = client.getRoom(roomId); if (room && room.getMyMembership() === 'join') { return roomId; } } return null; } /** * Search for users by name or ID */ export async function searchUsers(query: string, limit = 10): Promise> { if (!client || !query.trim()) return []; try { const response = await client.searchUserDirectory({ term: query, limit }); return response.results.map((user: any) => ({ userId: user.user_id, displayName: user.display_name || user.user_id, avatarUrl: user.avatar_url ? client!.mxcUrlToHttp(user.avatar_url, 40, 40, 'crop') : null, })); } catch (e) { console.error('User search failed:', e); return []; } } /** * Search messages in a room (local search through loaded timeline) */ export function searchMessagesLocal(roomId: string, query: string): Array<{ eventId: string; sender: string; senderName: string; content: string; timestamp: number; }> { if (!client || !query.trim()) return []; const room = client.getRoom(roomId); if (!room) return []; const lowerQuery = query.toLowerCase(); const timeline = room.getLiveTimeline(); const events = timeline.getEvents(); const results: Array<{ eventId: string; sender: string; senderName: string; content: string; timestamp: number; }> = []; for (const event of events) { if (event.getType() !== 'm.room.message') continue; const content = event.getContent(); const body = content.body || ''; if (body.toLowerCase().includes(lowerQuery)) { const sender = event.getSender() || ''; const member = room.getMember(sender); results.push({ eventId: event.getId() || '', sender, senderName: member?.name || sender, content: body, timestamp: event.getTs(), }); } } return results.reverse(); // Most recent first } /** * Get read receipts for a room - returns map of eventId -> userIds who have read up to that event */ export function getRoomReadReceipts(roomId: string): Map { if (!client) return new Map(); const room = client.getRoom(roomId); if (!room) return new Map(); const receipts = new Map(); const members = room.getJoinedMembers(); for (const member of members) { // Skip own user if (member.userId === client.getUserId()) continue; const receipt = room.getReadReceiptForUserId(member.userId); if (receipt?.eventId) { const existing = receipts.get(receipt.eventId) || []; existing.push(member.userId); receipts.set(receipt.eventId, existing); } } return receipts; } /** * Get users who have read up to a specific event */ export function getReadReceiptsForEvent(roomId: string, eventId: string): Array<{ userId: string; name: string; avatarUrl: string | null; }> { if (!client) return []; const room = client.getRoom(roomId); if (!room) return []; const readers: Array<{ userId: string; name: string; avatarUrl: string | null }> = []; const members = room.getJoinedMembers(); for (const member of members) { if (member.userId === client.getUserId()) continue; const receipt = room.getReadReceiptForUserId(member.userId); if (receipt?.eventId === eventId) { readers.push({ userId: member.userId, name: member.name || member.userId, avatarUrl: member.getAvatarUrl(client.baseUrl, 20, 20, 'crop', true, true) || null, }); } } return readers; } /** * Get user presence status */ export function getUserPresence(userId: string): { presence: 'online' | 'offline' | 'unavailable'; lastActiveAgo?: number; statusMsg?: string } { if (!client) return { presence: 'offline' }; try { const user = client.getUser(userId); if (!user) return { presence: 'offline' }; const presence = user.presence as 'online' | 'offline' | 'unavailable' || 'offline'; return { presence, lastActiveAgo: user.lastActiveAgo, statusMsg: user.presenceStatusMsg, }; } catch { return { presence: 'offline' }; } } /** * Set own presence status */ export async function setPresence(presence: 'online' | 'offline' | 'unavailable', statusMsg?: string): Promise { if (!client) return; try { await client.setPresence({ presence, status_msg: statusMsg }); } catch (e) { console.error('Failed to set presence:', e); } } /** * Get all presence for room members */ export function getRoomMembersPresence(roomId: string): Map { const presenceMap = new Map(); if (!client) return presenceMap; const room = client.getRoom(roomId); if (!room) return presenceMap; const members = room.getJoinedMembers(); for (const member of members) { const { presence } = getUserPresence(member.userId); presenceMap.set(member.userId, presence); } return presenceMap; } // Re-export types for convenience export type { MatrixClient, Room, MatrixEvent };