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

1107
src/lib/matrix/client.ts Normal file

File diff suppressed because it is too large Load Diff

101
src/lib/matrix/context.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Matrix Client Context
*
* Provides a Svelte Context-based approach for MatrixClient lifecycle management.
* Replaces the module singleton pattern for better testability and explicit dependencies.
*
* Usage:
* // In root layout or provider component:
* setMatrixContext(client);
*
* // In any child component:
* const client = getMatrixContext();
*/
import { getContext, setContext } from 'svelte';
import type { MatrixClient } from 'matrix-js-sdk';
// Unique symbol key for context (prevents collisions)
const MATRIX_CLIENT_KEY = Symbol('matrix-client');
// ============================================================================
// Context Types
// ============================================================================
export interface MatrixClientContext {
client: MatrixClient;
isReady: boolean;
}
// ============================================================================
// Context Setters
// ============================================================================
/**
* Set the MatrixClient in Svelte context.
* Must be called during component initialization (not in event handlers).
*/
export function setMatrixContext(client: MatrixClient): void {
setContext<MatrixClientContext>(MATRIX_CLIENT_KEY, {
client,
isReady: true,
});
}
/**
* Set an uninitialized context (for loading states)
*/
export function setMatrixContextPending(): void {
setContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY, null);
}
// ============================================================================
// Context Getters
// ============================================================================
/**
* Get the MatrixClient from Svelte context.
* Throws if context is not set or client is not ready.
*
* @throws Error if called outside of a component that has MatrixProvider as ancestor
*/
export function getMatrixContext(): MatrixClient {
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
if (!ctx) {
throw new Error(
'Matrix client not available. Ensure this component is wrapped in MatrixProvider.'
);
}
if (!ctx.isReady) {
throw new Error('Matrix client is not ready yet.');
}
return ctx.client;
}
/**
* Get the MatrixClient context, returning null if not available.
* Safe version that doesn't throw.
*/
export function getMatrixContextSafe(): MatrixClient | null {
try {
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
return ctx?.isReady ? ctx.client : null;
} catch {
return null;
}
}
/**
* Check if Matrix context is available and ready
*/
export function hasMatrixContext(): boolean {
try {
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
return ctx?.isReady ?? false;
} catch {
return false;
}
}

90
src/lib/matrix/index.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* Matrix Module Index
*
* Re-exports all Matrix-related functionality for convenient imports.
*/
// Client
export {
initMatrixClient,
loginWithPassword,
getClient,
isClientInitialized,
stopClient,
logout,
getRooms,
getRoom,
sendMessage,
sendReaction,
removeReaction,
editMessage,
deleteMessage,
createRoom,
createSpace,
addRoomToSpace,
removeRoomFromSpace,
getSpaceChildren,
getSpaces,
joinRoom,
leaveRoom,
setTyping,
markRoomAsRead,
loadMoreMessages,
isRoomEncrypted,
getCryptoStatus,
uploadFile,
sendFileMessage,
getMediaUrl,
getAuthenticatedMediaUrl,
getAuthenticatedThumbnailUrl,
getRoomMembers,
getRoomReadReceipts,
getReadReceiptsForEvent,
searchMessagesLocal,
createDirectMessage,
findExistingDM,
searchUsers,
getUserPresence,
setPresence,
getRoomMembersPresence,
setRoomName,
setRoomTopic,
setRoomAvatar,
getRoomNotificationLevel,
setRoomNotificationLevel,
getPinnedMessages,
pinMessage,
unpinMessage,
type NotificationLevel,
type LoginCredentials,
type LoginWithPasswordParams,
type MatrixClient,
type Room,
type MatrixEvent,
} from './client';
// Sync
export {
setupSyncHandlers,
removeSyncHandlers,
} from './sync';
// Context (for dependency injection)
export {
setMatrixContext,
getMatrixContext,
getMatrixContextSafe,
hasMatrixContext,
type MatrixClientContext,
} from './context';
// Types
export type {
SyncState,
RoomMember,
Message,
RoomSummary,
TypingInfo,
ReadReceipt,
Space,
} from './types';

79
src/lib/matrix/matrix-sdk-augment.d.ts vendored Normal file
View File

