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:
23
src/lib/services/index.ts
Normal file
23
src/lib/services/index.ts
Normal 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';
|
||||
354
src/lib/services/reactions.ts
Normal file
354
src/lib/services/reactions.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user