nativescript-matrix-sdk
Version:
Native Matrix SDK integration for NativeScript
1,529 lines (1,312 loc) • 101 kB
text/typescript
/// <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