nativescript-matrix-sdk
Version:
Native Matrix SDK integration for NativeScript
410 lines (350 loc) • 11.4 kB
text/typescript
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()
};
}
}