Files
root-org/src/lib/matrix/client.ts
AlacrisDevs d1ce5d0951 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
2026-02-07 01:44:06 +02:00

1108 lines
29 KiB
TypeScript

/**
* 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<MatrixClient> {
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<typeof client.startClient>[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<LoginCredentials> {
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<void> {
if (client) {
client.stopClient();
client = null;
}
}
/**
* Logout and clear credentials
*/
export async function logout(): Promise<void> {
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 = `<mx-reply><blockquote><a href="https://matrix.to/#/${roomId}/${replyToEventId}">In reply to</a> <a href="https://matrix.to/#/${replySender}">${replySender}</a><br>${replyBody}</blockquote></mx-reply>${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<void> {
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<void> {
const c = getClient();
await c.redactEvent(roomId, reactionEventId);
}
/**
* Edit a message
*/
export async function editMessage(roomId: string, eventId: string, newBody: string): Promise<void> {
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<void> {
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<string, unknown> }> = [];
// 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<void> {
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<void> {
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<void> {
const c = getClient();
await c.leave(roomId);
}
/**
* Update room name
*/
export async function setRoomName(roomId: string, name: string): Promise<void> {
const c = getClient();
await c.setRoomName(roomId, name);
}
/**
* Update room topic
*/
export async function setRoomTopic(roomId: string, topic: string): Promise<void> {
const c = getClient();
await c.setRoomTopic(roomId, topic);
}
/**
* Update room avatar
*/
export async function setRoomAvatar(roomId: string, file: File): Promise<void> {
const c = getClient();
const uploadResponse = await c.uploadContent(file, { type: file.type });
await sendTypedStateEvent<RoomAvatarContent>(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<void> {
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<void> {
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<void> {
if (!client) return;
const currentPinned = getPinnedMessages(roomId);
if (currentPinned.includes(eventId)) return;
const newPinned = [...currentPinned, eventId];
await sendTypedStateEvent<PinnedEventsContent>(client, roomId, 'm.room.pinned_events', { pinned: newPinned });
}
/**
* Unpin a message
*/
export async function unpinMessage(roomId: string, eventId: string): Promise<void> {
if (!client) return;
const currentPinned = getPinnedMessages(roomId);
const newPinned = currentPinned.filter(id => id !== eventId);
await sendTypedStateEvent<PinnedEventsContent>(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<void> {
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<string> {
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<string, string>();
/**
* 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<string | null> {
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<string | null> {
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<string> {
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<Array<{
userId: string;
displayName: string;
avatarUrl: string | null;
}>> {
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<string, string[]> {
if (!client) return new Map();
const room = client.getRoom(roomId);
if (!room) return new Map();
const receipts = new Map<string, string[]>();
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<void> {
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<string, 'online' | 'offline' | 'unavailable'> {
const presenceMap = new Map<string, 'online' | 'offline' | 'unavailable'>();
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 };