624 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|