/** * Paarrot API Handler * * This module handles API actions from the Electron API server. * It receives actions via IPC and executes them on the Matrix client. * * To use this, import and call initPaarrotAPI() early in your app initialization. */ import { MatrixClient } from 'matrix-js-sdk'; import { getHomeRoomPath, getDirectRoomPath, getSpaceRoomPath } from './pages/pathUtils'; import { getCanonicalAliasOrRoomId, isRoomId } from './utils/matrix'; import { getCallService } from './features/call/useCall'; import { getNotificationType, getUnreadInfo, reactionOrEditEvent, roomHaveNotification, roomHaveUnread, } from './utils/room'; import { MessageEvent, NotificationType, RoomType, StateEvent } from '../types/matrix/room'; /** * Global reference to the router navigate function * This is set by the router and used to navigate programmatically */ let navigateFunction: ((path: string) => void) | null = null; /** * Track if the API has been initialized to prevent duplicate listeners */ let isInitialized = false; /** * @param {any} room * @returns {boolean} */ function isSpaceLikeRoom(room: any): boolean { if (!room) return false; if (typeof room.isSpaceRoom === 'function' && room.isSpaceRoom()) return true; const roomKindEvent = room.currentState?.getStateEvents?.(StateEvent.PaarrotRoomKind, ''); const roomKind = roomKindEvent?.getContent?.()?.kind; if (roomKind === 'forum_space') return true; const createEvent = room.currentState?.getStateEvents?.(StateEvent.RoomCreate, ''); const roomType = createEvent?.getContent?.()?.type; return roomType === RoomType.Forum; } /** * Set the navigate function for programmatic navigation * Call this from your router setup */ export function setPaarrotNavigate(navigate: (path: string) => void) { navigateFunction = navigate; console.log('Paarrot API: Navigate function registered'); } /** * Initialize the Paarrot API handler * This should be called once during app startup */ export function initPaarrotAPI(matrixClient: MatrixClient) { // Check if we're running in Electron if (!(window as any).electron?.api?.onAction) { console.log('Paarrot API: Not running in Electron, skipping API initialization'); return; } // Prevent duplicate registrations if (isInitialized) { console.log('Paarrot API: Already initialized, skipping'); return; } isInitialized = true; console.log('Paarrot API: Initializing API handler'); // Listen for API actions from the Electron API server (window as any).electron.api.onAction(async (action: { action: string; params: any; responseChannel: string; }) => { console.log('Paarrot API: Received action:', action.action, action.params); try { let result; switch (action.action) { case 'get-status': result = await getStatus(matrixClient); break; case 'toggle-mute': result = await toggleMute(matrixClient); break; case 'set-mute': result = await setMute(matrixClient, action.params.muted); break; case 'toggle-deafen': result = await toggleDeafen(matrixClient); break; case 'set-deafen': result = await setDeafen(matrixClient, action.params.deafened); break; case 'change-channel': result = await changeChannel(matrixClient, action.params.roomId); break; case 'get-channels': result = await getChannels(matrixClient); break; case 'send-message': result = await sendMessage(matrixClient, action.params.roomId, action.params.message); break; case 'send-message-current': result = await sendMessageCurrent(matrixClient, action.params.message); break; case 'get-current-room': result = await getCurrentRoom(matrixClient); break; case 'get-messages-groups': result = await getMessagesByScope(matrixClient, 'groups', action.params.limit); break; case 'get-messages-dms': result = await getMessagesByScope(matrixClient, 'dms', action.params.limit); break; case 'get-messages-combined': result = await getMessagesByScope(matrixClient, 'combined', action.params.limit); break; case 'get-unreads-groups': result = await getUnreadsByScope(matrixClient, 'groups'); break; case 'get-unreads-dms': result = await getUnreadsByScope(matrixClient, 'dms'); break; case 'get-unreads-combined': result = await getUnreadsByScope(matrixClient, 'combined'); break; default: throw new Error(`Unknown action: ${action.action}`); } // Send success response back (window as any).electron.api.sendResponse(action.responseChannel, { success: true, data: result, }); console.log('Paarrot API: Action completed successfully:', action.action); } catch (error: any) { console.error('Paarrot API: Action failed:', action.action, error); // Send error response back (window as any).electron.api.sendResponse(action.responseChannel, { success: false, error: error.message || 'Unknown error', }); } }); console.log('Paarrot API: Handler initialized'); } /** * Get current app status */ async function getStatus(matrixClient: MatrixClient) { const currentRoom = getCurrentRoomId(); // Get actual mute/deafen state from CallService if available const callService = getCallService(); const activeCall = callService?.getActiveCall(); return { muted: activeCall?.isMuted ?? false, deafened: activeCall?.isDeafened ?? false, currentRoom, connected: matrixClient.isInitialSyncComplete() || false, userId: matrixClient.getUserId(), }; } /** * Toggle microphone mute */ async function toggleMute(matrixClient: MatrixClient) { const callService = getCallService(); if (!callService) { return { muted: false, message: 'CallService not initialized - no active call', }; } const activeCall = callService.getActiveCall(); if (!activeCall) { return { muted: false, message: 'No active call to mute/unmute', }; } // Toggle mute state const newMuteState = callService.toggleMute(); return { muted: newMuteState, }; } /** * Set microphone mute state */ async function setMute(matrixClient: MatrixClient, muted: boolean) { const callService = getCallService(); if (!callService) { return { muted, message: 'CallService not initialized - no active call', }; } const activeCall = callService.getActiveCall(); if (!activeCall) { return { muted, message: 'No active call to mute/unmute', }; } // Set mute to desired state if it's different if (activeCall.isMuted !== muted) { callService.toggleMute(); } return { muted, }; } /** * Toggle deafen (mute speakers) */ async function toggleDeafen(matrixClient: MatrixClient) { const callService = getCallService(); if (!callService) { return { deafened: false, message: 'CallService not initialized - no active call', }; } const activeCall = callService.getActiveCall(); if (!activeCall) { return { deafened: false, message: 'No active call to deafen/undeafen', }; } // Toggle deafen state const newDeafenState = callService.toggleDeafen(); return { deafened: newDeafenState, }; } /** * Set deafen state */ async function setDeafen(matrixClient: MatrixClient, deafened: boolean) { const callService = getCallService(); if (!callService) { return { deafened, message: 'CallService not initialized - no active call', }; } const activeCall = callService.getActiveCall(); if (!activeCall) { return { deafened, message: 'No active call to deafen/undeafen', }; } // Set deafen to desired state if it's different if (activeCall.isDeafened !== deafened) { callService.toggleDeafen(); } return { deafened, }; } /** * Change to a different room/channel */ async function changeChannel(matrixClient: any, roomId: string) { const room = matrixClient?.getRoom(roomId); if (!room) { throw new Error(`Room not found: ${roomId}`); } if (!navigateFunction) { throw new Error('Navigate function not registered. Call setPaarrotNavigate from your router setup.'); } // Determine the appropriate path based on room type const roomIdOrAlias = getCanonicalAliasOrRoomId(matrixClient, roomId); let path: string; // Check if it's a direct message const isDirect = room.guessDMUserId() !== null; if (isDirect) { // Navigate to direct message path = getDirectRoomPath(roomIdOrAlias); } else if (isSpaceLikeRoom(room)) { // Navigate to space path = getSpaceRoomPath(roomIdOrAlias, roomIdOrAlias); } else { // Navigate to regular room in home path = getHomeRoomPath(roomIdOrAlias); } // Navigate to the room navigateFunction(path); return { roomId, roomName: room.name || 'Unnamed Room', path, }; } /** * Get list of rooms/channels */ async function getChannels(matrixClient: any) { const rooms = matrixClient?.getRooms() || []; return rooms .filter((room: any) => !isSpaceLikeRoom(room)) .map((room: any) => { // Extract server domain from roomId (!localpart:domain.tld) const serverMatch = room.roomId.match(/:(.+)$/); const server = serverMatch ? serverMatch[1] : null; return { roomId: room.roomId, name: room.name || 'Unnamed Room', server: server, isDirect: room.getMyMembership() === 'invite' ? false : room.guessDMUserId() !== null, avatar: room.getMxcAvatarUrl() || null, }; }) .sort((a: any, b: any) => a.name.localeCompare(b.name)); } /** * Send a message to a specific room */ async function sendMessage(matrixClient: any, roomId: string, message: string) { const room = matrixClient?.getRoom(roomId); if (!room) { throw new Error(`Room not found: ${roomId}`); } const content = { msgtype: 'm.text', body: message.trim(), }; const result = await matrixClient.sendMessage(roomId, content); return { eventId: result.event_id, roomId, }; } /** * Send a message to the current room */ async function sendMessageCurrent(matrixClient: any, message: string) { const currentRoomId = getCurrentRoomId(); if (!currentRoomId) { throw new Error('No room currently active'); } return sendMessage(matrixClient, currentRoomId, message); } /** * Get information about the current room */ async function getCurrentRoom(matrixClient: any) { const currentRoomId = getCurrentRoomId(); if (!currentRoomId) { throw new Error('No room currently active'); } const room = matrixClient?.getRoom(currentRoomId); if (!room) { throw new Error(`Current room not found: ${currentRoomId}`); } // Extract server domain from roomId (!localpart:domain.tld) const serverMatch = room.roomId.match(/:(.+)$/); const server = serverMatch ? serverMatch[1] : null; return { roomId: room.roomId, name: room.name || 'Unnamed Room', server: server, avatar: room.getMxcAvatarUrl() || null, isDirect: room.guessDMUserId() !== null, }; } const DEFAULT_MESSAGE_LIMIT = 10; const MAX_MESSAGE_LIMIT = 100; type MessageScope = 'groups' | 'dms' | 'combined'; type ApiMessage = { eventId: string; roomId: string; roomName: string; isDirect: boolean; sender: string; senderName: string; timestamp: number; msgtype: string; body: string; }; function isDirectRoom(room: any): boolean { return room?.guessDMUserId?.() !== null; } function parseMessageLimit(limit?: number | string): number { const parsedLimit = Number(limit); return Math.min( Math.max(Number.isFinite(parsedLimit) ? Math.floor(parsedLimit) : DEFAULT_MESSAGE_LIMIT, 1), MAX_MESSAGE_LIMIT ); } /** * Collect recent room messages from the local live timeline (newest last). * Skips reactions, edits, and redacted events. */ function collectRecentMessages(room: any, limit: number): ApiMessage[] { const liveEvents = room.getLiveTimeline?.()?.getEvents?.() ?? []; const isDirect = isDirectRoom(room); const roomId = room.roomId; const roomName = room.name || 'Unnamed Room'; const messages: ApiMessage[] = []; for (let i = liveEvents.length - 1; i >= 0 && messages.length < limit; i -= 1) { const evt = liveEvents[i]; if (!evt) continue; if (typeof evt.isRedacted === 'function' && evt.isRedacted()) continue; if (reactionOrEditEvent(evt)) continue; const type = evt.getType?.(); if ( type !== MessageEvent.RoomMessage && type !== MessageEvent.RoomMessageEncrypted && type !== MessageEvent.Sticker ) { continue; } const content = evt.getContent?.() ?? {}; const sender = evt.getSender?.() ?? ''; const member = sender ? room.getMember?.(sender) : null; if (type === MessageEvent.RoomMessageEncrypted && !content.msgtype) { messages.push({ eventId: evt.getId?.() ?? '', roomId, roomName, isDirect, sender, senderName: member?.name ?? sender, timestamp: evt.getTs?.() ?? 0, msgtype: 'm.encrypted', body: '[encrypted]', }); continue; } messages.push({ eventId: evt.getId?.() ?? '', roomId, roomName, isDirect, sender, senderName: member?.name ?? sender, timestamp: evt.getTs?.() ?? 0, msgtype: content.msgtype || (type === MessageEvent.Sticker ? 'm.sticker' : 'm.text'), body: content.body || '', }); } return messages.reverse(); } function getRoomsForScope(matrixClient: any, scope: MessageScope) { const rooms = (matrixClient?.getRooms?.() || []).filter( (room: any) => !isSpaceLikeRoom(room) && room.getMyMembership?.() === 'join' ); if (scope === 'groups') { return rooms.filter((room: any) => !isDirectRoom(room)); } if (scope === 'dms') { return rooms.filter((room: any) => isDirectRoom(room)); } return rooms; } /** * Get recent messages across groups, DMs, or both (combined). */ async function getMessagesByScope( matrixClient: any, scope: MessageScope, limit?: number | string ) { const limitNum = parseMessageLimit(limit); const rooms = getRoomsForScope(matrixClient, scope); const candidates: ApiMessage[] = []; for (const room of rooms) { candidates.push(...collectRecentMessages(room, limitNum)); } candidates.sort((a, b) => a.timestamp - b.timestamp); const messages = candidates.slice(-limitNum); return { scope, limit: limitNum, count: messages.length, messages, }; } /** * List unread conversations across groups, DMs, or both (combined). * Uses the same unread rules as the app UI (notification counts + read receipts, muted excluded). */ async function getUnreadsByScope(matrixClient: any, scope: MessageScope) { const rooms = getRoomsForScope(matrixClient, scope); const unreads: Array<{ roomId: string; name: string; isDirect: boolean; avatar: string | null; total: number; highlight: number; latest?: { eventId: string; sender: string; senderName: string; timestamp: number; msgtype: string; body: string; }; }> = []; for (const room of rooms) { if (getNotificationType(matrixClient, room.roomId) === NotificationType.Mute) { continue; } if (!roomHaveNotification(room) && !roomHaveUnread(matrixClient, room)) { continue; } const unreadInfo = getUnreadInfo(room); const latestMessages = collectRecentMessages(room, 1); const latest = latestMessages[0]; unreads.push({ roomId: room.roomId, name: room.name || 'Unnamed Room', isDirect: isDirectRoom(room), avatar: room.getMxcAvatarUrl?.() || null, total: unreadInfo.total, highlight: unreadInfo.highlight, latest: latest ? { eventId: latest.eventId, sender: latest.sender, senderName: latest.senderName, timestamp: latest.timestamp, msgtype: latest.msgtype, body: latest.body, } : undefined, }); } unreads.sort((a, b) => { const aTs = a.latest?.timestamp ?? 0; const bTs = b.latest?.timestamp ?? 0; if (bTs !== aTs) return bTs - aTs; if (b.highlight !== a.highlight) return b.highlight - a.highlight; return b.total - a.total; }); return { scope, count: unreads.length, unreads, }; } /** * Get the currently active room ID * Extracts room ID from the current URL pathname */ function getCurrentRoomId(): string | null { try { // Get the pathname from URL // Support both normal routing and hash routing let pathname = window.location.pathname; // If using hash router, parse from hash if (window.location.hash) { const hashMatch = window.location.hash.match(/#(.+)/); if (hashMatch) { pathname = hashMatch[1]; } } // URL patterns: // /home/:roomIdOrAlias/:eventId?/ // /direct/:roomIdOrAlias/:eventId?/ // /:spaceIdOrAlias/:roomIdOrAlias/:eventId?/ // Try to match room patterns const patterns = [ /^\/home\/([^/]+)/, // /home/:roomIdOrAlias /^\/direct\/([^/]+)/, // /direct/:roomIdOrAlias /^\/[^/]+\/([^/]+)/, // /:spaceIdOrAlias/:roomIdOrAlias (space rooms) ]; for (const pattern of patterns) { const match = pathname.match(pattern); if (match && match[1]) { // Decode URI component in case the room ID was encoded return decodeURIComponent(match[1]); } } return null; } catch (error) { console.error('Paarrot API: Error getting current room ID:', error); return null; } } export default { initPaarrotAPI };