@@ -0,0 +1,79 @@
/**
* Matrix SDK Type Augmentations
*
* Provides extended client start options that include pendingEventOrdering.
* Also augments TimelineEvents and AccountDataEvents with Matrix event types.
*/
import 'matrix-js-sdk';
declare module 'matrix-js-sdk' {
/**
* Extended start client options that include pendingEventOrdering
* which is supported by the SDK but missing from official types
*/
export interface IStartClientOpts {
initialSyncLimit?: number;
lazyLoadMembers?: boolean;
pendingEventOrdering?: 'chronological' | 'detached';
includeArchivedRooms?: boolean;
filter?: object;
}
/**
* Augment TimelineEvents to include Matrix event types
*/
export interface TimelineEvents {
'm.room.message': {
msgtype: string;
body: string;
format?: string;
formatted_body?: string;
'm.relates_to'?: {
rel_type?: string;
event_id?: string;
'm.in_reply_to'?: { event_id: string };
};
'm.new_content'?: {
msgtype: string;
body: string;
};
url?: string;
info?: Record<string, unknown>;
};
'm.reaction': {
'm.relates_to': {
rel_type: 'm.annotation';
event_id: string;
key: string;
};
};
'm.room.redaction': {
reason?: string;
};
}
/**
* Augment AccountDataEvents to include Matrix account data types
*/
export interface AccountDataEvents {
'm.direct': Record<string, string[]>;
'm.push_rules': unknown;
'm.ignored_user_list': { ignored_users: Record<string, object> };
}
/**
* Augment StateEvents to include Space-related state events
*/
export interface StateEvents {
'm.space.child': {
via?: string[];
suggested?: boolean;
order?: string;
};
'm.space.parent': {
via?: string[];
canonical?: boolean;
};
}
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { getMessageType, stripReplyFallback, formatFileSize } from './messageUtils';
describe('messageUtils', () => {
describe('getMessageType', () => {
it('returns "image" for m.image msgtype', () => {
expect(getMessageType('m.image')).toBe('image');
});
it('returns "video" for m.video msgtype', () => {
expect(getMessageType('m.video')).toBe('video');
});
it('returns "audio" for m.audio msgtype', () => {
expect(getMessageType('m.audio')).toBe('audio');
});
it('returns "file" for m.file msgtype', () => {
expect(getMessageType('m.file')).toBe('file');
});
it('returns "notice" for m.notice msgtype', () => {
expect(getMessageType('m.notice')).toBe('notice');
});
it('returns "emote" for m.emote msgtype', () => {
expect(getMessageType('m.emote')).toBe('emote');
});
it('returns "text" for m.text msgtype', () => {
expect(getMessageType('m.text')).toBe('text');
});
it('returns "text" for unknown msgtype', () => {
expect(getMessageType('m.unknown')).toBe('text');
expect(getMessageType('')).toBe('text');
});
});
describe('stripReplyFallback', () => {
it('returns original content when hasReply is false', () => {
const content = '> quoted text\n\nactual message';
expect(stripReplyFallback(content, false)).toBe(content);
});
it('strips single-line reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n\nMy reply';
expect(stripReplyFallback(content, true)).toBe('My reply');
});
it('strips multi-line reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n> This is a longer message\n\nMy reply';
expect(stripReplyFallback(content, true)).toBe('My reply');
});
it('handles empty content', () => {
expect(stripReplyFallback('', true)).toBe('');
expect(stripReplyFallback('', false)).toBe('');
});
it('handles content with only reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n\n';
expect(stripReplyFallback(content, true)).toBe('');
});
it('preserves content after reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n\nFirst line\nSecond line';
expect(stripReplyFallback(content, true)).toBe('First line\nSecond line');
});
it('handles bare > lines', () => {
const content = '>\n\nMy message';
expect(stripReplyFallback(content, true)).toBe('My message');
});
});
describe('formatFileSize', () => {
it('returns empty string for undefined', () => {
expect(formatFileSize(undefined)).toBe('');
});
it('returns empty string for 0', () => {
expect(formatFileSize(0)).toBe('');
});
it('formats bytes correctly', () => {
expect(formatFileSize(500)).toBe('500 B');
expect(formatFileSize(1023)).toBe('1023 B');
});
it('formats kilobytes correctly', () => {
expect(formatFileSize(1024)).toBe('1.0 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(10240)).toBe('10.0 KB');
});
it('formats megabytes correctly', () => {
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
expect(formatFileSize(10 * 1024 * 1024)).toBe('10.0 MB');
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Message Utilities
*
* Shared utility functions for processing Matrix messages.
*/
import type { Message } from './types';
/**
* Determine message type from Matrix msgtype
*/
export function getMessageType(msgtype: string): Message['type'] {
switch (msgtype) {
case 'm.image':
return 'image';
case 'm.video':
return 'video';
case 'm.audio':
return 'audio';
case 'm.file':
return 'file';
case 'm.notice':
return 'notice';
case 'm.emote':
return 'emote';
default:
return 'text';
}
}
/**
* Strip Matrix reply fallback from message content
* Format: "> <@user> text\n\n actual message"
*/
export function stripReplyFallback(content: string, hasReply: boolean): string {
if (!hasReply) return content;
const lines = content.split('\n');
let startIndex = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('> ') || lines[i] === '>') {
startIndex = i + 1;
} else if (lines[i] === '') {
startIndex = i + 1;
break;
} else {
break;
}
}
return lines.slice(startIndex).join('\n').trim();
}
/**
* Format file size for display
*/
export function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from 'vitest';
import type { MatrixClient, Room } from 'matrix-js-sdk';
import {
onClientEvent,
removeClientEventListeners,
type ClientEventName,
type SyncStateValue,
} from './sdk-types';
describe('sdk-types', () => {
describe('onClientEvent', () => {
it('calls client.on with the correct event name', () => {
const mockClient = {
on: vi.fn(),
} as unknown as MatrixClient;
const handler = vi.fn();
onClientEvent(mockClient, 'sync', handler);
expect(mockClient.on).toHaveBeenCalledWith('sync', handler);
});
it('works with different event types', () => {
const mockClient = {
on: vi.fn(),
} as unknown as MatrixClient;
const events: ClientEventName[] = [
'sync',
'Room',
'Room.timeline',
'Room.redaction',
'RoomMember.typing',
'RoomMember.membership',
'RoomState.events',
'User.presence',
];
events.forEach((event) => {
const handler = vi.fn();
// Use type assertion since we're testing all event types in a loop
(onClientEvent as (client: MatrixClient, event: string, handler: (...args: unknown[]) => void) => void)(mockClient, event, handler);
expect(mockClient.on).toHaveBeenCalledWith(event, handler);
});
});
});
describe('removeClientEventListeners', () => {
it('calls client.removeAllListeners with the correct event name', () => {
const mockClient = {
removeAllListeners: vi.fn(),
} as unknown as MatrixClient;
removeClientEventListeners(mockClient, 'sync');
expect(mockClient.removeAllListeners).toHaveBeenCalledWith('sync');
});
});
describe('SyncStateValue type', () => {
it('accepts valid sync state values', () => {
const states: SyncStateValue[] = [
'STOPPED',
'SYNCING',
'PREPARED',
'CATCHUP',
'RECONNECTING',
'ERROR',
];
// This is a compile-time type check - if this compiles, the types are correct
expect(states).toHaveLength(6);
});
});
});

249
src/lib/matrix/sdk-types.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* Matrix SDK Type Extensions
*
* Type declarations to extend matrix-js-sdk types and provide
* better type safety for Matrix events and state.
*/
import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
// ============================================================================
// Event Types
// ============================================================================
/** Matrix event type strings */
export type MatrixEventType =
| 'm.room.message'
| 'm.room.name'
| 'm.room.topic'
| 'm.room.avatar'
| 'm.room.member'
| 'm.room.pinned_events'
| 'm.room.encryption'
| 'm.reaction'
| 'm.room.redaction';
/** Matrix state event type strings */
export type MatrixStateEventType =
| 'm.room.name'
| 'm.room.topic'
| 'm.room.avatar'
| 'm.room.pinned_events'
| 'm.room.encryption'
| 'm.room.member';
/** Push rule scope */
export type PushRuleScope = 'global' | 'device';
/** Push rule kind */
export type PushRuleKind = 'override' | 'underride' | 'sender' | 'room' | 'content';
// ============================================================================
// Client Event Names
// ============================================================================
/** Client event names for event listeners */
export type ClientEventName =
| 'sync'
| 'Room'
| 'Room.timeline'
| 'Room.redaction'
| 'RoomMember.typing'
| 'RoomMember.membership'
| 'RoomState.events'
| 'User.presence';
// ============================================================================
// Sync State
// ============================================================================
/** Sync state values */
export type SyncStateValue =
| 'STOPPED'
| 'SYNCING'
| 'PREPARED'
| 'CATCHUP'
| 'RECONNECTING'
| 'ERROR';
// ============================================================================
// Event Handlers
// ============================================================================
/** Sync event handler */
export type SyncEventHandler = (
state: SyncStateValue,
prevState: SyncStateValue | null,
data?: { error?: Error }
) => void;
/** Room event handler */
export type RoomEventHandler = (room: Room) => void;
/** Room timeline event handler */
export type RoomTimelineEventHandler = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean
) => void;
/** Room redaction event handler */
export type RoomRedactionEventHandler = (
event: MatrixEvent,
room: Room
) => void;
/** Room member typing event handler */
export type RoomMemberTypingEventHandler = (
event: MatrixEvent,
member: RoomMember
) => void;
/** Room member membership event handler */
export type RoomMemberMembershipEventHandler = (
event: MatrixEvent,
member: RoomMember
) => void;
/** Room state events handler */
export type RoomStateEventsEventHandler = (event: MatrixEvent) => void;
/** User presence event handler */
export type UserPresenceEventHandler = (
event: MatrixEvent,
user: { userId: string; presence: 'online' | 'offline' | 'unavailable' }
) => void;
// ============================================================================
// Pinned Events Content
// ============================================================================
/** Content for m.room.pinned_events state event */
export interface PinnedEventsContent {
pinned: string[];
[key: string]: unknown;
}
/** Content for m.room.avatar state event */
export interface RoomAvatarContent {
url: string;
[key: string]: unknown;
}
// ============================================================================
// Type-safe Client Extensions
// ============================================================================
/**
* Type-safe wrapper for client.on() with proper event typing
*/
export function onClientEvent(
client: MatrixClient,
event: 'sync',
handler: SyncEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'Room',
handler: RoomEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'Room.timeline',
handler: RoomTimelineEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'Room.redaction',
handler: RoomRedactionEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'RoomMember.typing',
handler: RoomMemberTypingEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'RoomMember.membership',
handler: RoomMemberMembershipEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'RoomState.events',
handler: RoomStateEventsEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'User.presence',
handler: UserPresenceEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: ClientEventName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: (...args: any[]) => void
): void {
// The SDK's type definitions are incomplete, so we use type assertion here
// but expose a type-safe API to consumers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(client as any).on(event, handler);
}
/**
* Type-safe wrapper for client.removeAllListeners()
*/
export function removeClientEventListeners(
client: MatrixClient,
event: ClientEventName
): void {
(client as any).removeAllListeners(event);
}
/**
* Type-safe wrapper for sendStateEvent
*/
export async function sendTypedStateEvent<T extends Record<string, unknown>>(
client: MatrixClient,
roomId: string,
eventType: MatrixStateEventType,
content: T,
stateKey = ''
): Promise<{ event_id: string }> {
return (client as any).sendStateEvent(roomId, eventType, content, stateKey);
}
/**
* Type-safe wrapper for getStateEvents
*/
export function getTypedStateEvent(
room: Room,
eventType: MatrixStateEventType,
stateKey = ''
): MatrixEvent | null {
return room.currentState.getStateEvents(eventType as any, stateKey) as MatrixEvent | null;
}
/**
* Type-safe wrapper for push rules
*/
export async function addTypedPushRule(
client: MatrixClient,
scope: PushRuleScope,
kind: PushRuleKind,
ruleId: string,
body: { actions: string[]; conditions?: unknown[] }
): Promise<void> {
await (client as any).addPushRule(scope, kind, ruleId, body);
}
/**
* Type-safe wrapper for deleting push rules
*/
export async function deleteTypedPushRule(
client: MatrixClient,
scope: PushRuleScope,
kind: PushRuleKind,
ruleId: string
): Promise<void> {
await (client as any).deletePushRule(scope, kind, ruleId);
}

217
src/lib/matrix/sync.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Matrix Sync Handler
*
* Manages the Matrix sync loop and updates Svelte stores accordingly.
*/
import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
import { get } from 'svelte/store';
import {
syncState,
syncError,
typingByRoom,
refreshRooms,
syncRoomsFromEvent,
upsertRoom,
addMessage,
addReaction,
removeReaction,
loadRoomMessages,
updatePresence,
selectedRoomId
} from '$lib/stores/matrix';
import type { Message } from '$lib/matrix/types';
import { getMessageType, stripReplyFallback } from '$lib/matrix/messageUtils';
import { onClientEvent, removeClientEventListeners, type SyncStateValue } from '$lib/matrix/sdk-types';
/**
* Set up event listeners on the Matrix client to sync with Svelte stores
*/
export function setupSyncHandlers(client: MatrixClient): void {
// Sync state changes
onClientEvent(client, 'sync', (state, prevState, data) => {
syncState.set(state as SyncStateValue);
if (state === 'ERROR') {
syncError.set(data?.error?.message || 'Sync error');
} else {
syncError.set(null);
}
// When sync is prepared, load rooms and refresh selected room messages
if (state === 'PREPARED' || state === 'SYNCING') {
refreshRooms();
// On initial sync completion, reload messages for the selected room
// This ensures we have the canonical state and removes any duplicates
// that may have been added during the sync process
if (state === 'PREPARED') {
const currentRoomId = get(selectedRoomId);
if (currentRoomId) {
loadRoomMessages(currentRoomId);
}
}
}
});
// New room events - use targeted update
onClientEvent(client, 'Room', (room: Room) => {
if (room?.roomId) {
syncRoomsFromEvent('join', room.roomId);
}
});
// Consolidated Room.timeline event dispatcher
// Handles messages, edits, and reactions in a single pass
onClientEvent(client, 'Room.timeline', (event, room, toStartOfTimeline) => {
if (!room || toStartOfTimeline) return;
const eventType = event.getType();
const content = event.getContent();
const sender = event.getSender();
// Dispatch based on event type
switch (eventType) {
case 'm.room.message': {
if (!sender) return;
// Skip edit events - handled by reloading messages
if (content['m.relates_to']?.rel_type === 'm.replace') {
loadRoomMessages(room.roomId);
return;
}
// Get sender info
const member = room.getMember(sender);
const senderName = member?.name || sender;
const senderAvatar = member?.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
// Determine message type
const type = getMessageType(content.msgtype || 'm.text');
// Strip reply fallback from content
const hasReply = !!content['m.relates_to']?.['m.in_reply_to'];
const messageContent = stripReplyFallback(content.body || '', hasReply);
const message: Message = {
eventId: event.getId() || '',
roomId: room.roomId,
sender,
senderName,
senderAvatar,
content: messageContent,
timestamp: event.getTs(),
type,
isEdited: false,
isRedacted: false,
replyTo: content['m.relates_to']?.['m.in_reply_to']?.event_id,
reactions: new Map(),
};
addMessage(room.roomId, message);
break;
}
case 'm.reaction': {
const relatesTo = content['m.relates_to'];
if (relatesTo?.rel_type === 'm.annotation') {
const targetEventId = relatesTo.event_id;
const emoji = relatesTo.key;
const reactionEventId = event.getId();
if (targetEventId && emoji && sender && reactionEventId) {
addReaction(room.roomId, targetEventId, emoji, sender, reactionEventId);
}
}
break;
}
}
});
// Consolidated Room.redaction handler
onClientEvent(client, 'Room.redaction', (event, room) => {
if (!room) return;
// Reload messages to reflect redactions (both messages and reactions)
loadRoomMessages(room.roomId);
});
// Typing indicators
onClientEvent(client, 'RoomMember.typing', (event) => {
const roomId = event.getRoomId();
if (!roomId) return;
const room = client.getRoom(roomId);
if (!room) return;
// Get list of typing users (excluding self)
const typingMembers = room.currentState.getStateEvents('m.room.member')
.filter((e: MatrixEvent) => {
const userId = e.getStateKey();
return userId !== client.getUserId();
})
.map((e: MatrixEvent) => e.getStateKey() || '')
.filter((userId: string) => {
// Check if user is actually typing
const memberEvent = room.getMember(userId);
return memberEvent?.typing;
});
typingByRoom.update(map => {
map.set(roomId, typingMembers);
return new Map(map);
});
});
// Room membership changes - targeted update for specific room
onClientEvent(client, 'RoomMember.membership', (event: MatrixEvent, member: RoomMember) => {
const roomId = event.getRoomId();
if (!roomId) return;
const membership = member?.membership;
if (membership === 'join') {
syncRoomsFromEvent('join', roomId);
} else if (membership === 'leave' || membership === 'ban') {
// Check if it's the current user leaving
const userId = member?.userId;
if (userId === client.getUserId()) {
syncRoomsFromEvent('leave', roomId);
} else {
// Another user left/was banned - just update the room
syncRoomsFromEvent('update', roomId);
}
} else {
syncRoomsFromEvent('update', roomId);
}
});
// Room state changes (name, avatar) - targeted update
onClientEvent(client, 'RoomState.events', (event: MatrixEvent) => {
const eventType = event.getType();
if (eventType === 'm.room.name' || eventType === 'm.room.avatar') {
const roomId = event.getRoomId();
if (roomId) {
syncRoomsFromEvent('update', roomId);
}
}
});
// User presence events
onClientEvent(client, 'User.presence', (event, user) => {
if (!user?.userId) return;
updatePresence(user.userId, user.presence || 'offline');
});
}
/**
* Remove event listeners (call on logout/cleanup)
*/
export function removeSyncHandlers(client: MatrixClient): void {
removeClientEventListeners(client, 'sync');
removeClientEventListeners(client, 'Room');
removeClientEventListeners(client, 'Room.timeline');
removeClientEventListeners(client, 'RoomMember.typing');
removeClientEventListeners(client, 'RoomMember.membership');
removeClientEventListeners(client, 'RoomState.events');
removeClientEventListeners(client, 'Room.redaction');
removeClientEventListeners(client, 'User.presence');
}

88
src/lib/matrix/types.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Matrix Types
*
* Type definitions for Matrix events and data structures
*/
export type SyncState = 'STOPPED' | 'SYNCING' | 'PREPARED' | 'CATCHUP' | 'RECONNECTING' | 'ERROR';
export type PresenceState = 'online' | 'offline' | 'unavailable';
export interface UserPresence {
userId: string;
presence: PresenceState;
lastActiveAgo?: number;
statusMsg?: string;
currentlyActive?: boolean;
}
export interface RoomMember {
userId: string;
name: string;
avatarUrl: string | null;
membership: 'join' | 'invite' | 'leave' | 'ban';
powerLevel: number;
presence?: PresenceState;
}
export interface MediaInfo {
url: string; // mxc:// URL
httpUrl?: string; // HTTP URL for display
mimetype?: string;
size?: number;
width?: number;
height?: number;
filename?: string;
thumbnailUrl?: string;
}
export interface Message {
eventId: string;
roomId: string;
sender: string;
senderName: string;
senderAvatar: string | null;
content: string;
timestamp: number;
type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'notice' | 'emote';
isEdited: boolean;
isRedacted: boolean;
isPending?: boolean; // True while message is being sent
replyTo?: string;
reactions: Map<string, Map<string, string>>; // emoji -> userId -> reactionEventId
media?: MediaInfo; // For image/video/audio/file messages
}
export interface RoomSummary {
roomId: string;
name: string;
avatarUrl: string | null;
topic: string | null;
isDirect: boolean;
isEncrypted: boolean;
isSpace: boolean; // True if this is a space (organization)
parentSpaceId: string | null; // The space this room belongs to, null for orphan rooms
memberCount: number;
unreadCount: number;
lastMessage: Message | null;
lastActivity: number;
}
export interface TypingInfo {
roomId: string;
userIds: string[];
}
export interface ReadReceipt {
eventId: string;
userId: string;
timestamp: number;
}
export interface Space {
roomId: string;
name: string;
avatarUrl: string | null;
childRooms: string[];
childSpaces: string[];
}