- 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
1108 lines
29 KiB
TypeScript
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 };
|