Files
cinny/src/app/components/title-bar/TitleBar.tsx

624 lines
22 KiB
TypeScript

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<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);
// 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 () => {
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 (
<Box className={css.TitleBar} alignItems="Center" justifyContent="SpaceBetween">
<Box
className={css.TitleBarDragRegion}
alignItems="Center"
gap="200"
grow="Yes"
onMouseDown={handleDragStart}
>
<Text size="T200" truncate style={{ color: 'inherit' }}>
<span style={{ fontWeight: 700 }}>Paarrot</span>{titleParts.suffix}
</Text>
{notifications.map((notification) => (
<div
key={notification.id}
className={`${css.NewMessagePill} ${notification.exiting ? css.NewMessagePillExiting : ''}`}
onClick={handleNotificationClick(notification)}
onMouseDown={(e) => e.stopPropagation()}
title={`${notification.senderName}: ${notification.preview}`}
>
<span>{notification.senderName}: {notification.preview}</span>
</div>
))}
{children}
</Box>
<UpdateNotification />
<WindowControls />
</Box>
);
}