feat: implement Paarrot API for Electron integration with navigation and IPC handlers

This commit is contained in:
2026-02-21 21:40:38 +11:00
parent 6a31ea64ed
commit ef55d1583f
5 changed files with 903 additions and 53 deletions

View File

@@ -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<NewMessageNotification | null>(null);
const [isExiting, setIsExiting] = useState(false);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [notifications, setNotifications] = useState<NewMessageNotification[]>([]);
const dismissTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const lastMxRef = useRef<MatrixClient | undefined>();
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<string>; specialMessages: Set<string>; allMessages: Set<string> };
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<IPushRules>() ?? { 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<string>(),
specialMessages: new Set<string>(),
allMessages: new Set<string>(),
};
// 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}
>
<Text size="T200" truncate style={{ color: 'inherit' }}>
<b>Paarrot</b>{titleParts.suffix}
<span style={{ fontWeight: 700 }}>Paarrot</span>{titleParts.suffix}
</Text>
{newMessage && (
{notifications.map((notification) => (
<div
className={`${css.NewMessagePill} ${isExiting ? css.NewMessagePillExiting : ''}`}
onClick={handleNotificationClick}
key={notification.id}
className={`${css.NewMessagePill} ${notification.exiting ? css.NewMessagePillExiting : ''}`}
onClick={handleNotificationClick(notification)}
onMouseDown={(e) => e.stopPropagation()}
title={`${newMessage.senderName}: ${newMessage.preview}`}
title={`${notification.senderName}: ${notification.preview}`}
>
<span>{newMessage.senderName}: {newMessage.preview}</span>
<span>{notification.senderName}: {notification.preview}</span>
</div>
)}
))}
{children}
</Box>
<WindowControls />

View File

@@ -19,6 +19,28 @@ interface CallContextValue {
const CallContext = createContext<CallContextValue | null>(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]);

418
src/app/paarrot-api.ts Normal file
View File

@@ -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 };

View File

@@ -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) {
<FaviconUpdater />
<InviteNotifications />
<MessageNotifications />
<PaarrotAPIInitializer />
{children}
</>
);

View File

@@ -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();