From ef55d1583f960ea94e20e3b7b96efb6bd97ebf6d Mon Sep 17 00:00:00 2001 From: Litruv Date: Sat, 21 Feb 2026 21:40:38 +1100 Subject: [PATCH] feat: implement Paarrot API for Electron integration with navigation and IPC handlers --- src/app/components/title-bar/TitleBar.tsx | 483 +++++++++++++++++-- src/app/features/call/useCall.ts | 24 + src/app/paarrot-api.ts | 418 ++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 23 + src/app/pages/client/ClientRoot.tsx | 8 + 5 files changed, 903 insertions(+), 53 deletions(-) create mode 100644 src/app/paarrot-api.ts diff --git a/src/app/components/title-bar/TitleBar.tsx b/src/app/components/title-bar/TitleBar.tsx index 3812f14..b762b55 100644 --- a/src/app/components/title-bar/TitleBar.tsx +++ b/src/app/components/title-bar/TitleBar.tsx @@ -1,23 +1,29 @@ import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react'; import { Box, Text } from 'folds'; import { invoke } from '@tauri-apps/api/core'; -import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room } from 'matrix-js-sdk'; +import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; import { WindowControls } from './WindowControls'; import * as css from './TitleBar.css'; -import { getUnreadInfos, getOrphanParents, guessPerfectParent } from '../../utils/room'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { getUnreadInfos, getOrphanParents, guessPerfectParent, getAllParents } from '../../utils/room'; +import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../utils/matrix'; import { activeRoomIdAtom } from '../../state/activeRoom'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { mDirectAtom } from '../../state/mDirectList'; import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../../pages/pathUtils'; +import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; +import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode'; +import { isRoomId } from '../../utils/matrix'; type NewMessageNotification = { + id: string; roomId: string; eventId: string; senderName: string; preview: string; + exiting?: boolean; }; export type TitleBarProps = { @@ -28,56 +34,201 @@ export type TitleBarProps = { export function TitleBar({ mx, children }: TitleBarProps) { const [updateCounter, setUpdateCounter] = useState(0); const roomId = useAtomValue(activeRoomIdAtom); - const [newMessage, setNewMessage] = useState(null); - const [isExiting, setIsExiting] = useState(false); - const dismissTimerRef = useRef | null>(null); + const [notifications, setNotifications] = useState([]); + const dismissTimersRef = useRef>>(new Map()); + const lastMxRef = useRef(); const navigate = useNavigate(); const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); - // Clear dismiss timer on unmount + // Notification-related state - only populated when mx is available + const [notificationData, setNotificationData] = useState<{ + preferences: { mute: Set; specialMessages: Set; allMessages: Set }; + keywordPatterns: string[]; + userId: string | undefined; + displayName: string | undefined; + }>({ + preferences: { mute: new Set(), specialMessages: new Set(), allMessages: new Set() }, + keywordPatterns: [], + userId: undefined, + displayName: undefined, + }); + + // Update notification data when mx is available (only when mx changes) + useEffect(() => { + // Only update if mx actually changed + if (lastMxRef.current === mx) return; + lastMxRef.current = mx; + + if (!mx) { + setNotificationData({ + preferences: { mute: new Set(), specialMessages: new Set(), allMessages: new Set() }, + keywordPatterns: [], + userId: undefined, + displayName: undefined, + }); + return; + } + + // We can't use hooks here, so we'll fetch the data directly + const userId = mx.getUserId(); + + // Get push rules for keywords + const pushRulesEvt = mx.getAccountData(AccountDataEvent.PushRules); + const pushRules = pushRulesEvt?.getContent() ?? { global: {} }; + const content = pushRules.global.content ?? []; + const keywordPatterns = content + .filter((rule: any) => rule.default === false && typeof rule.pattern === 'string') + .map((rule: any) => rule.pattern as string); + + // Get notification preferences + const global = pushRules?.global; + const room = global?.room ?? []; + const override = global?.override ?? []; + + const preferences = { + mute: new Set(), + specialMessages: new Set(), + allMessages: new Set(), + }; + + // Check override rules for muted rooms + override.forEach((rule: any) => { + if (isRoomId(rule.rule_id) && getNotificationMode(rule.actions) === NotificationMode.OFF) { + preferences.mute.add(rule.rule_id); + } + }); + + // Check room rules for special messages (OFF mode = mentions only) + room.forEach((rule: any) => { + if (getNotificationMode(rule.actions) === NotificationMode.OFF) { + preferences.specialMessages.add(rule.rule_id); + } + }); + + // Check room rules for all messages (Notify or NotifyLoud mode) + room.forEach((rule: any) => { + if (getNotificationMode(rule.actions) !== NotificationMode.OFF) { + preferences.allMessages.add(rule.rule_id); + } + }); + + // Get display name + const user = userId ? mx.getUser(userId) : null; + const displayName = user?.displayName; + + console.log('[TitleBar] Notification preferences loaded:', { + muted: Array.from(preferences.mute), + specialMessages: Array.from(preferences.specialMessages), + allMessages: Array.from(preferences.allMessages), + keywordPatterns, + }); + + setNotificationData({ + preferences, + keywordPatterns, + userId, + displayName, + }); + }, [mx]); + + // Check if message should trigger a notification for SpecialMessages mode + const shouldNotifySpecialMessage = useCallback((event: MatrixEvent): boolean => { + if (!notificationData.userId) return false; + + const content = event.getContent(); + const body = content.body?.toLowerCase() || ''; + + // Check for user mentions in m.mentions + const mentions = content['m.mentions']; + if (mentions?.user_ids?.includes(notificationData.userId)) { + return true; + } + + // Check for @room mention + if (mentions?.room === true) { + return true; + } + + // Check if body contains @room + if (body.includes('@room')) { + return true; + } + + // Check if body contains user's display name + if (notificationData.displayName && body.includes(notificationData.displayName.toLowerCase())) { + return true; + } + + // Check if body contains user's username/localpart + const username = getMxIdLocalPart(notificationData.userId); + if (username && body.includes(username.toLowerCase())) { + return true; + } + + // Check if body contains any of the user's keywords + for (const keyword of notificationData.keywordPatterns) { + if (body.includes(keyword.toLowerCase())) { + return true; + } + } + + return false; + }, [notificationData]); + + // Clear all dismiss timers on unmount useEffect(() => { return () => { - if (dismissTimerRef.current) { - clearTimeout(dismissTimerRef.current); - } + dismissTimersRef.current.forEach(timer => clearTimeout(timer)); + dismissTimersRef.current.clear(); }; }, []); // Auto-dismiss notification after 10 seconds - const scheduleDismiss = useCallback(() => { - if (dismissTimerRef.current) { - clearTimeout(dismissTimerRef.current); + const scheduleDismiss = useCallback((notificationId: string) => { + // Clear existing timer for this notification if any + const existingTimer = dismissTimersRef.current.get(notificationId); + if (existingTimer) { + clearTimeout(existingTimer); } - dismissTimerRef.current = setTimeout(() => { - setIsExiting(true); + + // Schedule new dismiss timer + const timer = setTimeout(() => { + setNotifications(prev => + prev.map(n => n.id === notificationId ? { ...n, exiting: true } : n) + ); setTimeout(() => { - setNewMessage(null); - setIsExiting(false); + setNotifications(prev => prev.filter(n => n.id !== notificationId)); + dismissTimersRef.current.delete(notificationId); }, 200); // Wait for exit animation }, 10000); + + dismissTimersRef.current.set(notificationId, timer); }, []); // Navigate to the new message - const handleNotificationClick = useCallback((e: React.MouseEvent) => { + const handleNotificationClick = useCallback((notification: NewMessageNotification) => (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - if (!newMessage || !mx) return; + if (!mx) return; - const targetRoomId = newMessage.roomId; - const eventId = newMessage.eventId; + const targetRoomId = notification.roomId; + const eventId = notification.eventId; - // Dismiss notification - setIsExiting(true); - setTimeout(() => { - setNewMessage(null); - setIsExiting(false); - }, 200); - - if (dismissTimerRef.current) { - clearTimeout(dismissTimerRef.current); + // Dismiss this notification + const timer = dismissTimersRef.current.get(notification.id); + if (timer) { + clearTimeout(timer); + dismissTimersRef.current.delete(notification.id); } + + setNotifications(prev => + prev.map(n => n.id === notification.id ? { ...n, exiting: true } : n) + ); + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== notification.id)); + }, 200); // Simple navigation approach - just try all paths try { @@ -116,7 +267,7 @@ export function TitleBar({ mx, children }: TitleBarProps) { console.error('Fallback navigation also failed:', fallbackError); } } - }, [newMessage, mx, roomToParents, mDirects, navigate]); + }, [mx, roomToParents, mDirects, navigate]); // Subscribe to room changes for unread updates and new messages useEffect(() => { @@ -131,13 +282,99 @@ export function TitleBar({ mx, children }: TitleBarProps) { if (!room || !event) return; // Ignore if it's the currently active room - if (room.roomId === roomId) return; + if (room.roomId === roomId) { + console.log('[TitleBar] Skipping: active room'); + return; + } // Ignore if it's our own message - if (event.getSender() === mx.getUserId()) return; + if (event.getSender() === mx.getUserId()) { + console.log('[TitleBar] Skipping: own message'); + return; + } // Only notify for actual messages - if (event.getType() !== 'm.room.message') return; + if (event.getType() !== 'm.room.message') { + console.log('[TitleBar] Skipping: not a message event, type:', event.getType()); + return; + } + + // Only show for recent messages (within 15 seconds) to avoid showing notifications + // for old messages when loading history + const eventTime = event.getTs(); + const now = Date.now(); + if (now - eventTime > 15000) { + console.log('[TitleBar] Skipping: message too old', (now - eventTime) / 1000, 'seconds'); + return; // More than 15 seconds old + } + + // Only show for unread messages - check if event is after the last read receipt + const readUpToEventId = room.getEventReadUpTo(mx.getUserId() ?? ''); + if (readUpToEventId) { + const timeline = room.getLiveTimeline().getEvents(); + const readUpToIndex = timeline.findIndex(e => e.getId() === readUpToEventId); + const eventIndex = timeline.findIndex(e => e.getId() === event.getId()); + + // If we found both events and the new event is not after the read marker, skip it + if (readUpToIndex !== -1 && eventIndex !== -1 && eventIndex <= readUpToIndex) { + console.log('[TitleBar] Skipping: already read (eventIndex:', eventIndex, ', readUpToIndex:', readUpToIndex, ')'); + return; + } + } + + // Determine notification mode for this room + const roomIdStr = room.roomId; + let notificationMode: RoomNotificationMode; + + if (notificationData.preferences.mute.has(roomIdStr)) { + notificationMode = RoomNotificationMode.Mute; + } else if (notificationData.preferences.specialMessages.has(roomIdStr)) { + notificationMode = RoomNotificationMode.SpecialMessages; + } else if (notificationData.preferences.allMessages.has(roomIdStr)) { + notificationMode = RoomNotificationMode.AllMessages; + } else { + notificationMode = RoomNotificationMode.Unset; + } + + // Debug logging + console.log('[TitleBar Notification]', { + roomId: roomIdStr, + roomName: room.name, + notificationMode, + isDM: mDirects.has(roomIdStr), + preferences: { + muted: notificationData.preferences.mute.has(roomIdStr), + specialMessages: notificationData.preferences.specialMessages.has(roomIdStr), + allMessages: notificationData.preferences.allMessages.has(roomIdStr), + }, + }); + + // Never show for muted rooms + if (notificationMode === RoomNotificationMode.Mute) return; + + // DMs with Unset mode should notify for all messages (underride rule behavior) + const isDM = mDirects.has(roomIdStr); + const treatAsAllMessages = notificationMode === RoomNotificationMode.AllMessages || + (notificationMode === RoomNotificationMode.Unset && isDM); + + console.log('[TitleBar] treatAsAllMessages:', treatAsAllMessages, 'isDM:', isDM); + + // For SpecialMessages mode, only show if message would trigger a notification + // For Unset mode (non-DM), also check for special messages + if (!treatAsAllMessages && notificationMode === RoomNotificationMode.SpecialMessages) { + const shouldNotify = shouldNotifySpecialMessage(event); + console.log('[TitleBar] SpecialMessages mode check:', shouldNotify); + if (!shouldNotify) return; + } + if (!treatAsAllMessages && notificationMode === RoomNotificationMode.Unset && !isDM) { + const shouldNotify = shouldNotifySpecialMessage(event); + console.log('[TitleBar] Unset mode (non-DM) check:', shouldNotify); + if (!shouldNotify) return; + } + + console.log('[TitleBar] Passed all checks, showing notification bubble'); + + // For AllMessages mode, show all messages (no additional check needed) // Get content const content = event.getContent(); @@ -152,14 +389,25 @@ export function TitleBar({ mx, children }: TitleBarProps) { ? content.body.substring(0, 30) + '...' : content.body; - setNewMessage({ - roomId: room.roomId, - eventId: event.getId() || '', - senderName, - preview, + // Generate unique ID for this notification + const notificationId = `${room.roomId}-${event.getId()}-${Date.now()}`; + + // Add to notifications array (limit to 4 notifications) + setNotifications(prev => { + const newNotification: NewMessageNotification = { + id: notificationId, + roomId: room.roomId, + eventId: event.getId() || '', + senderName, + preview, + }; + + // Remove oldest if we already have 4 + const updated = prev.length >= 4 ? prev.slice(1) : prev; + return [...updated, newNotification]; }); - setIsExiting(false); - scheduleDismiss(); + + scheduleDismiss(notificationId); }; mx.on(RoomEvent.Timeline, handleTimelineEvent); @@ -171,9 +419,137 @@ export function TitleBar({ mx, children }: TitleBarProps) { mx.off(RoomEvent.Receipt, update); mx.off(ClientEvent.Room, update); }; - }, [mx, roomId, scheduleDismiss]); + }, [mx, roomId, scheduleDismiss, notificationData, shouldNotifySpecialMessage]); - // Get room name + // Clear notifications when user reads messages in a room + useEffect(() => { + if (!mx) return; + + const handleReceipt = (event: MatrixEvent, room: Room) => { + const myUserId = mx.getUserId(); + if (!myUserId) return; + + const content = event.getContent(); + const isMyReceipt = Object.keys(content).find((eventId) => + (Object.keys(content[eventId]) as any[]).find( + (receiptType) => content[eventId][receiptType][myUserId] + ) + ); + + // If this is my receipt, clear all notifications for this room + if (isMyReceipt) { + setNotifications(prev => { + const updated = prev.filter(n => n.roomId !== room.roomId); + // Clear timers for removed notifications + prev.forEach(n => { + if (n.roomId === room.roomId) { + const timer = dismissTimersRef.current.get(n.id); + if (timer) { + clearTimeout(timer); + dismissTimersRef.current.delete(n.id); + } + } + }); + return updated; + }); + } + }; + + mx.on(RoomEvent.Receipt, handleReceipt); + return () => { + mx.off(RoomEvent.Receipt, handleReceipt); + }; + }, [mx]); + + // Clear notifications for the active room when it changes + useEffect(() => { + if (!roomId) return; + + setNotifications(prev => { + const updated = prev.filter(n => n.roomId !== roomId); + // Clear timers for removed notifications + prev.forEach(n => { + if (n.roomId === roomId) { + const timer = dismissTimersRef.current.get(n.id); + if (timer) { + clearTimeout(timer); + dismissTimersRef.current.delete(n.id); + } + } + }); + return updated; + }); + }, [roomId]); + + // Build breadcrumb path for room (Space > Subspace > RoomName) + const breadcrumbPath = useMemo(() => { + if (!mx || !roomId) return null; + + const room = mx.getRoom(roomId); + if (!room) return null; + + const roomName = room.name; + if (!roomName) return null; + + // DMs just show the room name without hierarchy + if (mDirects.has(roomId)) { + return roomName; + } + + // Get the parent chain + const directParents = roomToParents.get(roomId); + if (!directParents || directParents.size === 0) { + // No parents, just the room name + return roomName; + } + + // Build the parent chain recursively + // This returns the chain of parent names ABOVE the given roomId, not including roomId itself + const buildParentChain = (currentRoomId: string): string[] => { + const parents = roomToParents.get(currentRoomId); + if (!parents || parents.size === 0) { + // No parents above this room + return []; + } + + // Pick the best parent (or first if only one) + const parentsList = Array.from(parents); + let chosenParent: string; + + if (parentsList.length === 1) { + chosenParent = parentsList[0]; + } else { + // Use heuristic to pick best parent + // Prefer a parent that is itself a top-level space (orphan) + const orphanParents = parentsList.filter(p => !roomToParents.has(p)); + if (orphanParents.length > 0) { + // Use guessPerfectParent if available, otherwise just pick first orphan + chosenParent = guessPerfectParent(mx, currentRoomId, orphanParents) ?? orphanParents[0]; + } else { + // All parents have parents themselves, just pick first and continue + chosenParent = parentsList[0]; + } + } + + // Get the parent's name + const parentRoom = mx.getRoom(chosenParent); + if (!parentRoom?.name) return []; + + // Get the chain above the parent + const upperChain = buildParentChain(chosenParent); + + // Return the upper chain plus this parent + return [...upperChain, parentRoom.name]; + }; + + const parentChain = buildParentChain(roomId); + const path = [...parentChain, roomName]; + + return path.join(' > '); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mx, roomId, roomToParents, mDirects, updateCounter]); + + // Get room name (for backward compatibility, keeping simple name too) const roomName = useMemo(() => { if (!mx || !roomId) return null; const room = mx.getRoom(roomId); @@ -192,14 +568,14 @@ export function TitleBar({ mx, children }: TitleBarProps) { // Build title parts const titleParts = useMemo(() => { let suffix = ''; - if (roomName) { - suffix += ` - ${roomName}`; + if (breadcrumbPath) { + suffix += ` | ${breadcrumbPath}`; } if (totalUnread > 0) { suffix += ` (${totalUnread})`; } return { suffix }; - }, [roomName, totalUnread]); + }, [breadcrumbPath, totalUnread]); const handleDragStart = async () => { try { @@ -224,18 +600,19 @@ export function TitleBar({ mx, children }: TitleBarProps) { onMouseDown={handleDragStart} > - Paarrot{titleParts.suffix} + Paarrot{titleParts.suffix} - {newMessage && ( + {notifications.map((notification) => (
e.stopPropagation()} - title={`${newMessage.senderName}: ${newMessage.preview}`} + title={`${notification.senderName}: ${notification.preview}`} > - {newMessage.senderName}: {newMessage.preview} + {notification.senderName}: {notification.preview}
- )} + ))} {children} diff --git a/src/app/features/call/useCall.ts b/src/app/features/call/useCall.ts index 47ddbee..99547e2 100644 --- a/src/app/features/call/useCall.ts +++ b/src/app/features/call/useCall.ts @@ -19,6 +19,28 @@ interface CallContextValue { const CallContext = createContext(null); +/** + * Global reference to CallService for use outside React components + * (e.g., from Electron IPC handlers) + */ +let globalCallServiceInstance: CallService | null = null; + +/** + * Get the global CallService instance + * @returns The CallService instance or null if not initialized + */ +export function getCallService(): CallService | null { + return globalCallServiceInstance; +} + +/** + * Set the global CallService instance + * @param service - The CallService instance + */ +function setGlobalCallService(service: CallService | null): void { + globalCallServiceInstance = service; +} + /** * Hook to access the call context * @returns The call context value @@ -103,6 +125,7 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu ); setCallService(service); + setGlobalCallService(service); // Make available globally for Paarrot API setCallSupported(true); setCallSupportLoading(false); // eslint-disable-next-line no-console @@ -146,6 +169,7 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu return () => { unsubscribe?.(); service?.dispose(); + setGlobalCallService(null); // Clear global reference }; }, [mx, configuredLivekitUrl]); diff --git a/src/app/paarrot-api.ts b/src/app/paarrot-api.ts new file mode 100644 index 0000000..95966d7 --- /dev/null +++ b/src/app/paarrot-api.ts @@ -0,0 +1,418 @@ +/** + * 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'; + +/** + * 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; + +/** + * 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; + } + + 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; + + 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 (room.isSpaceRoom()) { + // 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) => !room.isSpaceRoom()) + .map((room: any) => ({ + roomId: room.roomId, + name: room.name || 'Unnamed Room', + 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, + }; + + 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}`); + } + + return { + roomId: room.roomId, + name: room.name || 'Unnamed Room', + avatar: room.getMxcAvatarUrl() || null, + isDirect: room.guessDMUserId() !== null, + }; +} + +/** + * 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 }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 30e1aa9..c932358 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -27,6 +27,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { isTauri, sendNotification, setupNotificationTapListener } from '../../utils/tauri'; +import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api'; /** * Applies the selected emoji style font to the document. @@ -322,6 +323,27 @@ function MessageNotifications() { ); } +/** + * Initializes the Paarrot API for Electron integration + * Registers the navigate function and sets up IPC handlers + */ +function PaarrotAPIInitializer() { + const navigate = useNavigate(); + const mx = useMatrixClient(); + + useEffect(() => { + // Register navigate function for Paarrot API + setPaarrotNavigate(navigate); + + // Initialize Paarrot API handlers + initPaarrotAPI(mx); + + console.log('Paarrot API: Initialized with navigate function'); + }, [navigate, mx]); + + return null; +} + type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -334,6 +356,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index fd62bda..b21d67e 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -38,6 +38,7 @@ import { getFallbackSession } from '../../state/sessions'; import { CallProviderWrapper } from '../../features/call/CallProviderWrapper'; import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri'; import { TitleBar } from '../../components/title-bar'; +import { initPaarrotAPI } from '../../paarrot-api'; function ClientRootLoading() { return ( @@ -250,6 +251,13 @@ export function ClientRoot({ children }: ClientRootProps) { useVisibilitySync(mx); useBackgroundSync(mx); + // Initialize Paarrot API when client is ready + useEffect(() => { + if (mx) { + initPaarrotAPI(mx); + } + }, [mx]); + useEffect(() => { if (loadState.status === AsyncStatus.Idle) { loadMatrix();