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, IPushRules } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; import { WindowControls } from './WindowControls'; import { UpdateNotification } from '../update-notification'; import * as css from './TitleBar.css'; 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 = { mx?: MatrixClient; children?: ReactNode; }; export function TitleBar({ mx, children }: TitleBarProps) { const [updateCounter, setUpdateCounter] = useState(0); const roomId = useAtomValue(activeRoomIdAtom); const [notifications, setNotifications] = useState([]); const dismissTimersRef = useRef>>(new Map()); const lastMxRef = useRef(); const navigate = useNavigate(); const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); // 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 () => { dismissTimersRef.current.forEach(timer => clearTimeout(timer)); dismissTimersRef.current.clear(); }; }, []); // Auto-dismiss notification after 10 seconds const scheduleDismiss = useCallback((notificationId: string) => { // Clear existing timer for this notification if any const existingTimer = dismissTimersRef.current.get(notificationId); if (existingTimer) { clearTimeout(existingTimer); } // Schedule new dismiss timer const timer = setTimeout(() => { setNotifications(prev => prev.map(n => n.id === notificationId ? { ...n, exiting: true } : n) ); setTimeout(() => { 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((notification: NewMessageNotification) => (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); if (!mx) return; const targetRoomId = notification.roomId; const eventId = notification.eventId; // 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 { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, targetRoomId); // Check if it's a direct message if (mDirects.has(targetRoomId)) { const path = getDirectRoomPath(roomIdOrAlias, eventId); console.log('Navigating to direct room:', path); navigate(path); return; } // Check if it belongs to a space const orphanParents = getOrphanParents(roomToParents, targetRoomId); if (orphanParents.length > 0) { const parentSpace = guessPerfectParent(mx, targetRoomId, orphanParents) ?? orphanParents[0]; const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); const path = getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId); console.log('Navigating to space room:', path); navigate(path); return; } // Default to home room const path = getHomeRoomPath(roomIdOrAlias, eventId); console.log('Navigating to home room:', path); navigate(path); } catch (error) { console.error('Error navigating to room:', error); // Last resort - navigate without event ID try { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, targetRoomId); navigate(getHomeRoomPath(roomIdOrAlias)); } catch (fallbackError) { console.error('Fallback navigation also failed:', fallbackError); } } }, [mx, roomToParents, mDirects, navigate]); // Subscribe to room changes for unread updates and new messages useEffect(() => { if (!mx) return; const update = () => setUpdateCounter(c => c + 1); const handleTimelineEvent = (event: MatrixEvent, room: Room | undefined) => { update(); // Check if this is a new message we should notify about if (!room || !event) return; // Ignore if it's the currently active room if (room.roomId === roomId) { console.log('[TitleBar] Skipping: active room'); return; } // Ignore if it's our own message if (event.getSender() === mx.getUserId()) { console.log('[TitleBar] Skipping: own message'); return; } // Only notify for actual messages 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(); if (!content.body) return; // Get sender display name const sender = room.getMember(event.getSender() || ''); const senderName = sender?.name || event.getSender() || 'Unknown'; // Create preview (truncate if needed) const preview = content.body.length > 30 ? content.body.substring(0, 30) + '...' : content.body; // 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]; }); scheduleDismiss(notificationId); }; mx.on(RoomEvent.Timeline, handleTimelineEvent); mx.on(RoomEvent.Receipt, update); mx.on(ClientEvent.Room, update); return () => { mx.off(RoomEvent.Timeline, handleTimelineEvent); mx.off(RoomEvent.Receipt, update); mx.off(ClientEvent.Room, update); }; }, [mx, roomId, scheduleDismiss, notificationData, shouldNotifySpecialMessage]); // 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); return room?.name || null; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mx, roomId, updateCounter]); // Get total unread count const totalUnread = useMemo(() => { if (!mx) return 0; const unreadInfos = getUnreadInfos(mx); return unreadInfos.reduce((sum, info) => sum + info.total, 0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mx, updateCounter]); // Build title parts const titleParts = useMemo(() => { let suffix = ''; if (breadcrumbPath) { suffix += ` | ${breadcrumbPath}`; } if (totalUnread > 0) { suffix += ` (${totalUnread})`; } return { suffix }; }, [breadcrumbPath, totalUnread]); const handleDragStart = async () => { try { await invoke('window_start_drag'); } catch (error) { console.error('Failed to start window drag:', error); } }; // Only render on desktop (when Tauri is available) if (typeof (window as any).__TAURI__ === 'undefined') { return null; } return ( Paarrot{titleParts.suffix} {notifications.map((notification) => (
e.stopPropagation()} title={`${notification.senderName}: ${notification.preview}`} > {notification.senderName}: {notification.preview}
))} {children}
); }