Compare commits
4 Commits
ffb934fce6
...
579449acc3
| Author | SHA1 | Date | |
|---|---|---|---|
| 579449acc3 | |||
| 34592d9144 | |||
| 0adce52d37 | |||
| 501d493ca4 |
@@ -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, B1–B4, 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.
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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), {});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user