Compare commits

...

4 Commits

Author SHA1 Message Date
jared 579449acc3 docs: Slack-style per-thread notifications (P4-1) across catalog/README/TODO/BUGS
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s
LOTUS_FEATURES: Notifications subsection under Threads (participating default,
per-thread All/Mentions/Mute, badge behavior). README: thread-notifications
bullet. LOTUS_TODO: P4-1 → [~] + 6-step live-QA checklist + caveats.
LOTUS_BUGS: verification row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00
jared 34592d9144 fix(build): copy-pdf-worker must never mask the real build error
closeBundle also runs when the build FAILED mid-render (dist/ absent); the
plugin's copyFileSync then threw ENOENT and vite reported THAT instead of the
actual render error — exactly what hid the real failure in the Windows desktop
CI run. Now: warn-and-skip on any error, mkdir the dest dir when copying.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00
jared 0adce52d37 fix(threads): review-wave fixes for per-thread notifications
- useRoomsListener now PREPENDS the emitting Room (was appended): the SDK emits
  RoomEvent.UnreadNotifications with VARIABLE arity (0/1/2 args), so a trailing
  extra arg landed in the wrong positional slot on the most common room-count
  sync path — room.isSpaceRoom() threw inside the SDK emit loop and the badge
  PUT never ran. Both consumers updated (CONFIRMED HIGH review finding).
- roomToUnread: SpaceChild RESET now passes the thread prefs so muted-thread
  subtraction survives space-child state changes.

Reviewer also verified: badge subtraction math exact (no double-subtraction),
encrypted thread replies caught by the timeline guard (m.relates_to is
cleartext), fresh prefs flow to handlers, single-owner wiring load-bearing.
Documented-acceptable: hasCurrentUserParticipated can lag until the server
bundle refreshes after your first reply; dedupe maps grow per-session only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00
jared 501d493ca4 feat(threads): Slack-style per-thread notifications (P4-1)
Default = Participating: thread replies notify only when you've posted in the
thread or are @mentioned; per-thread override to All / Mentions-only / Mute via
a bell menu in the thread panel header. Modes sync across devices in
io.lotus.thread_notifications account data (pruned on write: left rooms, >180d,
cap 200/room). Muted threads: no notifications/sounds, chip badge suppressed
(+BellMute glyph), and their counts are subtracted from the room's sidebar
badge (client-side; clamped ≥0).

Also fixes the thread notification path itself: thread replies are now owned by
exactly ONE handler (room-level ThreadEvent.NewReply via a new useRoomsListener
hook, with per-thread dedupe, panel-aware focus suppression, and per-thread OS
tag coalescing) — the existing RoomEvent.Timeline handlers in the notifier and
the unread binder are explicitly thread-guarded, eliminating the previously
un-gated/double path. Room badges now also refresh live on
RoomEvent.UnreadNotifications (surgical per-room PUT; fixes thread-badge lag).

