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
+9 -3
View File
@@ -1008,20 +1008,24 @@ Themes:
---
### [ ] P5-7 · In-App Notification Toast Redesign (TDS mode only)
### [x] P5-7 · In-App Notification Toast Redesign (TDS mode only)
**What:** Slim dark card sliding in from bottom-right: user avatar, display name (orange), truncated message preview, room name (dim), × dismiss. 4-second auto-dismiss. TDS variables only — non-TDS keeps existing behavior.
**[AUDIT REQUIRED]** Find where in-app notification toasts are currently rendered in `src/app/`. May be in `ClientNonUIFeatures.tsx`.
**Complexity:** Medium.
**COMPLETED June 2026.** `src/app/state/toast.ts``ToastNotif` type + `toastQueueAtom` (unbounded append) + `dismissToastAtom` (filter by id). `src/app/features/toast/LotusToastContainer.tsx` — fixed `position:fixed; bottom:1.5rem; right:1.5rem; z-index:9997` stack of toast cards. Each card: 24px circular avatar (img or initials fallback), display name in `--lt-accent-orange`, body text (80-char truncated), room name in `--lt-text-secondary`, × dismiss, 4 s `setTimeout` auto-dismiss. Slide-in via `@keyframes lotusToastIn` (translateX 120%→0, 0.2 s ease-out; opacity-only fade under `prefers-reduced-motion`). `<LotusToastContainer />` added in `App.tsx` as sibling to `<NightLightOverlay />` inside `JotaiProvider`. `InviteNotifications` and `MessageNotifications` in `ClientNonUIFeatures.tsx` call `setToast` and return early when `document.hasFocus()` — OS notification path unchanged when window is blurred. Message toast uses `mDirectAtom` to pick `getDirectRoomPath` vs `getHomeRoomPath` for correct `hashPath` navigation. Invite toast uses `hashPath: getInboxInvitesPath()`.
---
### [ ] P5-8 · Mention Highlight Animation
### [x] P5-8 · Mention Highlight Animation
**What:** Brief pulse/glow animation (0.40.6s ease-out) on incoming @mention messages. CSS keyframe: scale 1.0 → 1.005 → 1.0 + background glow pulse. Only fires on new incoming messages, not on page load. Respects `prefers-reduced-motion`.
**[AUDIT REQUIRED]** Find where mentioned messages receive their highlight class in the timeline. Verify animation doesn't affect scroll position.
**Complexity:** Low.
**COMPLETED — already upstream (discovered June 2026).** Full audit confirmed: `MentionHighlightPulse` style in `src/app/components/message/layout/layout.css.ts:113-129` — 0.6 s `ease-out` `scale(1.003)` + `Warning.Main` box-shadow glow. Wired in `Message.tsx:975` via `isMentioned && isNewRef.current` — one-shot via `isNewRef` flipped in `onAnimationEnd`. Detects `m.mentions.user_ids` and `m.mentions.room`. Scoped to `(prefers-reduced-motion: no-preference)`. No implementation needed.
---
### [ ] P5-9 · LFG (Looking for Group) Slash Command
@@ -1094,12 +1098,14 @@ Themes:
---
### [ ] P5-17 · Quick Emoji Reaction Bar (Hover Shortcut)
### [x] P5-17 · Quick Emoji Reaction Bar (Hover Shortcut)
**What:** Floating mini-bar of 5 most recent reactions above the hover toolbar. One-click react. 6th button opens full emoji board.
**[AUDIT REQUIRED]** Find the message hover toolbar in `Message.tsx` and confirm how to inject an additional row without breaking layout. Confirm recent emoji tracking mechanism in EmojiBoard.
**Complexity:** Medium.
**COMPLETED June 2026.** `MessageQuickReactions` component (already existed) moved from the 3-dots PopOut menu into the main hover toolbar in `Message.tsx`. Now renders directly on hover between the "Add Reaction" emoji-board button and the "Reply" button, guarded by `canSendReaction`. Limit increased from 4 to 5 recent emojis (`useRecentEmoji(mx, 5)`). The `<Line />` separator (appropriate in menu context only) removed from the component. `setEmojiBoardAnchor(undefined)` added to the toolbar callback so clicking a quick reaction also closes any open emoji picker. Removed from 3-dots menu entirely (was redundant). Emoji source: Matrix account data `ElementRecentEmoji`, sorted by usage count.
---
### [ ] P5-18 · Status-Based Avatar Border Color
+2
View File
@@ -150,6 +150,8 @@ Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]`
### UX & Composer
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
- **Quick emoji reactions on hover**: The 5 most-recently-used emoji reactions appear directly in the message hover toolbar (between the emoji-board button and Reply), so reacting requires a single click rather than opening the 3-dots menu first. Clicking a quick-reaction also closes any open emoji picker. Powered by `useRecentEmoji` sourced from Matrix account data.
- **In-app notification toasts**: When a message or invite notification fires and the browser window is focused, a slim TDS-styled toast card slides in from the bottom-right instead of triggering an OS notification. Card shows: 24px avatar (initials fallback), sender name in orange, truncated message body, room name, × dismiss, 4 s auto-dismiss. Clicking navigates directly to the correct room (DM or home path) or the invites inbox. OS notifications are unchanged when the window is not focused. Implemented in `src/app/features/toast/LotusToastContainer.tsx` + `src/app/state/toast.ts`.
- **Collapsible long messages**: Messages exceeding ~20 lines are auto-collapsed with a "Read more ↓" button. Click to expand inline; a "Collapse ↑" button re-folds. Threshold (in lines) configurable in Settings → Appearance. Uses CSS `max-height` + `overflow: hidden` — works correctly with code blocks and embedded media. Respects `prefers-reduced-motion`.
- **Message send animation**: Own sent messages fade and scale into the timeline (0.15 s ease-out: `scale(0.97)→scale(1)`, `opacity 0.4→1`). Incoming messages are unaffected. Respects `prefers-reduced-motion`.
- **Right-click room context menu**: Expanded sidebar room context menu — **Mute** now opens a duration submenu (15 min / 1 hr / 8 hr / 24 hr / Indefinite) with auto-restore after the selected window; **Copy Room Link** copies the `matrix.to` URL with a "Copied!" flash; **Mark as Read** marks the room read to the latest event; plus Leave Room and Room Settings shortcuts.
+9 -10
View File
@@ -152,7 +152,7 @@ type MessageQuickReactionsProps = {
export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
({ onReaction, ...props }, ref) => {
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 4);
const recentEmojis = useRecentEmoji(mx, 5);
if (recentEmojis.length === 0) return <span />;
return (
@@ -180,7 +180,6 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
</IconButton>
))}
</Box>
<Line size="300" />
</>
);
},
@@ -1035,6 +1034,14 @@ export const Message = React.memo(
</IconButton>
</PopOut>
)}
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId()!, key, shortcode);
setEmojiBoardAnchor(undefined);
}}
/>
)}
<IconButton
onClick={onReplyClick}
data-event-id={mEvent.getId()}
@@ -1085,14 +1092,6 @@ export const Message = React.memo(
}}
>
<Menu>
{canSendReaction && (
<MessageQuickReactions
onReaction={(key, shortcode) => {
onReactionToggle(mEvent.getId()!, key, shortcode);
closeMenu();
}}
/>
)}
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
{canSendReaction && (
<MenuItem
@@ -0,0 +1,207 @@
import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
// Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes';
function ensureKeyframes() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
@keyframes lotusToastIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes lotusToastIn {
from { opacity: 0; }
to { opacity: 1; }
}
}
`;
document.head.appendChild(style);
}
type ToastCardProps = {
toast: ToastNotif;
};
function ToastCard({ toast }: ToastCardProps) {
const dismiss = useSetAtom(dismissToastAtom);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id]);
const handleCardClick = () => {
// window.location.hash setter auto-prepends '#', so values must not include it
window.location.hash = toast.hashPath ?? `/room/${toast.roomId}`;
dismiss(toast.id);
};
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
dismiss(toast.id);
};
const cardStyle: CSSProperties = {
position: 'relative',
background: 'var(--lt-bg-card, #1a1a2e)',
border: '1px solid var(--lt-border-color, rgba(255,255,255,0.1))',
borderRadius: '12px',
padding: '12px 14px',
minWidth: '280px',
maxWidth: '340px',
boxShadow: 'var(--lt-box-glow-orange, 0 4px 16px rgba(0,0,0,0.4))',
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
};
const rowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginRight: '20px',
};
const avatarStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
};
const initialsStyle: CSSProperties = {
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--lt-accent-orange-dim, rgba(255,107,0,0.15))',
border: '1px solid var(--lt-accent-orange-border, rgba(255,107,0,0.35))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
color: 'var(--lt-accent-orange, #ff6b00)',
flexShrink: 0,
};
const nameStyle: CSSProperties = {
color: 'var(--lt-accent-orange, #ff6b00)',
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const dismissBtnStyle: CSSProperties = {
position: 'absolute',
top: '8px',
right: '10px',
background: 'none',
border: 'none',
color: 'var(--lt-text-secondary, #7fa3bf)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
padding: '2px 4px',
borderRadius: '4px',
};
const bodyStyle: CSSProperties = {
color: 'var(--lt-text-primary, #c4d9ee)',
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const roomNameStyle: CSSProperties = {
color: 'var(--lt-text-secondary, #7fa3bf)',
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const initials = toast.displayName
.split(' ')
.slice(0, 2)
.map((w) => w[0] ?? '')
.join('')
.toUpperCase();
return (
<div
role="button"
tabIndex={0}
style={cardStyle}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleCardClick();
}}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
>
<button
type="button"
style={dismissBtnStyle}
onClick={handleDismiss}
aria-label="Dismiss notification"
>
×
</button>
<div style={rowStyle}>
{toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
) : (
<div style={initialsStyle} aria-hidden="true">
{initials || '?'}
</div>
)}
<span style={nameStyle}>{toast.displayName}</span>
</div>
<div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div>
</div>
);
}
export function LotusToastContainer() {
useEffect(() => {
ensureKeyframes();
}, []);
const toasts = useAtomValue(toastQueueAtom);
if (toasts.length === 0) return null;
const containerStyle: CSSProperties = {
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 9997,
display: 'flex',
flexDirection: 'column',
gap: '8px',
pointerEvents: 'auto',
};
return (
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} />
))}
</div>
);
}
+2
View File
@@ -14,6 +14,7 @@ import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
@@ -95,6 +96,7 @@ function App() {
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
+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) ?? '',
});
}
+27
View File
@@ -0,0 +1,27 @@
import { atom } from 'jotai';
export type ToastNotif = {
id: string;
avatarUrl?: string;
displayName: string;
body: string;
roomName: string;
roomId: string;
hashPath?: string; // overrides window.location.hash navigation when set
};
const baseAtom = atom<ToastNotif[]>([]);
// Write-only setter used in ClientNonUIFeatures
export const toastQueueAtom = atom<ToastNotif[], [ToastNotif | null], void>(
(get) => get(baseAtom),
(get, set, notif) => {
if (notif === null) return; // no-op guard
set(baseAtom, [...get(baseAtom), notif]);
}
);
export const dismissToastAtom = atom<null, [string], void>(
null,
(get, set, id) => set(baseAtom, get(baseAtom).filter((t) => t.id !== id))
);