UNPKG

nativescript-matrix-sdk

Version:

Native Matrix SDK integration for NativeScript

410 lines (350 loc) 11.4 kB
import { ApplicationSettings } from '@nativescript/core'; import { MessageTransactionStatus, IPendingTransaction, IRetryOptions, IFileContent } from '../common/interfaces'; import { MatrixError, MatrixErrorType } from './errors'; import { Logger } from './logger'; // Update MatrixMessage interface import interface IMatrixMessageSender { id: string; name: string; avatar?: string; } // Simple reaction interface for local use interface IMatrixMessageReaction { emoji: string; count: number; users: string[]; } interface IMatrixMessage { id: string; localId?: string; chatId: string; sender: IMatrixMessageSender; content: string; contentType: string; reactions: IMatrixMessageReaction[]; createdAt: Date; status: MessageTransactionStatus; fileContent?: IFileContent; } /** * Default retry options */ export const DEFAULT_RETRY_OPTIONS: IRetryOptions = { maxRetries: 5, baseDelay: 2000, // 2 seconds useExponentialBackoff: true, maxDelay: 60000 // 1 minute }; /** * Manager for Matrix message transactions * Handles storage, queueing, and retry of message transactions */ export abstract class TransactionManager { private static STORAGE_KEY = 'matrix-sdk:pendingTransactions'; private pendingTransactions: Map<string, IPendingTransaction> = new Map(); private chatTransactions: Map<string, Set<string>> = new Map(); private isLoaded = false; /** * Create a new transaction manager */ constructor() { this.loadFromStorage(); } /** * Ensures the transaction manager is loaded before operations * @throws {MatrixError} if transactions are not loaded */ private ensureLoaded(): void { if (!this.isLoaded) { throw MatrixError.initialization( 'Transaction manager is not loaded yet', undefined, { retryable: true } ); } } /** * Load pending transactions from persistent storage */ private loadFromStorage(): void { try { const storedData = ApplicationSettings.getString(TransactionManager.STORAGE_KEY); if (storedData) { const parsedData = JSON.parse(storedData); // Restore transactions if (Array.isArray(parsedData.transactions)) { for (const transaction of parsedData.transactions) { // Restore dates if (transaction.createdAt) { transaction.createdAt = new Date(transaction.createdAt); } if (transaction.updatedAt) { transaction.updatedAt = new Date(transaction.updatedAt); } if (transaction.nextRetryAt) { transaction.nextRetryAt = new Date(transaction.nextRetryAt); } this.pendingTransactions.set(transaction.localId, transaction); // Add to chat index if (!this.chatTransactions.has(transaction.chatId)) { this.chatTransactions.set(transaction.chatId, new Set()); } this.chatTransactions.get(transaction.chatId)?.add(transaction.localId); } } } this.isLoaded = true; Logger.debug(`[TransactionManager] Loaded ${this.pendingTransactions.size} pending transactions`); } catch (error) { Logger.error('[TransactionManager] Error loading transactions from storage:', error); // Initialize as empty if loading fails this.pendingTransactions.clear(); this.chatTransactions.clear(); this.isLoaded = true; } } /** * Save pending transactions to persistent storage */ private saveToStorage(): void { try { const transactions = Array.from(this.pendingTransactions.values()); const dataToStore = { transactions }; ApplicationSettings.setString( TransactionManager.STORAGE_KEY, JSON.stringify(dataToStore) ); Logger.debug(`[TransactionManager] Saved ${transactions.length} transactions to storage`); } catch (error) { Logger.error('[TransactionManager] Error saving transactions to storage:', error); } } /** * Generate a unique local ID for a transaction */ public generateLocalId(): string { return `local_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } /** * Add a new pending transaction */ public addTransaction(transaction: IPendingTransaction): void { this.ensureLoaded(); // Set created/updated timestamps if not provided if (!transaction.createdAt) { transaction.createdAt = new Date(); } if (!transaction.updatedAt) { transaction.updatedAt = new Date(); } // Store transaction this.pendingTransactions.set(transaction.localId, transaction); // Update chat index if (!this.chatTransactions.has(transaction.chatId)) { this.chatTransactions.set(transaction.chatId, new Set()); } this.chatTransactions.get(transaction.chatId)?.add(transaction.localId); // Save changes this.saveToStorage(); Logger.debug(`[TransactionManager] Added transaction ${transaction.localId} for chat ${transaction.chatId}`); } /** * Update an existing transaction */ public updateTransaction(localId: string, updates: Partial<IPendingTransaction>): IPendingTransaction { this.ensureLoaded(); const transaction = this.pendingTransactions.get(localId); if (!transaction) { throw new MatrixError( `Transaction not found: ${localId}`, MatrixErrorType.UNKNOWN ); } // Apply updates Object.assign(transaction, { ...updates, updatedAt: new Date() }); // Save changes this.saveToStorage(); Logger.debug(`[TransactionManager] Updated transaction ${localId} to status ${transaction.status}`); return transaction; } /** * Remove a transaction */ public removeTransaction(localId: string): void { this.ensureLoaded(); const transaction = this.pendingTransactions.get(localId); if (transaction) { // Remove from chat index const chatId = transaction.chatId; const chatTransactions = this.chatTransactions.get(chatId); if (chatTransactions) { chatTransactions.delete(localId); if (chatTransactions.size === 0) { this.chatTransactions.delete(chatId); } } // Remove from main store this.pendingTransactions.delete(localId); // Save changes this.saveToStorage(); Logger.debug(`[TransactionManager] Removed transaction ${localId}`); } } /** * Get all pending transactions */ public getAllTransactions(): Map<string, IPendingTransaction> { this.ensureLoaded(); return new Map(this.pendingTransactions); } /** * Get transactions for a specific chat */ public getTransactionsForChat(chatId: string): Map<string, IPendingTransaction> { this.ensureLoaded(); const result = new Map<string, IPendingTransaction>(); const transactionIds = this.chatTransactions.get(chatId); if (transactionIds) { for (const id of transactionIds) { const transaction = this.pendingTransactions.get(id); if (transaction) { result.set(id, transaction); } } } return result; } /** * Get a specific transaction */ public getTransaction(localId: string): IPendingTransaction | undefined { this.ensureLoaded(); return this.pendingTransactions.get(localId); } /** * Get transactions with specific status */ public getTransactionsByStatus(status: MessageTransactionStatus): IPendingTransaction[] { this.ensureLoaded(); return Array.from(this.pendingTransactions.values()) .filter(transaction => transaction.status === status); } /** * Get transactions that need to be retried */ public getRetryableTransactions(): IPendingTransaction[] { this.ensureLoaded(); const now = new Date(); return Array.from(this.pendingTransactions.values()).filter(transaction => { // Transaction failed but has retries left return ( transaction.status === MessageTransactionStatus.FAILED && transaction.retryCount < transaction.maxRetries && (!transaction.nextRetryAt || transaction.nextRetryAt <= now) ); }); } /** * Schedule a transaction for retry */ public scheduleRetry(localId: string): Date { this.ensureLoaded(); const transaction = this.pendingTransactions.get(localId); if (!transaction) { throw new MatrixError( `Transaction not found: ${localId}`, MatrixErrorType.UNKNOWN ); } // Calculate next retry time with exponential backoff const retryCount = transaction.retryCount + 1; const baseDelay = transaction.retryDelay; let delay = baseDelay; // Apply exponential backoff if configured if (retryCount > 1) { const factor = transaction.retryCount; delay = Math.min( baseDelay * Math.pow(2, factor), 60000 // Max 1 minute delay ); } // Calculate next retry time const nextRetryAt = new Date(Date.now() + delay); // Update transaction this.updateTransaction(localId, { status: MessageTransactionStatus.QUEUED, retryCount, nextRetryAt }); Logger.debug(`[TransactionManager] Scheduled retry for ${localId} at ${nextRetryAt.toISOString()}`); return nextRetryAt; } /** * Mark a transaction as successful */ public markAsSuccess(localId: string, eventId: string): void { this.ensureLoaded(); this.updateTransaction(localId, { status: MessageTransactionStatus.SENT, eventId }); } /** * Mark a transaction as failed */ protected abstract markAsFailed(localId: string, error: unknown): void; /** * Convert a transaction to a message object */ public transactionToMessage(transaction: IPendingTransaction, userId: string): IMatrixMessage { return { id: transaction.eventId || transaction.localId, localId: transaction.localId, chatId: transaction.chatId, sender: { id: userId, name: 'Me', // Local user avatar: '' }, content: transaction.content, contentType: transaction.contentType, reactions: [], createdAt: transaction.createdAt, status: transaction.status, fileContent: transaction.fileContent }; } /** * Create a transaction from a message */ public createTransactionFromMessage( message: IMatrixMessage, retryOptions: IRetryOptions = DEFAULT_RETRY_OPTIONS ): IPendingTransaction { const localId = message.localId || this.generateLocalId(); return { localId, chatId: message.chatId, status: message.status || MessageTransactionStatus.QUEUED, content: message.content, contentType: message.contentType, fileContent: message.fileContent, eventId: message.id !== localId ? message.id : undefined, retryCount: 0, maxRetries: retryOptions.maxRetries, retryDelay: retryOptions.baseDelay, createdAt: message.createdAt || new Date(), updatedAt: new Date() }; } }