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:
1107
src/lib/matrix/client.ts
Normal file
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
101
src/lib/matrix/context.ts
Normal 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
90
src/lib/matrix/index.ts
Normal 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
79
src/lib/matrix/matrix-sdk-augment.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
103
src/lib/matrix/messageUtils.spec.ts
Normal file
103
src/lib/matrix/messageUtils.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/lib/matrix/messageUtils.ts
Normal file
63
src/lib/matrix/messageUtils.ts
Normal 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`;
|
||||
}
|
||||
75
src/lib/matrix/sdk-types.spec.ts
Normal file
75
src/lib/matrix/sdk-types.spec.ts
Normal 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
249
src/lib/matrix/sdk-types.ts
Normal 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
217
src/lib/matrix/sync.ts
Normal 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
88
src/lib/matrix/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user