UNPKG

nativescript-matrix-sdk

Version:

Native Matrix SDK integration for NativeScript

1,529 lines (1,312 loc) 101 kB
/// <reference path="./matrix-types.d.ts" /> import { MatrixClient, MatrixServer, MatrixChat, MatrixMessage, MatrixReaction, MatrixEventType, MatrixEventListener, MatrixEventData, MessageOptions, MessageResult, PaginationToken, FileSendOptions, TransferProgress, ReadReceipt, PresenceStatus, UserPresence, DeviceVerificationStatus, DeviceKeyInfo, KeyExportFormat, KeyBackupInfo, CrossSigningInfo, RoomEncryptionAlgorithm } from '../../index.d'; import { MatrixError, MatrixErrorType } from '../../src/errors'; import { Logger } from '../../src/logger'; import { File, isIOS } from '@nativescript/core'; import { MessageTransactionStatus, ISendMessageOptions } from '../../common/interfaces'; import { TransactionManagerIOS } from './transaction-manager.ios'; // Declare Matrix iOS SDK classes let MXRestClient: any; let MXCredentials: any; let MXRoom: any; // Add constants for device verification statuses const MXDeviceVerified = 2; // Represents verified device status in iOS SDK const MXDeviceUnverified = 0; // Represents unverified device status in iOS SDK const MXDeviceBlocked = 1; // Represents blocked device status in iOS SDK const MXKeyVerificationRequestStateVerified = 6; // Represents verified request state in iOS SDK // Detect if Matrix iOS SDK is available const hasMatrixSDK = () => { return typeof NSClassFromString === 'function' && NSClassFromString('MXRestClient') !== null && NSClassFromString('MXCredentials') !== null; }; // Initialize SDK classes if available try { if (hasMatrixSDK()) { MXRestClient = NSClassFromString('MXRestClient'); MXCredentials = NSClassFromString('MXCredentials'); MXRoom = NSClassFromString('MXRoom'); Logger.info('[MatrixIOSClient] Successfully loaded Matrix iOS SDK classes'); } else { Logger.warn('[MatrixIOSClient] Matrix iOS SDK classes not found. Using mock implementation.'); } } catch (error) { Logger.error('[MatrixIOSClient] Error loading Matrix iOS SDK:', error); } /** * Matrix iOS Client implementation * This will use the native Matrix iOS SDK if available, * otherwise falls back to a mock implementation */ export class MatrixIOSClient implements MatrixClient { private nativeClient: any; // This will be the native MXRestClient private userId: string | null = null; private homeserverUrl: string | null = null; private isInitialized = false; private useMockImplementation = !hasMatrixSDK(); private rooms: Map<string, any> = new Map(); // Store for MXRoom objects // Event handling private eventListeners: Map<MatrixEventType, Set<MatrixEventListener>> = new Map(); private isListening = false; private listeningRooms: Set<string> = new Set(); private notificationObservers: any[] = []; // Store notification observers for cleanup private transactionManager: TransactionManagerIOS; constructor() { this.transactionManager = new TransactionManagerIOS(this); } /** * Initialize the Matrix client * @param homeserverUrl - URL of the Matrix homeserver * @param accessToken - User's access token */ async initialize(homeserverUrl: string, accessToken: string): Promise<void> { try { this.homeserverUrl = homeserverUrl; if (this.useMockImplementation) { // Mock implementation for testing or when native SDK is not available Logger.info('[MatrixIOSClient] Using mock implementation with homeserver: ' + homeserverUrl); this.userId = "@user:matrix.org"; // Placeholder this.isInitialized = true; return; } // Real implementation using native iOS SDK const credentials = MXCredentials.alloc().init(); credentials.homeServer = homeserverUrl; credentials.accessToken = accessToken; // We don't have the user ID yet, will be retrieved from the client this.nativeClient = MXRestClient.alloc().initWithCredentials(credentials); // Get the current user ID const userProfile = await this.wrapNativePromise<any>( this.nativeClient.getUserProfile(null) ); this.userId = (userProfile as any).userId || `@user:${new URL(homeserverUrl).hostname}`; credentials.userId = this.userId; // Update credentials with correct user ID Logger.info(`[MatrixIOSClient] Successfully initialized with user ID: ${this.userId}`); this.isInitialized = true; this.transactionManager = new TransactionManagerIOS(this); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Initialization error: ${errorMessage}`); throw MatrixError.initialization( `Failed to initialize Matrix iOS client: ${errorMessage}`, error instanceof Error ? error : undefined, { homeserverUrl } ); } } /** * Login with username and password * @param homeserverUrl - URL of the Matrix homeserver * @param username - Username or Matrix ID * @param password - User password * @param deviceName - Name for this device * @returns Promise that resolves with user ID and access token */ async login(homeserverUrl: string, username: string, password: string, deviceName?: string): Promise<{userId: string, accessToken: string}> { try { Logger.info(`[MatrixIOSClient] Logging in with username: ${username} on server: ${homeserverUrl}`); if (this.useMockImplementation) { // Mock implementation for testing Logger.info(`[MatrixIOSClient] Using mock login implementation`); this.homeserverUrl = homeserverUrl; this.userId = `@${username}:${new URL(homeserverUrl).hostname}`; const mockToken = `mock_token_${Date.now()}`; this.isInitialized = true; return { userId: this.userId, accessToken: mockToken }; } // Check if MXRestClient is available if (!MXRestClient) { throw MatrixError.initialization('Matrix iOS SDK is not available', undefined, { homeserverUrl }); } // Create authentication parameters const params = NSMutableDictionary.alloc().init(); params.setObjectForKey(username, "user"); params.setObjectForKey(password, "password"); if (deviceName) { params.setObjectForKey(deviceName, "initial_device_display_name"); } else { params.setObjectForKey("NativeScript iOS Client", "initial_device_display_name"); } // Login using the Matrix iOS SDK const credentials = await this.wrapNativePromise<any>( MXRestClient.loginWithParameters(homeserverUrl, params) ); // Update client with the obtained credentials this.userId = credentials.userId; this.homeserverUrl = homeserverUrl; // Create the native client with these credentials const mxCredentials = MXCredentials.alloc().init(); mxCredentials.homeServer = homeserverUrl; mxCredentials.userId = credentials.userId; mxCredentials.accessToken = credentials.accessToken; this.nativeClient = MXRestClient.alloc().initWithCredentials(mxCredentials); this.isInitialized = true; Logger.info(`[MatrixIOSClient] Successfully logged in as: ${credentials.userId}`); return { userId: credentials.userId, accessToken: credentials.accessToken }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Login error: ${errorMessage}`); throw MatrixError.authentication( `Failed to login: ${errorMessage}`, error instanceof Error ? error : undefined, { homeserverUrl, username } ); } } /** * Login with SSO * @param homeserverUrl - URL of the Matrix homeserver * @param callbackUrl - URL to redirect to after successful SSO authentication * @returns Promise that resolves with user ID and access token */ async loginWithSSO(homeserverUrl: string, callbackUrl: string): Promise<{userId: string, accessToken: string}> { try { Logger.info(`[MatrixIOSClient] Initiating SSO login on server: ${homeserverUrl}`); if (this.useMockImplementation) { // Mock implementation for testing Logger.info(`[MatrixIOSClient] Using mock SSO login implementation`); this.homeserverUrl = homeserverUrl; this.userId = `@sso_user:${new URL(homeserverUrl).hostname}`; const mockToken = `mock_sso_token_${Date.now()}`; this.isInitialized = true; return { userId: this.userId, accessToken: mockToken }; } // Check if MXRestClient is available if (!MXRestClient) { throw MatrixError.initialization('Matrix iOS SDK is not available', undefined, { homeserverUrl }); } // Get SSO login URL const identityProviders = await this.wrapNativePromise<any>( MXRestClient.getLoginFlowsForHomeserver(homeserverUrl) ); let ssoSupported = false; let ssoUrl = ''; // Find SSO login flow for (let i = 0; i < identityProviders.count; i++) { const flow = identityProviders.objectAtIndex(i); if (flow.type === 'm.login.sso') { ssoSupported = true; // Generate the SSO URL ssoUrl = `${homeserverUrl}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(callbackUrl)}`; break; } } if (!ssoSupported) { throw MatrixError.authentication('SSO login not supported by this homeserver', undefined, { homeserverUrl }); } // Return the SSO URL to be opened in a WebView // Note: The actual token exchange should be handled when receiving the redirect to callbackUrl return { userId: '', // Will be set after redirect completes accessToken: '', // Will be set after redirect completes ssoUrl // Additional property to be handled by the app } as any; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] SSO Login error: ${errorMessage}`); throw MatrixError.authentication( `Failed to initiate SSO login: ${errorMessage}`, error instanceof Error ? error : undefined, { homeserverUrl } ); } } /** * Register a new user * @param homeserverUrl - URL of the Matrix homeserver * @param username - Desired username * @param password - Desired password * @param deviceName - Name for this device * @returns Promise that resolves with user ID and access token */ async register(homeserverUrl: string, username: string, password: string, deviceName?: string): Promise<{userId: string, accessToken: string}> { try { Logger.info(`[MatrixIOSClient] Registering user: ${username} on server: ${homeserverUrl}`); if (this.useMockImplementation) { // Mock implementation for testing Logger.info(`[MatrixIOSClient] Using mock registration implementation`); this.homeserverUrl = homeserverUrl; this.userId = `@${username}:${new URL(homeserverUrl).hostname}`; const mockToken = `mock_reg_token_${Date.now()}`; this.isInitialized = true; return { userId: this.userId, accessToken: mockToken }; } // Check if MXRestClient is available if (!MXRestClient) { throw MatrixError.initialization('Matrix iOS SDK is not available', undefined, { homeserverUrl }); } // Create registration parameters const params = NSMutableDictionary.alloc().init(); params.setObjectForKey(username, "username"); params.setObjectForKey(password, "password"); if (deviceName) { params.setObjectForKey(deviceName, "initial_device_display_name"); } else { params.setObjectForKey("NativeScript iOS Client", "initial_device_display_name"); } // Additional auth parameters const authParams = NSMutableDictionary.alloc().init(); authParams.setObjectForKey("m.login.dummy", "type"); params.setObjectForKey(authParams, "auth"); // Register using the Matrix iOS SDK const credentials = await this.wrapNativePromise<any>( MXRestClient.registerWithParameters(homeserverUrl, params) ); // Update client with the obtained credentials this.userId = credentials.userId; this.homeserverUrl = homeserverUrl; // Create the native client with these credentials const mxCredentials = MXCredentials.alloc().init(); mxCredentials.homeServer = homeserverUrl; mxCredentials.userId = credentials.userId; mxCredentials.accessToken = credentials.accessToken; this.nativeClient = MXRestClient.alloc().initWithCredentials(mxCredentials); this.isInitialized = true; Logger.info(`[MatrixIOSClient] Successfully registered as: ${credentials.userId}`); return { userId: credentials.userId, accessToken: credentials.accessToken }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Registration error: ${errorMessage}`); throw MatrixError.authentication( `Failed to register: ${errorMessage}`, error instanceof Error ? error : undefined, { homeserverUrl, username } ); } } /** * Get the current user ID */ getUserId(): string | null { return this.userId; } /** * Get available servers/homeservers */ async getServers(): Promise<MatrixServer[]> { this.checkInitialized(); // Matrix doesn't have a direct equivalent to Discord servers // For simplicity, we'll create a default "home" server if (this.useMockImplementation) { // Return a placeholder server return [{ id: 'home', name: 'Home', icon: '🏠', description: 'Your Matrix homeserver', memberCount: 1, isPrivate: false, createdAt: new Date(), updatedAt: new Date() }]; } try { // Get the homeserver domain as the server ID const domain = new URL(this.homeserverUrl || '').hostname; // Return a single server representing the Matrix homeserver return [{ id: domain, name: domain, icon: '🔷', description: 'Your Matrix homeserver', memberCount: 0, // We don't have this information isPrivate: false, createdAt: new Date(), updatedAt: new Date() }]; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error getting servers: ${errorMessage}`); throw MatrixError.network( `Failed to get Matrix servers: ${errorMessage}`, error instanceof Error ? error : undefined ); } } /** * Get chats for a specific server */ async getChats(serverId: string): Promise<MatrixChat[]> { this.checkInitialized(); if (this.useMockImplementation) { // Return placeholder chats return [{ id: '!room1:matrix.org', serverId, type: 'room', name: 'General Chat', avatar: '#', lastMessage: 'Welcome to Matrix!', lastMessageAt: new Date(), unread: 0, participants: ['@user:matrix.org'], isArchived: false, isPinned: false }]; } try { // Get all rooms the user is a member of const rooms = await this.wrapNativePromise( this.nativeClient.rooms() ); // Convert native rooms to our chat type const chats: MatrixChat[] = []; for (let i = 0; i < (rooms as any).count; i++) { const room = (rooms as any).objectAtIndex(i); // Store the room reference for later use this.rooms.set(room.roomId, room); // Determine if it's a direct chat based on member count const isDirect = room.summary.membersCount === 2; const roomType = isDirect ? 'direct' : 'room'; // Get the last message if available let lastMessage = 'No messages'; let lastMessageAt = new Date(); if (room.summary.lastMessage) { lastMessage = room.summary.lastMessage.text || 'No text'; lastMessageAt = new Date(room.summary.lastMessage.originServerTs); } chats.push({ id: room.roomId, serverId, type: roomType, name: room.summary.displayname || 'Unnamed Room', avatar: isDirect ? '👤' : '#', lastMessage, lastMessageAt, unread: room.summary.notificationCount || 0, participants: [], // Would need additional call to get members isArchived: false, isPinned: false }); } return chats; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error getting chats: ${errorMessage}`); throw MatrixError.room( `Failed to get chats: ${errorMessage}`, error instanceof Error ? error : undefined, { serverId } ); } } /** * Get messages for a specific chat * @param chatId ID of the chat to fetch messages from * @param options Options for retrieving messages * @returns List of messages in the chat and pagination token */ async getMessages(chatId: string, options?: MessageOptions): Promise<MessageResult> { this.checkInitialized(); Logger.info(`[MatrixIOSClient] Getting messages for chat: ${chatId}`); // Set default options const limit = options?.limit || 20; const direction = options?.direction || 'backward'; const fromToken = options?.fromToken || null; try { if (this.useMockImplementation) { // Mock implementation for testing const messages: MatrixMessage[] = []; const now = new Date(); for (let i = 0; i < limit; i++) { const messageDate = new Date(now.getTime() - (i * 60000)); // 1 minute apart messages.push({ id: `mock_msg_${chatId}_${i}_${Date.now()}`, chatId, sender: { id: i % 3 === 0 ? this.userId || "@user:matrix.org" : `@user${i}:matrix.org`, name: i % 3 === 0 ? "Me" : `User ${i}`, avatar: '' }, content: `This is mock message ${i} in chat ${chatId}`, contentType: 'text', reactions: [], createdAt: messageDate, status: MessageTransactionStatus.SENT }); } // Return mock pagination token return { messages, nextToken: { token: `mock_token_${Date.now()}`, hasMore: messages.length >= limit // Pretend there are more if we returned the requested limit } }; } // Get the MXRoom for this chat ID const room = this.getRoomById(chatId); if (!room) { throw MatrixError.room(`Room not found: ${chatId}`); } // Create the appropriate pagination options const paginationOptions = NSMutableDictionary.alloc().init(); // Set the limit paginationOptions.setObjectForKey(limit, "limit"); // Set the direction if (direction === 'forward') { paginationOptions.setObjectForKey(true, "forwards"); // true for forwards } else { paginationOptions.setObjectForKey(false, "forwards"); // false for backwards } // Set the pagination token if provided if (fromToken) { paginationOptions.setObjectForKey(fromToken, "token"); } // Get messages with pagination const paginationResponse = await this.wrapNativePromise<any>( room.messagesWithOptions(paginationOptions) ); // Parse the response const messages: MatrixMessage[] = []; // Get the events array const events = paginationResponse.objectForKey("events"); // Extract the pagination token const nextToken: PaginationToken = { token: paginationResponse.objectForKey("end") || "", hasMore: paginationResponse.objectForKey("hasMore") === true }; // Process the events into messages for (let i = 0; i < events.count; i++) { const event = events.objectAtIndex(i); // Skip non-message events if (event.type !== "m.room.message") { continue; } // Get the sender profile const senderId = event.sender; const senderProfile = await this.wrapNativePromise<any>( this.nativeClient.getProfileForUserId(senderId) ); // Get event content const content = event.content; // Create the message object const message: MatrixMessage = { id: event.eventId, chatId, sender: { id: senderId, name: senderProfile.displayname || senderId, avatar: senderProfile.avatar_url || '' }, content: content.body || "", contentType: this.convertContentType(content.msgtype), reactions: await this.getReactionsForEvent(room, event.eventId), createdAt: new Date(event.originServerTs), status: MessageTransactionStatus.SENT }; messages.push(message); } return { messages, nextToken }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error getting messages: ${errorMessage}`); throw MatrixError.room( `Failed to get messages for room ${chatId}: ${errorMessage}`, error instanceof Error ? error : undefined, { chatId } ); } } /** * Helper to convert Matrix message type to SDK message type */ private convertContentType(matrixType: string): 'text' | 'image' | 'file' | 'system' { switch (matrixType) { case 'm.text': case 'm.notice': return 'text'; case 'm.image': return 'image'; case 'm.file': return 'file'; default: return 'text'; } } /** * Helper to get reactions for an event */ private async getReactionsForEvent(room: any, eventId: string): Promise<MatrixReaction[]> { try { // Get reactions from the room const reactionEvents = await this.wrapNativePromise<any>( room.getReactionsForEventId(eventId) ); const reactions: MatrixReaction[] = []; const reactionMap = new Map<string, {count: number, users: string[]}>(); // Process reaction events for (let i = 0; i < reactionEvents.count; i++) { const reactionEvent = reactionEvents.objectAtIndex(i); const content = reactionEvent.content; const emoji = content.relatesTo.key; const userId = reactionEvent.sender; // Group by emoji if (!reactionMap.has(emoji)) { reactionMap.set(emoji, {count: 0, users: []}); } const data = reactionMap.get(emoji)!; data.count++; data.users.push(userId); } // Convert map to array reactionMap.forEach((data, emoji) => { reactions.push({ emoji, count: data.count, users: data.users }); }); return reactions; } catch (error) { // Just return empty array on error Logger.warn(`[MatrixIOSClient] Error getting reactions: ${error}`); return []; } } /** * Send a message to a chat */ async sendMessage(chatId: string, content: string): Promise<MatrixMessage> { this.checkInitialized(); if (this.useMockImplementation) { // Mock implementation return { id: `mock-${Date.now()}`, chatId, sender: { id: this.userId || 'mock-user', name: 'Mock User', avatar: '' }, content, contentType: 'text', reactions: [], createdAt: new Date(), status: MessageTransactionStatus.SENT }; } try { // Get the room const room = this.rooms.get(chatId); if (!room) { throw MatrixError.room( `Room not found: ${chatId}`, undefined, { chatId } ); } try { // Send the message const eventId = await this.wrapNativePromise<string>( room.sendTextMessage(content) ); // Create a MatrixMessage object from the event const message: MatrixMessage = { id: eventId, chatId, sender: { id: this.userId || '', name: 'Me', // We're the sender avatar: '' }, content, contentType: 'text', reactions: [], createdAt: new Date(), status: MessageTransactionStatus.SENT }; return message; } catch (error) { Logger.error(`[MatrixIOSClient] Failed to send message: ${error}`); throw MatrixError.event( `Failed to send message: ${error}`, error instanceof Error ? error : undefined, { chatId } ); } } catch (error) { if (error instanceof MatrixError) { throw error; } throw new MatrixError( `Failed to send message: ${error}`, MatrixErrorType.UNKNOWN ); } } /** * Add a reaction to a message */ async addReaction(chatId: string, messageId: string, reaction: string): Promise<void> { this.checkInitialized(); if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for addReaction'); return; } try { // Get the room const room = this.rooms.get(chatId); if (!room) { throw new MatrixError( `Room not found: ${chatId}`, MatrixErrorType.ROOM, undefined, { chatId } ); } // Create reaction content const content = { 'm.relates_to': { rel_type: 'm.annotation', event_id: messageId, key: reaction } }; // Send reaction event await this.wrapNativePromise( room.sendEventOfType('m.reaction', content) ); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error adding reaction: ${errorMessage}`); // If this is already a MatrixError, just rethrow it if (error instanceof MatrixError) { throw error; } throw MatrixError.event( `Failed to add reaction: ${errorMessage}`, error instanceof Error ? error : undefined, { chatId, messageId, reaction } ); } } /** * Join a chat room */ async joinChat(chatId: string): Promise<void> { this.checkInitialized(); if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for joinChat'); return; } try { // Join room await this.wrapNativePromise( this.nativeClient.joinRoom(chatId) ); Logger.info(`[MatrixIOSClient] Successfully joined room ${chatId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error joining room: ${errorMessage}`); // If this is already a MatrixError, just rethrow it if (error instanceof MatrixError) { throw error; } throw MatrixError.room( `Failed to join room: ${errorMessage}`, error instanceof Error ? error : undefined, { chatId } ); } } /** * Leave a chat room */ async leaveChat(chatId: string): Promise<void> { this.checkInitialized(); if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for leaveChat'); return; } try { // Leave room await this.wrapNativePromise( this.nativeClient.leaveRoom(chatId) ); // Clean up room reference this.rooms.delete(chatId); Logger.info(`[MatrixIOSClient] Successfully left room ${chatId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error leaving room: ${errorMessage}`); // If this is already a MatrixError, just rethrow it if (error instanceof MatrixError) { throw error; } throw MatrixError.room( `Failed to leave room: ${errorMessage}`, error instanceof Error ? error : undefined, { chatId } ); } } /** * Clean up resources when client is no longer needed * Important for memory management */ async cleanup(): Promise<void> { if (!this.isInitialized || this.useMockImplementation) { return; } try { Logger.info('[MatrixIOSClient] Cleaning up resources'); // Clear rooms cache this.rooms.clear(); // Release native client if (this.nativeClient) { // Ensure any operations are complete this.nativeClient.close(); this.nativeClient = null; } this.isInitialized = false; this.userId = null; } catch (error) { Logger.error('[MatrixIOSClient] Error during cleanup:', error); } } /** * Enable end-to-end encryption for a room * @param roomId - ID of the room to enable encryption for */ async enableEncryption(roomId: string): Promise<void> { this.checkInitialized(); if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for enableEncryption'); return; } try { const room = this.rooms.get(roomId); if (!room) { throw MatrixError.room( `Room not found: ${roomId}`, undefined, { roomId } ); } // Enable encryption using m.room.encryption state event await room.enableEncryption({ algorithm: 'm.megolm.v1.aes-sha2' }); Logger.info(`[MatrixIOSClient] Successfully enabled encryption for room ${roomId}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error enabling encryption: ${errorMessage}`); throw MatrixError.encryption( `Failed to enable encryption for room ${roomId}: ${errorMessage}`, error instanceof Error ? error : undefined, { roomId } ); } } /** * Check if a room has encryption enabled * @param roomId - ID of the room to check * @returns Whether encryption is enabled */ async isEncryptionEnabled(roomId: string): Promise<boolean> { this.checkInitialized(); if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for isEncryptionEnabled'); return false; } try { const room = this.rooms.get(roomId); if (!room) { throw MatrixError.room( `Room not found: ${roomId}`, undefined, { roomId } ); } // Check if room has encryption enabled const summary = room.summary; return summary && summary.isEncrypted; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error checking encryption status: ${errorMessage}`); throw MatrixError.encryption( `Failed to check encryption status for room ${roomId}: ${errorMessage}`, error instanceof Error ? error : undefined, { roomId } ); } } /** * Check if the client is initialized * @private */ private checkInitialized(): void { if (!this.isInitialized) { throw new MatrixError( 'Matrix client not initialized', MatrixErrorType.INITIALIZATION ); } } /** * Helper to work with iOS/NativeScript promises * @private */ private wrapNativePromise<T>(nativePromise: any): Promise<T> { return new Promise<T>((resolve, reject) => { try { nativePromise.then( (result: T) => resolve(result), (error: any) => reject(error) ); } catch (error) { reject(error); } }); } /** * Add an event listener * @param eventType Type of event to listen for * @param listener Callback function to handle the event */ addEventListener(eventType: MatrixEventType, listener: MatrixEventListener): void { Logger.debug(`[MatrixIOSClient] Adding event listener for ${eventType}`); if (!this.eventListeners.has(eventType)) { this.eventListeners.set(eventType, new Set()); } const listeners = this.eventListeners.get(eventType); listeners?.add(listener); } /** * Remove an event listener * @param eventType Type of event to stop listening for * @param listener Callback function to remove */ removeEventListener(eventType: MatrixEventType, listener: MatrixEventListener): void { Logger.debug(`[MatrixIOSClient] Removing event listener for ${eventType}`); const listeners = this.eventListeners.get(eventType); if (listeners) { listeners.delete(listener); if (listeners.size === 0) { this.eventListeners.delete(eventType); } } } /** * Start listening for events * This enables real-time updates */ async startListening(): Promise<void> { this.checkInitialized(); if (this.isListening) { Logger.debug('[MatrixIOSClient] Already listening for events'); return; } if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for startListening'); this.isListening = true; return; } try { Logger.info('[MatrixIOSClient] Starting event listener'); // Set up listeners for all rooms for (const [roomId, room] of this.rooms.entries()) { this.setupRoomListeners(roomId, room); } // Register for global Matrix notifications using NSNotificationCenter this.setupMatrixNotifications(); this.isListening = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error starting listeners: ${errorMessage}`); throw new MatrixError( `Failed to start event listeners: ${errorMessage}`, MatrixErrorType.SYNC, error instanceof Error ? error : undefined ); } } /** * Stop listening for events */ async stopListening(): Promise<void> { if (!this.isListening) { return; } if (this.useMockImplementation) { Logger.warn('[MatrixIOSClient] Using mock implementation for stopListening'); this.isListening = false; return; } try { Logger.info('[MatrixIOSClient] Stopping event listener'); // Remove all notification observers const center = NSNotificationCenter.defaultCenter; this.notificationObservers.forEach(observer => { center.removeObserver(observer); }); this.notificationObservers = []; this.listeningRooms.clear(); this.isListening = false; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; Logger.error(`[MatrixIOSClient] Error stopping listeners: ${errorMessage}`); } } /** * Setup Matrix notifications using native NSNotificationCenter */ private setupMatrixNotifications(): void { if (this.useMockImplementation) { Logger.debug('[MatrixIOSClient] Using mock implementation, not setting up notifications'); return; } if (!NSNotificationCenter || !NSOperationQueue) { Logger.error('[MatrixIOSClient] NSNotificationCenter or NSOperationQueue not available'); return; } // Clear any existing observers this.cleanupNotificationObservers(); // Room messages const messageObserver = NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock( "kMXRoomDidFlushDataNotification", null, NSOperationQueue.mainQueue, (notification) => { if (notification && notification.object) { const roomId = notification.object.roomId; if (roomId) { Logger.debug(`[MatrixIOSClient] Room data flushed: ${roomId}`); this.processRoomMessageEvent(roomId, notification.userInfo?.eventId); } } } ); this.notificationObservers.push(messageObserver); // Room state changes const stateObserver = NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock( "kMXRoomDidUpdateSummaryNotification", null, NSOperationQueue.mainQueue, (notification) => { if (notification && notification.object) { const roomId = notification.object.roomId; if (roomId) { Logger.debug(`[MatrixIOSClient] Room summary updated: ${roomId}`); this.processRoomStateEvent(roomId); } } } ); this.notificationObservers.push(stateObserver); // Add observer for typing notifications const typingObserver = NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock( "kMXRoomTypingNotification", null, NSOperationQueue.mainQueue, (notification) => { if (notification && notification.object && notification.userInfo) { const roomId = notification.object.roomId; const userInfo = notification.userInfo; if (roomId) { // Get typing users from notification const typingUsers: string[] = []; if (userInfo.typingUsers) { const users = userInfo.typingUsers; const count = users.count; for (let i = 0; i < count; i++) { const userId = users.objectAtIndex(i); if (userId && userId !== this.userId) { typingUsers.push(userId); } } } // Dispatch typing event this.dispatchEvent(MatrixEventType.TYPING, { roomId, timestamp: new Date(), typing: { users: typingUsers, isTyping: typingUsers.length > 0 } }); Logger.debug(`[MatrixIOSClient] Typing notification for room ${roomId}: ${typingUsers.join(', ')}`); } } } ); this.notificationObservers.push(typingObserver); // Add observer for read receipts const receiptObserver = NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock( "kMXRoomDidUpdateReceiptNotification", null, NSOperationQueue.mainQueue, (notification) => { if (notification && notification.object && notification.userInfo) { const roomId = notification.object.roomId; const userInfo = notification.userInfo; if (roomId && userInfo.receiptType === "m.read" && userInfo.userId && userInfo.eventId) { const userId = userInfo.userId; const messageId = userInfo.eventId; const timestamp = userInfo.ts ? new Date(userInfo.ts) : new Date(); // Dispatch read receipt event this.dispatchEvent(MatrixEventType.READ_RECEIPT, { roomId, timestamp, readReceipt: { userId, messageId, timestamp } }); Logger.debug(`[MatrixIOSClient] Read receipt for room ${roomId}: ${userId} read message ${messageId}`); } } } ); this.notificationObservers.push(receiptObserver); Logger.info('[MatrixIOSClient] Matrix notifications set up successfully'); } /** * Process a room message event from notification */ private async processRoomMessageEvent(roomId: string, eventId: string): Promise<void> { try { const room = this.getRoomById(roomId); if (!room) return; // Get the event const event = await this.wrapNativePromise<any>(room.eventWithEventId(eventId)); if (!event || event.type !== "m.room.message") return; // Convert to SDK message format const senderId = event.sender; // Get sender profile const senderProfile = await this.wrapNativePromise<any>( this.nativeClient.getProfileForUserId(senderId) ); // Dispatch the event this.dispatchEvent(MatrixEventType.MESSAGE_RECEIVED, { roomId, message: { id: eventId, content: event.content.body || "", sender: { id: senderId, name: senderProfile.displayname || senderId } }, timestamp: new Date(event.originServerTs) }); } catch (error) { Logger.error(`[MatrixIOSClient] Error processing message event: ${error}`); } } /** * Process a room state change event from notification */ private async processRoomStateEvent(roomId: string): Promise<void> { try { const room = this.getRoomById(roomId); if (!room) return; // Get room summary const summary = room.summary; // Dispatch the event this.dispatchEvent(MatrixEventType.ROOM_STATE_CHANGED, { roomId, roomState: { name: summary.displayname, membersCount: summary.membersCount, updatedAt: new Date() }, timestamp: new Date() }); } catch (error) { Logger.error(`[MatrixIOSClient] Error processing room state event: ${error}`); } } /** * Dispatch an event to all registered listeners */ private dispatchEvent(eventType: MatrixEventType, data: MatrixEventData): void { const listeners = this.eventListeners.get(eventType); if (!listeners || listeners.size === 0) { return; } // Add timestamp to all events if not already present if (!data.timestamp) { data.timestamp = new Date(); } Logger.debug(`[MatrixIOSClient] Dispatching ${eventType} event`); // Call each listener with the event data listeners.forEach(listener => { try { listener(eventType, data); } catch (error) { Logger.error(`[MatrixIOSClient] Error in event listener for ${eventType}:`, error); } }); } /** * Set up listeners for a specific room */ private setupRoomListeners(roomId: string, room: any): void { if (this.listeningRooms.has(roomId)) { return; // Already listening to this room } try { // Set up timeline listener Logger.debug(`[MatrixIOSClient] Setting up listeners for room ${roomId}`); this.listeningRooms.add(roomId); // We'll use polling instead of direct event observers } catch (error) { Logger.error(`[MatrixIOSClient] Error setting up room listeners for ${roomId}:`, error); } } /** * Get a room by its ID * @param roomId ID of the room to retrieve * @returns The room object or null if not found */ private getRoomById(roomId: string): any { // Check if we already have the room cached if (this.rooms.has(roomId)) { return this.rooms.get(roomId); } // If not, try to get it from the client try { const room = this.nativeClient.roomWithRoomId(roomId); if (room) { // Cache the room for future use this.rooms.set(roomId, room); return room; } } catch (error) { Logger.warn(`[MatrixIOSClient] Error getting room ${roomId}: ${error}`); } return null; } /** * Send a file to a chat * @param chatId - ID of the chat to send the file to * @param localFilePath - Path to the local file to send * @param options - Options for sending the file * @returns The sent message */ async sendFile( chatId: string, localFilePath: string, options?: FileSendOptions ): Promise<MatrixMessage> { this.checkInitialized(); Logger.info(`[MatrixIOSClient] Sending file from ${localFilePath} to chat: ${chatId}`); try { if (this.useMockImplementation) { // Mock implementation for testing Logger.info(`[MatrixIOSClient] Using mock file sending implementation`); // Create a mock message with file content const fileUrl = `mxc://matrix.org/mockFileUri${Date.now()}`; const filename = options?.filename || localFilePath.split('/').pop() || `file_${Date.now()}`; const mockMessage: MatrixMessage = { id: `$mock_file_${Date.now()}`, chatId, sender: { id: this.userId || '@user:matrix.org', name: 'Me', avatar: '' }, content: filename, contentType: this.determineContentType(localFilePath, options?.mimeType), reactions: [], createdAt: new Date(), status: MessageTransactionStatus.SENT, fileContent: { url: fileUrl, mimeType: options?.mimeType || this.guessMimeType(localFilePath), filename: filename, size: 1024, // Mock size thumbnailUrl: this.isImageFile(localFilePath) ? `${fileUrl}/thumbnail` : undefined } }; return mockMessage; } // Get the MXRoom for this chat ID const room = this.getRoomById(chatId); if (!room) { throw MatrixError.room(`Room not found: ${chatId}`); } // Check if file exists const fileManager = NSFileManager.defaultManager; const fileExists = fileManager.fileExistsAtPath(localFilePath); if (!fileExists) { throw MatrixError.event( `File does not exist at path: ${localFilePath}`, undefined, { chatId, localFilePath } ); } // Get file attributes const fileAttrs = fileManager.attributesOfItemAtPathError(localFilePath); const fileSize = fileAttrs.objectForKey(NSFileSize); // Get file data const fileData = NSData.dataWithContentsOfFile(localFilePath); if (!fileData) { throw MatrixError.event( `Could not read file data from: ${localFilePath}`, undefined, { chatId, localFilePath } ); } // Determine content type and message type const filename = options?.filename || localFilePath.split('/').pop() || `file_${Date.now()}`; const mimeType = options?.mimeType || this.guessMimeType(localFilePath); const msgType = this.determineContentType(localFilePath, mimeType); // Create appropriate parameters based on file type let result; if (msgType === 'image' && options?.generateThumbnail !== false) { // For images, we can generate thumbnails const image = UIImage.imageWithData(fileData); if (image) { // Generate a thumbnail if requested let thumbnail: UIImage | null = null; // Check if we should generate a thumbnail (default is true) const shouldGenerateThumbnail = options?.generateThumbnail === undefined || options.generateThumbnail === true; if (shouldGenerateThumbnail) { const maxSize = 800; // Max dimension for thumbnail if (image.size.width > maxSize || image.size.height > maxSize) { const scale = Math.min(maxSize / image.size.width, maxSize / image.size.height); const newWidth = image.size.width * scale; const newHeight = image.size.height * scale; const newSize = CGSizeMake(newWidth, newHeight); UIGraphicsBeginImageContextWithOptions(newSize, false, 0.0); image.drawInRect(CGRectMake(0, 0, newWidth, newHeight)); thumbnail = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); } else { thumbnail = image; } } // Compress the image if requested let finalImageData = fileData; if (options?.compress) { const quality = options.compressionQuality || 0.85; const compressedData = UIImageJPEGRepresentation(image, quality); if (compressedData) { finalImageData = compressedData; } } // Send the image with the room's SDK method result = await this.wrapNativePromise<any>( room.sendImageData(finalImageData, image.size, mim