feat: quick emoji reactions on hover, in-app notification toasts, mention pulse audit

P5-17: MessageQuickReactions moved from 3-dots menu to hover toolbar;
shows 5 recent emoji directly on hover. Clicking a quick-reaction also
closes any open emoji picker (setEmojiBoardAnchor). Line separator
removed from component.

P5-7: LotusToastContainer slides in from bottom-right when window is
focused — replaces OS notification for in-focus events. Correct room
path (DM vs home) derived from mDirectAtom. Invite toast routes to
inbox. 4s auto-dismiss. Full TDS styling via CSS custom properties.

P5-8: Confirmed already implemented upstream (MentionHighlightPulse,
0.6s scale+glow, one-shot, prefers-reduced-motion). Marked complete.

Code-review fixes: toast navigation used nonexistent /room/ route;
emoji picker stayed open after toolbar quick-reaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:32:37 -04:00
parent 24e6882e72
commit 4876c2e4ca
7 changed files with 300 additions and 17 deletions
+44 -4
View File
@@ -1,4 +1,4 @@
import { useAtomValue } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
@@ -15,7 +15,13 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getInboxInvitesPath, getInboxNotificationsPath } from '../pathUtils';
import {
getDirectRoomPath,
getHomeRoomPath,
getInboxInvitesPath,
getInboxNotificationsPath,
} from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberDisplayName,
getNotificationType,
@@ -28,6 +34,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { toastQueueAtom } from '../../state/toast';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
@@ -107,6 +114,7 @@ function InviteNotifications() {
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
const setToast = useSetAtom(toastQueueAtom);
const soundSrc =
inviteSoundId !== 'none' ? (NOTIFICATION_SOUND_MAP[inviteSoundId] ?? InviteSound) : null;
@@ -123,6 +131,18 @@ function InviteNotifications() {
const notify = useCallback(
(count: number) => {
if (document.hasFocus()) {
setToast({
id: `invite-${Date.now()}`,
displayName: 'Invitation',
body: `You have ${count} new invitation request.`,
roomName: 'Invites',
roomId: '',
hashPath: getInboxInvitesPath(),
});
return;
}
const noti = new window.Notification('Invitation', {
icon: LogoSVG,
badge: LogoSVG,
@@ -135,7 +155,7 @@ function InviteNotifications() {
noti.close();
};
},
[navigate],
[navigate, setToast],
);
const playSound = useCallback(() => {
@@ -194,6 +214,8 @@ function MessageNotifications() {
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const soundSrc =
messageSoundId !== 'none'
@@ -219,13 +241,30 @@ function MessageNotifications() {
roomName,
roomAvatar,
username,
roomId,
eventId,
body,
}: {
roomName: string;
roomAvatar?: string;
username: string;
roomId: string;
eventId: string;
body?: string;
}) => {
if (document.hasFocus()) {
setToast({
id: `${roomId}-${eventId}-${Date.now()}`,
avatarUrl: roomAvatar,
displayName: username,
body: (body ?? '').slice(0, 80),
roomName,
roomId,
hashPath: mDirects.has(roomId) ? getDirectRoomPath(roomId) : getHomeRoomPath(roomId),
});
return;
}
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
@@ -242,7 +281,7 @@ function MessageNotifications() {
notifRef.current?.close();
notifRef.current = noti;
},
[navigate],
[navigate, setToast, mDirects],
);
const playSound = useCallback(() => {
@@ -298,6 +337,7 @@ function MessageNotifications() {
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
});
}