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

23
src/lib/services/index.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Services Barrel Export
*
* Centralized exports for all service modules.
*/
export {
reactionService,
addReaction,
removeReaction,
toggleReaction,
clearPendingOperations,
isOperationPending,
hasPendingOperations,
pendingReactionsList,
categorizeReactionError,
isTransientError,
ReactionErrorType,
type ReactionOperation,
type ReactionOperationType,
type ReactionOperationStatus,
type CategorizedError,
} from './reactions';

View File

@@ -0,0 +1,354 @@
/**
* Reaction Service
*
* Handles all reaction operations with optimistic updates, idempotency checks,
* and proper error categorization. Extracted from +page.svelte to achieve
* separation of concerns.
*/
import { writable, derived, get } from 'svelte/store';
import {
sendReaction as matrixSendReaction,
removeReaction as matrixRemoveReaction,
} from '$lib/matrix/client';
import {
addReaction as storeAddReaction,
removeReaction as storeRemoveReaction,
} from '$lib/stores/matrix';
// ============================================================================
// Types
// ============================================================================
export type ReactionOperationType = 'add' | 'remove';
export type ReactionOperationStatus = 'pending' | 'success' | 'error';
export interface ReactionOperation {
roomId: string;
messageId: string;
emoji: string;
type: ReactionOperationType;
status: ReactionOperationStatus;
timestamp: number;
}
/**
* Error categories for reaction operations.
* Using typed discrimination instead of string matching.
*/
export enum ReactionErrorType {
/** User already has this reaction - idempotent, safe to ignore */
AlreadyReacted = 'ALREADY_REACTED',
/** Duplicate request in flight */
DuplicateRequest = 'DUPLICATE_REQUEST',
/** SDK internal state issue */
SdkStateError = 'SDK_STATE_ERROR',
/** Network connectivity issue */
NetworkError = 'NETWORK_ERROR',
/** Unknown/unexpected error */
Unknown = 'UNKNOWN',
}
export interface CategorizedError {
type: ReactionErrorType;
message: string;
original: unknown;
isTransient: boolean;
}
// ============================================================================
// Error Categorization
// ============================================================================
/**
* Categorize an error into a typed discrimination.
* Replaces the string-matching `isIgnorableReactionError` function.
*/
export function categorizeReactionError(error: unknown): CategorizedError {
const message = error instanceof Error ? error.message : String(error);
const lowerMessage = message.toLowerCase();
// Already reacted - idempotent operation
if (lowerMessage.includes('already') || lowerMessage.includes('duplicate')) {
return {
type: ReactionErrorType.AlreadyReacted,
message: 'Reaction already exists',
original: error,
isTransient: true,
};
}
// SDK state errors (chronological ordering, pending events)
if (lowerMessage.includes('chronological') || lowerMessage.includes('pending')) {
return {
type: ReactionErrorType.SdkStateError,
message: 'SDK state synchronization issue',
original: error,
isTransient: true,
};
}
// Network errors
if (
lowerMessage.includes('networkerror') ||
lowerMessage.includes('fetch failed') ||
lowerMessage.includes('network')
) {
return {
type: ReactionErrorType.NetworkError,
message: 'Network connectivity issue',
original: error,
isTransient: true,
};
}
// Unknown error - not transient, should be reported
return {
type: ReactionErrorType.Unknown,
message,
original: error,
isTransient: false,
};
}
/**
* Check if an error should be silently ignored (transient errors)
*/
export function isTransientError(error: unknown): boolean {
return categorizeReactionError(error).isTransient;
}
// ============================================================================
// Reaction Service Store
// ============================================================================
/**
* Internal store for tracking pending operations.
* Key format: `${roomId}:${messageId}:${emoji}`
*/
const pendingOperations = writable<Map<string, ReactionOperation>>(new Map());
/**
* Derived store: Check if any operations are pending
*/
export const hasPendingOperations = derived(
pendingOperations,
($ops) => $ops.size > 0
);
/**
* Derived store: Get all pending operations as array
*/
export const pendingReactionsList = derived(
pendingOperations,
($ops) => Array.from($ops.values())
);
// ============================================================================
// Service Functions
// ============================================================================
/**
* Generate a unique key for a reaction operation
*/
function getOperationKey(roomId: string, messageId: string, emoji: string): string {
return `${roomId}:${messageId}:${emoji}`;
}
/**
* Check if an operation is currently pending
*/
export function isOperationPending(roomId: string, messageId: string, emoji: string): boolean {
const key = getOperationKey(roomId, messageId, emoji);
return get(pendingOperations).has(key);
}
/**
* Generate a temporary event ID for optimistic updates
*/
function generateTempEventId(): string {
return `~pending-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Add a reaction to a message.
* Applies optimistic update immediately, then confirms with server.
* On failure, rolls back the optimistic update without full reload.
*
* @returns Promise that resolves on success, rejects with CategorizedError on failure
*/
export async function addReaction(
roomId: string,
messageId: string,
emoji: string,
userId: string
): Promise<void> {
const key = getOperationKey(roomId, messageId, emoji);
// Idempotency check - prevent duplicate in-flight requests
if (get(pendingOperations).has(key)) {
return; // Silently ignore duplicate request
}
// Generate temporary ID for optimistic update
const tempEventId = generateTempEventId();
// Track the operation with temp ID for potential rollback
pendingOperations.update((ops) => {
ops.set(key, {
roomId,
messageId,
emoji,
type: 'add',
status: 'pending',
timestamp: Date.now(),
});
return new Map(ops);
});
// OPTIMISTIC UPDATE: Add reaction to store immediately
storeAddReaction(roomId, messageId, emoji, userId, tempEventId);
try {
// Send to Matrix server
await matrixSendReaction(roomId, messageId, emoji);
// Success - SDK sync will replace temp ID with real event ID
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
} catch (error) {
// ROLLBACK: Remove the optimistic reaction
storeRemoveReaction(roomId, messageId, emoji, userId);
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
const categorized = categorizeReactionError(error);
// Only throw for non-transient errors
if (!categorized.isTransient) {
throw categorized;
}
}
}
/**
* Remove a reaction from a message.
* Applies optimistic update immediately, then confirms with server.
* On failure, rolls back by re-adding the reaction without full reload.
*
* @returns Promise that resolves on success, rejects with CategorizedError on failure
*/
export async function removeReaction(
roomId: string,
messageId: string,
emoji: string,
userId: string,
reactionEventId: string
): Promise<void> {
const key = getOperationKey(roomId, messageId, emoji);
// Idempotency check
if (get(pendingOperations).has(key)) {
return;
}
// Track the operation
pendingOperations.update((ops) => {
ops.set(key, {
roomId,
messageId,
emoji,
type: 'remove',
status: 'pending',
timestamp: Date.now(),
});
return new Map(ops);
});
// OPTIMISTIC UPDATE: Remove from store immediately
storeRemoveReaction(roomId, messageId, emoji, userId);
try {
// Send redaction to Matrix server
await matrixRemoveReaction(roomId, reactionEventId);
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
} catch (error) {
// ROLLBACK: Re-add the reaction we just removed
storeAddReaction(roomId, messageId, emoji, userId, reactionEventId);
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
const categorized = categorizeReactionError(error);
// Only throw for non-transient errors
if (!categorized.isTransient) {
throw categorized;
}
}
}
/**
* Toggle a reaction on a message.
* If user has reacted, removes; otherwise adds.
*
* @param reactionEventId - The event ID of existing reaction (null if not reacted)
*/
export async function toggleReaction(
roomId: string,
messageId: string,
emoji: string,
userId: string,
reactionEventId: string | null
): Promise<void> {
if (reactionEventId) {
// User has already reacted - remove it
await removeReaction(roomId, messageId, emoji, userId, reactionEventId);
} else {
// User hasn't reacted - add it
await addReaction(roomId, messageId, emoji, userId);
}
}
/**
* Clear all pending operations (e.g., on logout or room switch)
*/
export function clearPendingOperations(): void {
pendingOperations.set(new Map());
}
// ============================================================================
// Exports
// ============================================================================
export const reactionService = {
// Actions
add: addReaction,
remove: removeReaction,
toggle: toggleReaction,
clear: clearPendingOperations,
// Queries
isPending: isOperationPending,
hasPending: hasPendingOperations,
pendingList: pendingReactionsList,
// Error handling
categorizeError: categorizeReactionError,
isTransient: isTransientError,
};