diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 024291013..1ff7a57d0 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -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`). `` added in `App.tsx` as sibling to `` 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.4–0.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 `` 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 diff --git a/README.md b/README.md index e800fa0ab..b31892ab5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 8649f0521..568c3a067 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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 ; return ( @@ -180,7 +180,6 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( ))} - ); }, @@ -1035,6 +1034,14 @@ export const Message = React.memo( )} + {canSendReaction && ( + { + onReactionToggle(mEvent.getId()!, key, shortcode); + setEmojiBoardAnchor(undefined); + }} + /> + )} - {canSendReaction && ( - { - onReactionToggle(mEvent.getId()!, key, shortcode); - closeMenu(); - }} - /> - )} {canSendReaction && ( | 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 ( +
{ + if (e.key === 'Enter' || e.key === ' ') handleCardClick(); + }} + aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`} + > + +
+ {toast.avatarUrl ? ( + + ) : ( + + )} + {toast.displayName} +
+
{toast.body}
+
{toast.roomName}
+
+ ); +} + +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 ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 8f5e58b39..fdb2a4f01 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -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() { + diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c2a261e17..f3ff24964 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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) ?? '', }); } diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts new file mode 100644 index 000000000..c69b79b9a --- /dev/null +++ b/src/app/state/toast.ts @@ -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([]); + +// Write-only setter used in ClientNonUIFeatures +export const toastQueueAtom = atom( + (get) => get(baseAtom), + (get, set, notif) => { + if (notif === null) return; // no-op guard + set(baseAtom, [...get(baseAtom), notif]); + } +); + +export const dismissToastAtom = atom( + null, + (get, set, id) => set(baseAtom, get(baseAtom).filter((t) => t.id !== id)) +);