feat: implement Paarrot API for Electron integration with navigation and IPC handlers
This commit is contained in:
@@ -1,23 +1,29 @@
|
|||||||
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Box, Text } from 'folds';
|
import { Box, Text } from 'folds';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
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 { useAtomValue } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { WindowControls } from './WindowControls';
|
import { WindowControls } from './WindowControls';
|
||||||
import * as css from './TitleBar.css';
|
import * as css from './TitleBar.css';
|
||||||
import { getUnreadInfos, getOrphanParents, guessPerfectParent } from '../../utils/room';
|
import { getUnreadInfos, getOrphanParents, guessPerfectParent, getAllParents } from '../../utils/room';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../../pages/pathUtils';
|
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 = {
|
type NewMessageNotification = {
|
||||||
|
id: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
|
exiting?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TitleBarProps = {
|
export type TitleBarProps = {
|
||||||
@@ -28,56 +34,201 @@ export type TitleBarProps = {
|
|||||||
export function TitleBar({ mx, children }: TitleBarProps) {
|
export function TitleBar({ mx, children }: TitleBarProps) {
|
||||||
const [updateCounter, setUpdateCounter] = useState(0);
|
const [updateCounter, setUpdateCounter] = useState(0);
|
||||||
const roomId = useAtomValue(activeRoomIdAtom);
|
const roomId = useAtomValue(activeRoomIdAtom);
|
||||||
const [newMessage, setNewMessage] = useState<NewMessageNotification | null>(null);
|
const [notifications, setNotifications] = useState<NewMessageNotification[]>([]);
|
||||||
const [isExiting, setIsExiting] = useState(false);
|
const dismissTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const lastMxRef = useRef<MatrixClient | undefined>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (dismissTimerRef.current) {
|
dismissTimersRef.current.forEach(timer => clearTimeout(timer));
|
||||||
clearTimeout(dismissTimerRef.current);
|
dismissTimersRef.current.clear();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-dismiss notification after 10 seconds
|
// Auto-dismiss notification after 10 seconds
|
||||||
const scheduleDismiss = useCallback(() => {
|
const scheduleDismiss = useCallback((notificationId: string) => {
|
||||||
if (dismissTimerRef.current) {
|
// Clear existing timer for this notification if any
|
||||||
clearTimeout(dismissTimerRef.current);
|
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(() => {
|
setTimeout(() => {
|
||||||
setNewMessage(null);
|
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||||
setIsExiting(false);
|
dismissTimersRef.current.delete(notificationId);
|
||||||
}, 200); // Wait for exit animation
|
}, 200); // Wait for exit animation
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
dismissTimersRef.current.set(notificationId, timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Navigate to the new message
|
// Navigate to the new message
|
||||||
const handleNotificationClick = useCallback((e: React.MouseEvent) => {
|
const handleNotificationClick = useCallback((notification: NewMessageNotification) => (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!newMessage || !mx) return;
|
if (!mx) return;
|
||||||
|
|
||||||
const targetRoomId = newMessage.roomId;
|
const targetRoomId = notification.roomId;
|
||||||
const eventId = newMessage.eventId;
|
const eventId = notification.eventId;
|
||||||
|
|
||||||
// Dismiss notification
|
// Dismiss this notification
|
||||||
setIsExiting(true);
|
const timer = dismissTimersRef.current.get(notification.id);
|
||||||
setTimeout(() => {
|
if (timer) {
|
||||||
setNewMessage(null);
|
clearTimeout(timer);
|
||||||
setIsExiting(false);
|
dismissTimersRef.current.delete(notification.id);
|
||||||
}, 200);
|
|
||||||
|
|
||||||
if (dismissTimerRef.current) {
|
|
||||||
clearTimeout(dismissTimerRef.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Simple navigation approach - just try all paths
|
||||||
try {
|
try {
|
||||||
@@ -116,7 +267,7 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
console.error('Fallback navigation also failed:', fallbackError);
|
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
|
// Subscribe to room changes for unread updates and new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,13 +282,99 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
if (!room || !event) return;
|
if (!room || !event) return;
|
||||||
|
|
||||||
// Ignore if it's the currently active room
|
// 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
|
// 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
|
// 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
|
// Get content
|
||||||
const content = event.getContent();
|
const content = event.getContent();
|
||||||
@@ -152,14 +389,25 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
? content.body.substring(0, 30) + '...'
|
? content.body.substring(0, 30) + '...'
|
||||||
: content.body;
|
: content.body;
|
||||||
|
|
||||||
setNewMessage({
|
// Generate unique ID for this notification
|
||||||
roomId: room.roomId,
|
const notificationId = `${room.roomId}-${event.getId()}-${Date.now()}`;
|
||||||
eventId: event.getId() || '',
|
|
||||||
senderName,
|
// Add to notifications array (limit to 4 notifications)
|
||||||
preview,
|
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);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
@@ -171,9 +419,137 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
mx.off(RoomEvent.Receipt, update);
|
mx.off(RoomEvent.Receipt, update);
|
||||||
mx.off(ClientEvent.Room, 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(() => {
|
const roomName = useMemo(() => {
|
||||||
if (!mx || !roomId) return null;
|
if (!mx || !roomId) return null;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
@@ -192,14 +568,14 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
// Build title parts
|
// Build title parts
|
||||||
const titleParts = useMemo(() => {
|
const titleParts = useMemo(() => {
|
||||||
let suffix = '';
|
let suffix = '';
|
||||||
if (roomName) {
|
if (breadcrumbPath) {
|
||||||
suffix += ` - ${roomName}`;
|
suffix += ` | ${breadcrumbPath}`;
|
||||||
}
|
}
|
||||||
if (totalUnread > 0) {
|
if (totalUnread > 0) {
|
||||||
suffix += ` (${totalUnread})`;
|
suffix += ` (${totalUnread})`;
|
||||||
}
|
}
|
||||||
return { suffix };
|
return { suffix };
|
||||||
}, [roomName, totalUnread]);
|
}, [breadcrumbPath, totalUnread]);
|
||||||
|
|
||||||
const handleDragStart = async () => {
|
const handleDragStart = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -224,18 +600,19 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
|||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
>
|
>
|
||||||
<Text size="T200" truncate style={{ color: 'inherit' }}>
|
<Text size="T200" truncate style={{ color: 'inherit' }}>
|
||||||
<b>Paarrot</b>{titleParts.suffix}
|
<span style={{ fontWeight: 700 }}>Paarrot</span>{titleParts.suffix}
|
||||||
</Text>
|
</Text>
|
||||||
{newMessage && (
|
{notifications.map((notification) => (
|
||||||
<div
|
<div
|
||||||
className={`${css.NewMessagePill} ${isExiting ? css.NewMessagePillExiting : ''}`}
|
key={notification.id}
|
||||||
onClick={handleNotificationClick}
|
className={`${css.NewMessagePill} ${notification.exiting ? css.NewMessagePillExiting : ''}`}
|
||||||
|
onClick={handleNotificationClick(notification)}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
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>
|
</div>
|
||||||
)}
|
))}
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
<WindowControls />
|
<WindowControls />
|
||||||
|
|||||||
@@ -19,6 +19,28 @@ interface CallContextValue {
|
|||||||
|
|
||||||
const CallContext = createContext<CallContextValue | null>(null);
|
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
|
* Hook to access the call context
|
||||||
* @returns The call context value
|
* @returns The call context value
|
||||||
@@ -103,6 +125,7 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
|
|||||||
);
|
);
|
||||||
|
|
||||||
setCallService(service);
|
setCallService(service);
|
||||||
|
setGlobalCallService(service); // Make available globally for Paarrot API
|
||||||
setCallSupported(true);
|
setCallSupported(true);
|
||||||
setCallSupportLoading(false);
|
setCallSupportLoading(false);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -146,6 +169,7 @@ export function useCallService(options?: UseCallServiceOptions): CallContextValu
|
|||||||
return () => {
|
return () => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
service?.dispose();
|
service?.dispose();
|
||||||
|
setGlobalCallService(null); // Clear global reference
|
||||||
};
|
};
|
||||||
}, [mx, configuredLivekitUrl]);
|
}, [mx, configuredLivekitUrl]);
|
||||||
|
|
||||||
|
|||||||
418
src/app/paarrot-api.ts
Normal file
418
src/app/paarrot-api.ts
Normal 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 };
|
||||||
@@ -27,6 +27,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { isTauri, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
import { isTauri, sendNotification, setupNotificationTapListener } from '../../utils/tauri';
|
||||||
|
import { setPaarrotNavigate, initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the selected emoji style font to the document.
|
* 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 = {
|
type ClientNonUIFeaturesProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -334,6 +356,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<FaviconUpdater />
|
<FaviconUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<PaarrotAPIInitializer />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { getFallbackSession } from '../../state/sessions';
|
|||||||
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
|
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
|
||||||
import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
|
import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
|
||||||
import { TitleBar } from '../../components/title-bar';
|
import { TitleBar } from '../../components/title-bar';
|
||||||
|
import { initPaarrotAPI } from '../../paarrot-api';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -250,6 +251,13 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
useVisibilitySync(mx);
|
useVisibilitySync(mx);
|
||||||
useBackgroundSync(mx);
|
useBackgroundSync(mx);
|
||||||
|
|
||||||
|
// Initialize Paarrot API when client is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (mx) {
|
||||||
|
initPaarrotAPI(mx);
|
||||||
|
}
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState.status === AsyncStatus.Idle) {
|
if (loadState.status === AsyncStatus.Idle) {
|
||||||
loadMatrix();
|
loadMatrix();
|
||||||
|
|||||||
Reference in New Issue
Block a user