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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
Reference in New Issue
Block a user