Pure decision core shouldNotifyThreadReply (13-case matrix) + prune + unread
subtraction: +32 tests (648 total). E2EE caveat documented: mentions-only may
under-notify pre-decryption (same class as the existing path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:39:10 -04:00
20 changed files with 1163 additions and 77 deletions
+1
View File
@@ -36,6 +36,7 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
**Verified working in live testing (2026-06):** A2, B1B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
+5
View File
@@ -800,9 +800,14 @@ Root messages in the main timeline show a **"N replies · time"** chip (server-a
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
### Notifications (Slack-style, P4-1)
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
### Under the Hood
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
- Deep links to thread events redirect into the panel
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
+11 -5
View File
@@ -214,12 +214,18 @@ Features:
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8) — **NOW UNBLOCKED (P3-8 implemented 2026-07)**.
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
**Complexity:** Medium (after thread panel exists).
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
**Manual QA checklist (post-deploy):**
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
2. @mention in any thread → notified regardless of participation
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
4. Set to All → every reply notifies; Mentions-only → only @mentions
5. Second device shows the same per-thread modes (account-data sync)
6. Room-level Mute still silences everything incl. thread overrides
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
---
+1
View File
@@ -19,6 +19,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
### Messaging
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time
@@ -0,0 +1,127 @@
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { ThreadNotificationMode } from '../utils/threadNotifications';
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
import { AsyncStatus } from '../hooks/useAsyncCallback';
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
return Icons.Bell;
};
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
useMemo(
() => [
ThreadNotificationMode.Default,
ThreadNotificationMode.All,
ThreadNotificationMode.MentionsOnly,
ThreadNotificationMode.Mute,
],
[],
);
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
useMemo(
() => ({
[ThreadNotificationMode.Default]: 'Default (participating)',
[ThreadNotificationMode.All]: 'All replies',
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
[ThreadNotificationMode.Mute]: 'Mute',
}),
[],
);
type ThreadNotificationModeSwitcherProps = {
roomId: string;
threadId: string;
value?: ThreadNotificationMode;
children: (
handleOpen: MouseEventHandler<HTMLButtonElement>,
opened: boolean,
changing: boolean,
) => ReactNode;
};
export function ThreadNotificationModeSwitcher({
roomId,
threadId,
value = ThreadNotificationMode.Default,
children,
}: ThreadNotificationModeSwitcherProps) {
const modes = useThreadNotificationModes();
const modeToStr = useThreadNotificationModeStr();
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
const changing = modeState.status === AsyncStatus.Loading;
const [menuCords, setMenuCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleClose = () => {
setMenuCords(undefined);
};
const handleSelect = (mode: ThreadNotificationMode) => {
if (changing) return;
setMode(mode);
handleClose();
};
return (
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{modes.map((mode) => (
<MenuItem
key={mode}
size="300"
variant="Surface"
aria-pressed={mode === value}
radii="300"
disabled={changing}
onClick={() => handleSelect(mode)}
before={
<Icon
size="100"
src={getThreadNotificationModeIcon(mode)}
filled={mode === value}
/>
}
>
<Text size="T300">
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
>
{children(handleOpenMenu, !!menuCords, changing)}
</PopOut>
);
}
+41 -3
View File
@@ -23,12 +23,21 @@ import { useKeyDown } from '../../../hooks/useKeyDown';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { RoomInput } from '../RoomInput';
import {
getThreadNotificationModeIcon,
ThreadNotificationModeSwitcher,
} from '../../../components/ThreadNotificationModeSwitcher';
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadPanelHeaderProps = {
room: Room;
threadId: string;
requestClose: () => void;
};
function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
const mode = useThreadNotificationMode(room.roomId, threadId);
return (
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
@@ -40,7 +49,36 @@ function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
{room.name}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<Box shrink="No" alignItems="Center" gap="100">
<ThreadNotificationModeSwitcher roomId={room.roomId} threadId={threadId} value={mode}>
{(handleOpen, opened) => (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Notifications</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Thread notifications"
aria-pressed={opened}
onClick={handleOpen}
>
<Icon
src={getThreadNotificationModeIcon(mode)}
filled={mode !== ThreadNotificationMode.Default}
/>
</IconButton>
)}
</TooltipProvider>
)}
</ThreadNotificationModeSwitcher>
<TooltipProvider
position="Bottom"
align="End"
@@ -137,7 +175,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
shrink="No"
direction="Column"
>
<ThreadPanelHeader room={room} requestClose={requestClose} />
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
{!thread ? (
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Spinner size="400" variant="Secondary" />
@@ -5,6 +5,7 @@ import { useThreadSummary } from '../../../hooks/useThreadSummary';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
type ThreadSummaryProps = {
rootEvent: MatrixEvent;
@@ -12,7 +13,7 @@ type ThreadSummaryProps = {
onOpen: (threadId: string) => void;
};
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
const { summary, unread } = useThreadSummary(rootEvent, room);
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
if (!summary || summary.count === 0) return null;
@@ -43,6 +44,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
{count === 1 ? '1 reply' : `${count} replies`}
{latestStr ? ` · ${latestStr}` : ''}
</Text>
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
</Chip>
</Box>
);
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import {
ClientEvent,
MatrixClient,
Room,
RoomEmittedEvents,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
/**
* Attach `handler` for `event` on every joined/known room, including rooms
* created after mount (via `ClientEvent.Room`). All listeners are detached on
* unmount or when `mx`/`event` change.
*
* The handler is stored in a ref (mirroring `useTauriEvent`) so callers don't
* need to memoize it — changing the handler identity never re-attaches the
* per-room listeners.
*
* The emitting {@link Room} is PREPENDED as the first argument, before the
* event's own args: several room-level SDK events (e.g.
* `RoomEvent.UnreadNotifications`) don't include the room in their payload,
* which callers need for per-room updates. Prepending (not appending) is
* load-bearing — some SDK events emit with VARIABLE arity
* (UnreadNotifications fires with 0, 1, or 2 args), so a trailing extra arg
* would land in a different positional slot per emit.
*/
export function useRoomsListener<E extends RoomEmittedEvents>(
mx: MatrixClient,
event: E,
handler: (room: Room, ...args: Parameters<RoomEventHandlerMap[E]>) => void,
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
// Track attached rooms (and their per-room trampolines) so re-emitted
// `ClientEvent.Room` (e.g. on membership changes) never double-subscribes,
// and cleanup can detach exactly what was attached.
const attached = new Map<string, (...args: unknown[]) => void>();
const attach = (room: Room) => {
if (attached.has(room.roomId)) return;
// Per-room trampoline: forwards to the current ref value with the
// emitting room PREPENDED (stable slot regardless of emit arity).
const roomHandler = (...args: unknown[]) =>
(handlerRef.current as (...a: unknown[]) => void)(room, ...args);
attached.set(room.roomId, roomHandler);
// `event`/`roomHandler` are correlated through E but TS can't prove it
// for the open generic, so we assert at the boundary.
room.on(event, roomHandler as any);
};
mx.getRooms().forEach(attach);
const handleRoom = (room: Room) => attach(room);
mx.on(ClientEvent.Room, handleRoom);
return () => {
mx.removeListener(ClientEvent.Room, handleRoom);
attached.forEach((roomHandler, roomId) => {
mx.getRoom(roomId)?.removeListener(event, roomHandler as any);
});
attached.clear();
};
}, [mx, event]);
}
+97
View File
@@ -0,0 +1,97 @@
import { useCallback } from 'react';
import { useAtomValue } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { threadNotificationsAtom } from '../state/threadNotifications';
import {
getThreadNotificationMode,
pruneThreadNotifications,
ThreadNotificationEntry,
ThreadNotificationMode,
ThreadNotificationsContent,
} from '../utils/threadNotifications';
import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
/** Read the current notification mode for a thread from the bound atom. */
export function useThreadNotificationMode(
roomId: string,
threadRootId: string,
): ThreadNotificationMode {
const content = useAtomValue(threadNotificationsAtom);
return getThreadNotificationMode(content, roomId, threadRootId);
}
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
| ThreadNotificationsContent
| undefined) ?? {};
const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
const joined = new Set<string>();
mx.getRooms().forEach((room) => {
if (room.getMyMembership() === 'join') {
joined.add(room.roomId);
}
});
return joined;
};
const writeThreadNotificationMode = async (
mx: MatrixClient,
roomId: string,
threadRootId: string,
mode: ThreadNotificationMode,
): Promise<void> => {
const current = readContent(mx);
const now = Date.now();
// Work on a mutable clone; prune produces a fresh object so the mutations
// below never touch the atom's/account-data's current content.
const next: ThreadNotificationsContent = {
...current,
rooms: Object.fromEntries(
Object.entries(current.rooms ?? {}).map(([rid, entries]) => [rid, { ...entries }]),
),
};
const rooms = next.rooms as Record<string, Record<string, ThreadNotificationEntry>>;
if (mode === ThreadNotificationMode.Default) {
if (rooms[roomId]) {
delete rooms[roomId][threadRootId];
if (Object.keys(rooms[roomId]).length === 0) {
delete rooms[roomId];
}
}
} else {
if (!rooms[roomId]) {
rooms[roomId] = {};
}
rooms[roomId][threadRootId] = { mode, ts: now };
}
// ALWAYS prune before persisting to keep account data bounded.
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
await (mx as any).setAccountData(AccountDataEvent.LotusThreadNotifications, finalContent);
};
export function useSetThreadNotificationMode(
roomId: string,
threadRootId: string,
): {
modeState: AsyncState<void, Error>;
setMode: (mode: ThreadNotificationMode) => Promise<void>;
} {
const mx = useMatrixClient();
const [modeState, setMode] = useAsyncCallback<void, Error, [ThreadNotificationMode]>(
useCallback(
(mode: ThreadNotificationMode) => writeThreadNotificationMode(mx, roomId, threadRootId, mode),
[mx, roomId, threadRootId],
),
);
return { modeState, setMode };
}
+12 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useAtomValue } from 'jotai';
import {
MatrixEvent,
NotificationCountType,
@@ -8,6 +9,8 @@ import {
ThreadEvent,
} from 'matrix-js-sdk';
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
import { threadNotificationsAtom } from '../state/threadNotifications';
import { getThreadNotificationMode, ThreadNotificationMode } from '../utils/threadNotifications';
/**
* Reactive thread summary + unread count for a root event's "N replies" chip.
@@ -18,9 +21,14 @@ import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/thr
export const useThreadSummary = (
rootEvent: MatrixEvent,
room: Room,
): { summary: ThreadSummaryData | undefined; unread: number } => {
): { summary: ThreadSummaryData | undefined; unread: number; mode: ThreadNotificationMode } => {
const threadId = rootEvent.getId();
const threadNotifications = useAtomValue(threadNotificationsAtom);
const mode = threadId
? getThreadNotificationMode(threadNotifications, room.roomId, threadId)
: ThreadNotificationMode.Default;
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
getThreadSummary(rootEvent),
);
@@ -53,5 +61,7 @@ export const useThreadSummary = (
};
}, [rootEvent, room, threadId]);
return { summary, unread };
const muted = mode === ThreadNotificationMode.Mute;
return { summary, unread: muted ? 0 : unread, mode };
};
+141 -51
View File
@@ -1,7 +1,14 @@
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';
import {
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
import { focusAssistActiveAtom } from '../../state/focusAssist';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/lotus.png';
@@ -35,6 +42,14 @@ import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../../state/threadNotifications';
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import {
getThreadNotificationMode,
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
@@ -212,6 +227,8 @@ function PresenceUpdater() {
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
// Per-thread dedupe: threadId -> last notified eventId.
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
@@ -242,6 +259,8 @@ function MessageNotifications() {
const navigate = useNavigate();
const notificationSelected = useInboxNotificationsSelected();
const selectedRoomId = useSelectedRoom();
const threadPrefs = useAtomValue(threadNotificationsAtom);
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? ''));
const notify = useCallback(
({
@@ -252,6 +271,7 @@ function MessageNotifications() {
eventId,
body,
encrypted,
threadId,
}: {
roomName: string;
roomAvatar?: string;
@@ -260,6 +280,7 @@ function MessageNotifications() {
eventId: string;
body?: string;
encrypted?: boolean;
threadId?: string;
}) => {
const roomPath = mDirects.has(roomId)
? getDirectRoomPath(roomId, eventId)
@@ -294,7 +315,9 @@ function MessageNotifications() {
silent: true,
// Coalesce repeated notifications for the same room (replaces the old
// manual notifRef.close() dedup, which a SW notification can't hold).
tag: roomId,
// For thread replies widen the tag to room:thread so each thread
// coalesces independently instead of clobbering the room's bucket.
tag: threadId ? `${roomId}:${threadId}` : roomId,
data: { path: roomPath },
},
() => {
@@ -326,6 +349,69 @@ function MessageNotifications() {
audioElement?.play();
}, []);
// Shared delivery tail for both the main timeline and per-thread paths:
// room-level unread dedup → avatar resolution → OS/toast notify → sound, all
// behind the quiet-hours / focus-assist gate. `threadId` (when set) widens the
// OS coalescing tag so each thread notifies independently; the click path
// stays the room path (RoomTimeline deep-links thread events into the panel).
const deliverNotification = useCallback(
(room: Room, mEvent: MatrixEvent, threadId?: string) => {
const sender = mEvent.getSender();
const eventId = mEvent.getId();
if (!sender || !eventId) return;
const unreadInfo = getUnreadInfo(room);
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
unreadCacheRef.current.set(room.roomId, unreadInfo);
if (unreadInfo.total === 0) return;
if (
cachedUnreadInfo &&
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
) {
return;
}
const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
encrypted: room.hasEncryptionStateEvent(),
threadId,
});
}
if (notificationSound && messageSoundId !== 'none') {
playSound();
}
},
[
mx,
notify,
playSound,
showNotifications,
notificationSound,
useAuthentication,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
focusAssistActive,
messageSoundId,
],
);
useEffect(() => {
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
mEvent,
@@ -349,61 +435,65 @@ function MessageNotifications() {
const sender = mEvent.getSender();
const eventId = mEvent.getId();
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
const unreadInfo = getUnreadInfo(room);
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
unreadCacheRef.current.set(room.roomId, unreadInfo);
// Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply
// handler below (per-thread gating), so ignore them here — a reply notifies once.
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
if (unreadInfo.total === 0) return;
if (
cachedUnreadInfo &&
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
) {
return;
}
const quietActive =
focusAssistActive || (quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
encrypted: room.hasEncryptionStateEvent(),
});
}
if (notificationSound && messageSoundId !== 'none') {
playSound();
}
}
deliverNotification(room, mEvent);
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [
mx,
notificationSound,
notificationSelected,
showNotifications,
playSound,
notify,
selectedRoomId,
useAuthentication,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
focusAssistActive,
messageSoundId,
]);
}, [mx, notificationSelected, selectedRoomId, deliverNotification]);
const handleNewReply = useCallback(
// useRoomsListener prepends the emitting Room; the thread's own room lookup
// below is kept as the authority (identical object in practice).
(_room: Room, thread: Thread, mEvent: MatrixEvent) => {
if (mx.getSyncState() !== 'SYNCING') return;
const room = mx.getRoom(thread.roomId);
if (!room || room.isSpaceRoom()) return;
if (!isNotificationEvent(mEvent) || mEvent.isSending()) return;
const sender = mEvent.getSender();
if (!sender || sender === mx.getUserId()) return;
// Suppress when the user is actively looking at this thread (or the inbox).
if (
document.hasFocus() &&
(notificationSelected || (selectedRoomId === thread.roomId && activeThreadId === thread.id))
) {
return;
}
// Per-thread dedupe: a NewReply can re-fire for the same event as the
// thread (re)populates; notify at most once per (thread, event).
const eventId = mEvent.getId();
if (eventId) {
if (lastNotifiedThreadRef.current.get(thread.id) === eventId) return;
lastNotifiedThreadRef.current.set(thread.id, eventId);
}
const content = threadPrefs;
const mode = getThreadNotificationMode(content, room.roomId, thread.id);
const actions = mx.getPushActionsForEvent(mEvent);
const decision = shouldNotifyThreadReply({
mode,
defaultBehavior: content.default ?? THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
participated: thread.hasCurrentUserParticipated,
highlight: !!actions?.tweaks?.highlight,
notify: !!actions?.notify,
roomMuted: getNotificationType(mx, room.roomId) === NotificationType.Mute,
});
if (decision === 'none') return;
// E2EE caveat: NewReply can fire before decryption, so MentionsOnly may
// under-notify in encrypted rooms (same class as the main timeline path).
// Plaintext body suppression for encrypted rooms is handled inside notify().
deliverNotification(room, mEvent, thread.id);
},
[mx, notificationSelected, selectedRoomId, activeThreadId, threadPrefs, deliverNotification],
);
useRoomsListener(mx, ThreadEvent.NewReply, handleNewReply);
return (
<audio ref={audioRef} style={{ display: 'none' }}>
+2
View File
@@ -5,12 +5,14 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
useBindAllInvitesAtom(mx, allInvitesAtom);
useBindAllRoomsAtom(mx, allRoomsAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
+64 -7
View File
@@ -1,5 +1,5 @@
import { produce } from 'immer';
import { atom, useSetAtom } from 'jotai';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import {
IRoomTimelineData,
MatrixClient,
@@ -9,7 +9,7 @@ import {
SyncState,
} from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import {
Membership,
NotificationType,
@@ -29,6 +29,9 @@ import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../threadNotifications';
import { getMutedThreads } from '../../utils/threadNotifications';
export type RoomToUnreadAction =
| {
@@ -169,11 +172,17 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
const setUnreadAtom = useSetAtom(unreadAtom);
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
const threadNotifications = useAtomValue(threadNotificationsAtom);
// Latest thread-notification prefs for the SDK-event handlers below, read via a
// ref so changing prefs never re-attaches the (many) per-room listeners. The
// dedicated reset effect keyed on `threadNotifications` handles mute/unmute.
const threadNotificationsRef = useRef(threadNotifications);
threadNotificationsRef.current = threadNotifications;
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}, [mx, setUnreadAtom]);
@@ -187,7 +196,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}
},
@@ -204,6 +213,10 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
data: IRoomTimelineData,
) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
// Single-owner rule: thread replies drive the room badge via
// RoomEvent.UnreadNotifications below — ignore them here so the count is
// never double-driven / mis-attributed to the main timeline.
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({
type: 'DELETE',
@@ -213,7 +226,13 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
}
if (mEvent.getSender() === mx.getUserId()) return;
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
setUnreadAtom({
type: 'PUT',
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
),
});
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
@@ -246,10 +265,48 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
// Mute/unmute of a thread rewrites `threadNotificationsAtom`; recompute badges
// immediately so muted-thread subtraction takes effect without waiting for the
// next timeline / unread event (mirrors the notification-preferences reset).
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, threadNotifications),
});
}, [mx, setUnreadAtom, threadNotifications]);
// RoomEvent.UnreadNotifications is emitted room-level only (never re-emitted
// client-side), so the main Timeline pathway misses thread-count changes and
// room badges lag. useRoomsListener PREPENDS the emitting Room (the SDK emits
// this event with variable arity — 0/1/2 args — so only a leading slot is
// positionally stable), making this a surgical per-room PUT with muted-thread
// subtraction re-applied. Room-mute keeps its DELETE semantics.
useRoomsListener(
mx,
RoomEvent.UnreadNotifications,
useCallback(
(room: Room) => {
if (room.isSpaceRoom()) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
return;
}
setUnreadAtom({
type: 'PUT',
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
),
});
},
[mx, setUnreadAtom],
),
);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== Membership.Join) {
@@ -272,7 +329,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
if (mEvent.getType() === StateEvent.SpaceChild) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}
},
+36
View File
@@ -0,0 +1,36 @@
import { atom, useSetAtom } from 'jotai';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { ThreadNotificationsContent } from '../utils/threadNotifications';
// Holds the parsed `io.lotus.thread_notifications` account data. Seeded and
// kept in sync by `useBindThreadNotificationsAtom`.
export const threadNotificationsAtom = atom<ThreadNotificationsContent>({});
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
| ThreadNotificationsContent
| undefined) ?? {};
export const useBindThreadNotificationsAtom = (
mx: MatrixClient,
threadNotifications: typeof threadNotificationsAtom,
) => {
const setContent = useSetAtom(threadNotifications);
useEffect(() => {
setContent(readContent(mx));
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() === AccountDataEvent.LotusThreadNotifications) {
setContent(event.getContent<ThreadNotificationsContent>() ?? {});
}
};
mx.on(ClientEvent.AccountData, handleAccountData);
return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
};
}, [mx, setContent]);
};
+57
View File
@@ -388,6 +388,63 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
const mockRoomWithThreadCounts = (
total: number,
highlight: number,
threadCounts: Record<string, { total: number; highlight: number }>,
): Room =>
({
roomId: '!r:x',
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? total : highlight,
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
type === NotificationCountType.Total
? (threadCounts[threadId]?.total ?? 0)
: (threadCounts[threadId]?.highlight ?? 0),
}) as unknown as Room;
test('getUnreadInfo subtracts muted thread counts from room totals', () => {
const room = mockRoomWithThreadCounts(5, 2, { $t1: { total: 3, highlight: 1 } });
assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
roomId: '!r:x',
highlight: 1,
total: 2,
});
});
test('getUnreadInfo subtracts multiple muted threads', () => {
const room = mockRoomWithThreadCounts(9, 3, {
$t1: { total: 3, highlight: 1 },
$t2: { total: 2, highlight: 1 },
});
assert.deepEqual(getUnreadInfo(room, new Set(['$t1', '$t2'])), {
roomId: '!r:x',
highlight: 1,
total: 4,
});
});
test('getUnreadInfo clamps subtracted counts at zero', () => {
const room = mockRoomWithThreadCounts(2, 1, { $t1: { total: 5, highlight: 4 } });
assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
roomId: '!r:x',
highlight: 0,
total: 0,
});
});
test('getUnreadInfo leaves counts untouched without muted threads', () => {
const room = mockRoomWithThreadCounts(4, 1, { $t1: { total: 3, highlight: 1 } });
// undefined muted set (backward compat)
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 1, total: 4 });
// empty muted set is a no-op too
assert.deepEqual(getUnreadInfo(room, new Set<string>()), {
roomId: '!r:x',
highlight: 1,
total: 4,
});
});
// --- getRoomIconSrc -------------------------------------------------------
test('getRoomIconSrc selects icon by room type and join rule', () => {
+24 -5
View File
@@ -29,6 +29,7 @@ import {
StateEvent,
UnreadInfo,
} from '../../types/matrix/room';
import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications';
export const getStateEvent = (
room: Room,
@@ -233,9 +234,23 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
export const getUnreadInfo = (room: Room): UnreadInfo => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
// Server room totals INCLUDE per-thread notification counts, so subtract any
// explicitly muted thread's counts back out (clamped at zero) to keep muted
// threads from contributing to the room badge (P4-1).
if (mutedThreads && mutedThreads.size > 0) {
mutedThreads.forEach((threadId) => {
total -= room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0;
highlight -=
room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) ?? 0;
});
if (total < 0) total = 0;
if (highlight < 0) highlight = 0;
}
return {
roomId: room.roomId,
highlight,
@@ -243,14 +258,18 @@ export const getUnreadInfo = (room: Room): UnreadInfo => {
};
};
export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
export const getUnreadInfos = (
mx: MatrixClient,
content?: ThreadNotificationsContent,
): UnreadInfo[] => {
const unreadInfos = mx.getRooms().reduce<UnreadInfo[]>((unread, room) => {
if (room.isSpaceRoom()) return unread;
if (room.getMyMembership() !== 'join') return unread;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
unread.push(getUnreadInfo(room));
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
unread.push(getUnreadInfo(room, mutedThreads));
}
return unread;
+260
View File
@@ -0,0 +1,260 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
getMutedThreads,
getThreadNotificationMode,
pruneThreadNotifications,
shouldNotifyThreadReply,
ThreadDefaultBehavior,
ThreadNotificationMode,
ThreadNotificationsContent,
ThreadNotifyDecision,
} from './threadNotifications';
const DAY = 24 * 60 * 60 * 1000;
const decide = (
overrides: Partial<Parameters<typeof shouldNotifyThreadReply>[0]>,
): ThreadNotifyDecision =>
shouldNotifyThreadReply({
mode: ThreadNotificationMode.Default,
defaultBehavior: 'participating',
participated: false,
highlight: false,
notify: false,
roomMuted: false,
...overrides,
});
describe('shouldNotifyThreadReply', () => {
it('roomMuted trumps everything, even mode All + highlight', () => {
assert.equal(
decide({ roomMuted: true, mode: ThreadNotificationMode.All, highlight: true }),
'none',
);
});
it('roomMuted trumps Default + participating + participated', () => {
assert.equal(decide({ roomMuted: true, participated: true }), 'none');
});
it('mode Mute is none regardless of highlight/participation', () => {
assert.equal(
decide({ mode: ThreadNotificationMode.Mute, highlight: true, participated: true }),
'none',
);
});
it('mode All + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: true }), 'loud');
});
it('mode All + no highlight => notify', () => {
assert.equal(decide({ mode: ThreadNotificationMode.All, highlight: false }), 'notify');
});
it('mode MentionsOnly + highlight => loud', () => {
assert.equal(decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: true }), 'loud');
});
it('mode MentionsOnly + no highlight => none (even if participated)', () => {
assert.equal(
decide({ mode: ThreadNotificationMode.MentionsOnly, highlight: false, participated: true }),
'none',
);
});
it('Default + behavior all + highlight => loud', () => {
assert.equal(decide({ defaultBehavior: 'all', highlight: true }), 'loud');
});
it('Default + behavior all + no highlight => notify', () => {
assert.equal(decide({ defaultBehavior: 'all', highlight: false }), 'notify');
});
it('Default + participating + highlight => loud (even if not participated)', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: true, participated: false }),
'loud',
);
});
it('Default + participating + no highlight + participated => notify', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: false, participated: true }),
'notify',
);
});
it('Default + participating + no highlight + not participated => none', () => {
assert.equal(
decide({ defaultBehavior: 'participating', highlight: false, participated: false }),
'none',
);
});
it('ignores the `notify` input entirely in v1', () => {
// notify=true must not upgrade a "none" decision.
assert.equal(
decide({ defaultBehavior: 'participating', participated: false, notify: true }),
'none',
);
// notify=false must not downgrade an "all" mode notify.
assert.equal(decide({ mode: ThreadNotificationMode.All, notify: false }), 'notify');
});
});
describe('getThreadNotificationMode', () => {
it('returns Default for undefined content', () => {
assert.equal(getThreadNotificationMode(undefined, '!r', '$t'), ThreadNotificationMode.Default);
});
it('returns Default when room or thread is absent', () => {
const content: ThreadNotificationsContent = {
rooms: { '!r': { $other: { mode: ThreadNotificationMode.All, ts: 1 } } },
};
assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(content, '!x', '$t'), ThreadNotificationMode.Default);
});
it('returns the stored mode', () => {
const content: ThreadNotificationsContent = {
rooms: { '!r': { $t: { mode: ThreadNotificationMode.Mute, ts: 1 } } },
};
assert.equal(getThreadNotificationMode(content, '!r', '$t'), ThreadNotificationMode.Mute);
});
it('is safe against malformed entries', () => {
const bad = {
rooms: {
'!r': {
$badMode: { mode: 'nonsense', ts: 1 },
$noTs: { mode: ThreadNotificationMode.All },
$notObj: 'oops',
$nullEntry: null,
},
},
} as unknown as ThreadNotificationsContent;
assert.equal(getThreadNotificationMode(bad, '!r', '$badMode'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(bad, '!r', '$noTs'), ThreadNotificationMode.Default);
assert.equal(getThreadNotificationMode(bad, '!r', '$notObj'), ThreadNotificationMode.Default);
assert.equal(
getThreadNotificationMode(bad, '!r', '$nullEntry'),
ThreadNotificationMode.Default,
);
});
it('is safe when rooms is not an object', () => {
const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent;
assert.equal(getThreadNotificationMode(bad, '!r', '$t'), ThreadNotificationMode.Default);
});
});
describe('getMutedThreads', () => {
it('returns empty set for undefined/absent room', () => {
assert.deepEqual(getMutedThreads(undefined, '!r'), new Set());
assert.deepEqual(getMutedThreads({ rooms: {} }, '!r'), new Set());
});
it('collects only Mute entries', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': {
$a: { mode: ThreadNotificationMode.Mute, ts: 1 },
$b: { mode: ThreadNotificationMode.All, ts: 1 },
$c: { mode: ThreadNotificationMode.Mute, ts: 1 },
},
},
};
assert.deepEqual(getMutedThreads(content, '!r'), new Set(['$a', '$c']));
});
it('ignores malformed entries', () => {
const bad = {
rooms: { '!r': { $a: { mode: 'mute-ish', ts: 1 }, $b: null } },
} as unknown as ThreadNotificationsContent;
assert.deepEqual(getMutedThreads(bad, '!r'), new Set());
});
});
describe('pruneThreadNotifications', () => {
const now = 1_000_000_000_000;
it('drops rooms not in joinedRoomIds', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!keep': { $t: { mode: ThreadNotificationMode.All, ts: now } },
'!left': { $t: { mode: ThreadNotificationMode.All, ts: now } },
},
};
const out = pruneThreadNotifications(content, new Set(['!keep']), now);
assert.deepEqual(Object.keys(out.rooms ?? {}), ['!keep']);
});
it('drops entries older than 180 days', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': {
$fresh: { mode: ThreadNotificationMode.All, ts: now - 179 * DAY },
$old: { mode: ThreadNotificationMode.All, ts: now - 181 * DAY },
},
},
};
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.deepEqual(Object.keys(out.rooms?.['!r'] ?? {}), ['$fresh']);
});
it('caps a room at 200 entries, evicting oldest ts first', () => {
const entries: Record<string, { mode: ThreadNotificationMode.All; ts: number }> = {};
// 205 entries, ts ascending with the id index.
for (let i = 0; i < 205; i += 1) {
entries[`$t${i}`] = { mode: ThreadNotificationMode.All, ts: now - (205 - i) * 1000 };
}
const content: ThreadNotificationsContent = { rooms: { '!r': entries } };
const out = pruneThreadNotifications(content, new Set(['!r']), now);
const kept = out.rooms?.['!r'] ?? {};
assert.equal(Object.keys(kept).length, 200);
// Oldest 5 ($t0..$t4) evicted; newest retained.
assert.equal(kept.$t0, undefined);
assert.equal(kept.$t4, undefined);
assert.notEqual(kept.$t5, undefined);
assert.notEqual(kept.$t204, undefined);
});
it('drops rooms left with no entries', () => {
const content: ThreadNotificationsContent = {
rooms: {
'!r': { $old: { mode: ThreadNotificationMode.All, ts: now - 200 * DAY } },
},
};
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.equal(out.rooms, undefined);
});
it('preserves the default behavior field', () => {
const behavior: ThreadDefaultBehavior = 'all';
const content: ThreadNotificationsContent = { default: behavior, rooms: {} };
const out = pruneThreadNotifications(content, new Set(), now);
assert.equal(out.default, 'all');
});
it('never mutates the input', () => {
const content: ThreadNotificationsContent = {
default: 'participating',
rooms: {
'!r': { $t: { mode: ThreadNotificationMode.All, ts: now } },
'!left': { $t: { mode: ThreadNotificationMode.All, ts: now } },
},
};
const snapshot = JSON.parse(JSON.stringify(content));
const out = pruneThreadNotifications(content, new Set(['!r']), now);
assert.deepEqual(content, snapshot);
// Output room objects are fresh, not shared references with the input.
assert.notEqual(out.rooms?.['!r'], content.rooms?.['!r']);
});
it('handles malformed rooms container safely', () => {
const bad = { rooms: 'oops' } as unknown as ThreadNotificationsContent;
assert.deepEqual(pruneThreadNotifications(bad, new Set(['!r']), now), {});
});
});
+196
View File
@@ -0,0 +1,196 @@
// Per-thread notification modes (P4-1). Stored in the
// `io.lotus.thread_notifications` account data event. The functions in this
// module are PURE — they never touch React or matrix-js-sdk objects so they
// can be unit-tested in isolation and reused by the pipeline/UI agents.
export enum ThreadNotificationMode {
Default = 'default',
All = 'all',
MentionsOnly = 'mentions',
Mute = 'mute',
}
export type ThreadDefaultBehavior = 'all' | 'participating';
export type ThreadNotifyDecision = 'loud' | 'notify' | 'none';
export type ThreadNotificationEntry = {
mode: Exclude<ThreadNotificationMode, ThreadNotificationMode.Default>;
ts: number;
};
export type ThreadNotificationsContent = {
default?: ThreadDefaultBehavior;
rooms?: Record<string, Record<string, ThreadNotificationEntry>>;
};
// DEFAULT behavior when the user has not chosen a global default. Fixed to
// 'participating': notify only if the current user participated in the thread
// or the reply mentions them.
export const THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR: ThreadDefaultBehavior = 'participating';
// Entries older than this are pruned on write to keep account data bounded.
const PRUNE_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000;
// Maximum stored entries per room; oldest are evicted first.
const PRUNE_MAX_ENTRIES_PER_ROOM = 200;
const STORED_MODES: ReadonlySet<string> = new Set([
ThreadNotificationMode.All,
ThreadNotificationMode.MentionsOnly,
ThreadNotificationMode.Mute,
]);
const isStoredMode = (
value: unknown,
): value is Exclude<ThreadNotificationMode, ThreadNotificationMode.Default> =>
typeof value === 'string' && STORED_MODES.has(value);
const readEntry = (
content: ThreadNotificationsContent | undefined,
roomId: string,
threadRootId: string,
): ThreadNotificationEntry | undefined => {
const entry = content?.rooms?.[roomId]?.[threadRootId];
if (!entry || typeof entry !== 'object') return undefined;
if (!isStoredMode(entry.mode) || typeof entry.ts !== 'number') return undefined;
return entry;
};
/**
* Resolve the stored notification mode for a thread. Absent or malformed
* content resolves to `ThreadNotificationMode.Default`.
*/
export function getThreadNotificationMode(
content: ThreadNotificationsContent | undefined,
roomId: string,
threadRootId: string,
): ThreadNotificationMode {
const entry = readEntry(content, roomId, threadRootId);
return entry ? entry.mode : ThreadNotificationMode.Default;
}
/**
* All thread root ids explicitly muted within a room. Malformed content yields
* an empty set.
*/
export function getMutedThreads(
content: ThreadNotificationsContent | undefined,
roomId: string,
): Set<string> {
const muted = new Set<string>();
const roomEntries = content?.rooms?.[roomId];
if (!roomEntries || typeof roomEntries !== 'object') return muted;
Object.keys(roomEntries).forEach((threadRootId) => {
const entry = roomEntries[threadRootId];
if (entry && isStoredMode(entry.mode) && entry.mode === ThreadNotificationMode.Mute) {
muted.add(threadRootId);
}
});
return muted;
}
/**
* Decide whether a thread reply should notify.
*
* NOTE: the `notify` input reflects the base matrix push rule outcome and is
* accepted for forward-compatibility, but is intentionally IGNORED in v1: the
* per-thread mode fully determines the decision, so honoring `notify` would let
* server push rules silently override an explicit "All" thread override. Kept
* in the signature so the pipeline can start plumbing it without a later break.
*/
export function shouldNotifyThreadReply(input: {
mode: ThreadNotificationMode;
defaultBehavior: ThreadDefaultBehavior;
participated: boolean;
highlight: boolean;
notify: boolean;
roomMuted: boolean;
}): ThreadNotifyDecision {
const { mode, defaultBehavior, participated, highlight, roomMuted } = input;
if (roomMuted) return 'none';
if (mode === ThreadNotificationMode.Mute) return 'none';
if (mode === ThreadNotificationMode.All) {
return highlight ? 'loud' : 'notify';
}
if (mode === ThreadNotificationMode.MentionsOnly) {
return highlight ? 'loud' : 'none';
}
// ThreadNotificationMode.Default
if (defaultBehavior === 'all') {
return highlight ? 'loud' : 'notify';
}
// defaultBehavior === 'participating'
if (highlight) return 'loud';
return participated ? 'notify' : 'none';
}
/**
* Return a NEW content object with stale/oversized data removed. Never mutates
* the input.
*
* (1) drop rooms not in `joinedRoomIds`
* (2) drop entries older than 180 days (`ts < now - PRUNE_MAX_AGE_MS`)
* (3) cap each room at 200 entries, evicting the oldest `ts` first
* (4) drop rooms left with no entries
*/
export function pruneThreadNotifications(
content: ThreadNotificationsContent,
joinedRoomIds: Set<string>,
now: number,
): ThreadNotificationsContent {
const minTs = now - PRUNE_MAX_AGE_MS;
const pruned: ThreadNotificationsContent = {};
if (content.default !== undefined) {
pruned.default = content.default;
}
const rooms = content.rooms;
if (!rooms || typeof rooms !== 'object') {
return pruned;
}
const prunedRooms: Record<string, Record<string, ThreadNotificationEntry>> = {};
Object.keys(rooms).forEach((roomId) => {
if (!joinedRoomIds.has(roomId)) return;
const roomEntries = rooms[roomId];
if (!roomEntries || typeof roomEntries !== 'object') return;
// Keep only well-formed, non-expired entries.
const kept: Array<[string, ThreadNotificationEntry]> = [];
Object.keys(roomEntries).forEach((threadRootId) => {
const entry = roomEntries[threadRootId];
if (!entry || !isStoredMode(entry.mode) || typeof entry.ts !== 'number') return;
if (entry.ts < minTs) return;
kept.push([threadRootId, { mode: entry.mode, ts: entry.ts }]);
});
if (kept.length === 0) return;
// Cap per room, evicting oldest first (ascending ts sort, keep the tail).
let capped = kept;
if (kept.length > PRUNE_MAX_ENTRIES_PER_ROOM) {
capped = [...kept]
.sort((a, b) => a[1].ts - b[1].ts)
.slice(kept.length - PRUNE_MAX_ENTRIES_PER_ROOM);
}
const nextRoom: Record<string, ThreadNotificationEntry> = {};
capped.forEach(([threadRootId, entry]) => {
nextRoom[threadRootId] = entry;
});
prunedRooms[roomId] = nextRoom;
});
if (Object.keys(prunedRooms).length > 0) {
pruned.rooms = prunedRooms;
}
return pruned;
}
+4
View File
@@ -14,6 +14,10 @@ export enum AccountDataEvent {
// devices like custom emoji/sticker packs).
LotusSoundboard = 'io.lotus.soundboard',
// [P4-1] Per-thread notification mode overrides (All/Mentions/Mute) plus the
// global default behavior for threads.
LotusThreadNotifications = 'io.lotus.thread_notifications',
SecretStorageDefaultKey = 'm.secret_storage.default_key',
CrossSigningMaster = 'm.cross_signing.master',
+15 -3
View File
@@ -59,9 +59,21 @@ function copyPdfWorker() {
return {
name: 'copy-pdf-worker',
closeBundle() {
const src = path.resolve('node_modules/pdfjs-dist/build/pdf.worker.min.mjs');
const dest = path.resolve('dist/pdf.worker.min.js');
if (fs.existsSync(src)) fs.copyFileSync(src, dest);
// Never throw from here: closeBundle also runs when the build FAILED
// mid-render (dist/ absent) and an exception here MASKS the real build
// error in vite's report (seen on the Windows CI runner). Warn and skip.
try {
const src = path.resolve('node_modules/pdfjs-dist/build/pdf.worker.min.mjs');
const dest = path.resolve('dist/pdf.worker.min.js');
if (!fs.existsSync(src)) {
console.warn('[copy-pdf-worker] source worker missing, skipped:', src);
return;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
} catch (err) {
console.warn('[copy-pdf-worker] skipped:', err?.message ?? err);
}
},
};
}