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>
This commit is contained in:
@@ -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 { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { RoomInput } from '../RoomInput';
|
import { RoomInput } from '../RoomInput';
|
||||||
|
import {
|
||||||
|
getThreadNotificationModeIcon,
|
||||||
|
ThreadNotificationModeSwitcher,
|
||||||
|
} from '../../../components/ThreadNotificationModeSwitcher';
|
||||||
|
import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications';
|
||||||
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
|
||||||
type ThreadPanelHeaderProps = {
|
type ThreadPanelHeaderProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
threadId: string;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
|
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||||
|
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
<Header className={css.ThreadPanelHeader} variant="Background" size="600">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
@@ -40,7 +49,36 @@ function ThreadPanelHeader({ room, requestClose }: ThreadPanelHeaderProps) {
|
|||||||
{room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
@@ -137,7 +175,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
|||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
>
|
>
|
||||||
<ThreadPanelHeader room={room} requestClose={requestClose} />
|
<ThreadPanelHeader room={room} threadId={threadId} requestClose={requestClose} />
|
||||||
{!thread ? (
|
{!thread ? (
|
||||||
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
<Spinner size="400" variant="Secondary" />
|
<Spinner size="400" variant="Secondary" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
|||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
||||||
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
|
|
||||||
type ThreadSummaryProps = {
|
type ThreadSummaryProps = {
|
||||||
rootEvent: MatrixEvent;
|
rootEvent: MatrixEvent;
|
||||||
@@ -12,7 +13,7 @@ type ThreadSummaryProps = {
|
|||||||
onOpen: (threadId: string) => void;
|
onOpen: (threadId: string) => void;
|
||||||
};
|
};
|
||||||
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
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');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
if (!summary || summary.count === 0) return null;
|
if (!summary || summary.count === 0) return null;
|
||||||
@@ -43,6 +44,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
|||||||
{count === 1 ? '1 reply' : `${count} replies`}
|
{count === 1 ? '1 reply' : `${count} replies`}
|
||||||
{latestStr ? ` · ${latestStr}` : ''}
|
{latestStr ? ` · ${latestStr}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
{mode === ThreadNotificationMode.Mute && <Icon size="50" src={Icons.BellMute} />}
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 appended as a FINAL extra argument after 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. Handlers that don't care can simply
|
||||||
|
* ignore it.
|
||||||
|
*/
|
||||||
|
export function useRoomsListener<E extends RoomEmittedEvents>(
|
||||||
|
mx: MatrixClient,
|
||||||
|
event: E,
|
||||||
|
handler: (...args: [...Parameters<RoomEventHandlerMap[E]>, Room]) => 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 appended.
|
||||||
|
const roomHandler = (...args: unknown[]) =>
|
||||||
|
(handlerRef.current as (...a: unknown[]) => void)(...args, room);
|
||||||
|
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 { useEffect, useState } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import {
|
import {
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
NotificationCountType,
|
NotificationCountType,
|
||||||
@@ -8,6 +9,8 @@ import {
|
|||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { getThreadSummary, ThreadSummaryData } from '../features/room/thread/threadSummary';
|
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.
|
* 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 = (
|
export const useThreadSummary = (
|
||||||
rootEvent: MatrixEvent,
|
rootEvent: MatrixEvent,
|
||||||
room: Room,
|
room: Room,
|
||||||
): { summary: ThreadSummaryData | undefined; unread: number } => {
|
): { summary: ThreadSummaryData | undefined; unread: number; mode: ThreadNotificationMode } => {
|
||||||
const threadId = rootEvent.getId();
|
const threadId = rootEvent.getId();
|
||||||
|
|
||||||
|
const threadNotifications = useAtomValue(threadNotificationsAtom);
|
||||||
|
const mode = threadId
|
||||||
|
? getThreadNotificationMode(threadNotifications, room.roomId, threadId)
|
||||||
|
: ThreadNotificationMode.Default;
|
||||||
|
|
||||||
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
|
const [summary, setSummary] = useState<ThreadSummaryData | undefined>(() =>
|
||||||
getThreadSummary(rootEvent),
|
getThreadSummary(rootEvent),
|
||||||
);
|
);
|
||||||
@@ -53,5 +61,7 @@ export const useThreadSummary = (
|
|||||||
};
|
};
|
||||||
}, [rootEvent, room, threadId]);
|
}, [rootEvent, room, threadId]);
|
||||||
|
|
||||||
return { summary, unread };
|
const muted = mode === ThreadNotificationMode.Mute;
|
||||||
|
|
||||||
|
return { summary, unread: muted ? 0 : unread, mode };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import {
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
ThreadEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||||
import LogoSVG from '../../../../public/res/lotus.png';
|
import LogoSVG from '../../../../public/res/lotus.png';
|
||||||
@@ -35,6 +41,14 @@ import { toastQueueAtom } from '../../state/toast';
|
|||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
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 {
|
function isInQuietHours(start: string, end: string): boolean {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -212,6 +226,8 @@ function PresenceUpdater() {
|
|||||||
function MessageNotifications() {
|
function MessageNotifications() {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
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 mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
@@ -242,6 +258,8 @@ function MessageNotifications() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const notificationSelected = useInboxNotificationsSelected();
|
const notificationSelected = useInboxNotificationsSelected();
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
const threadPrefs = useAtomValue(threadNotificationsAtom);
|
||||||
|
const activeThreadId = useAtomValue(roomIdToActiveThreadIdAtomFamily(selectedRoomId ?? ''));
|
||||||
|
|
||||||
const notify = useCallback(
|
const notify = useCallback(
|
||||||
({
|
({
|
||||||
@@ -252,6 +270,7 @@ function MessageNotifications() {
|
|||||||
eventId,
|
eventId,
|
||||||
body,
|
body,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
threadId,
|
||||||
}: {
|
}: {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
roomAvatar?: string;
|
roomAvatar?: string;
|
||||||
@@ -260,6 +279,7 @@ function MessageNotifications() {
|
|||||||
eventId: string;
|
eventId: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
encrypted?: boolean;
|
encrypted?: boolean;
|
||||||
|
threadId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const roomPath = mDirects.has(roomId)
|
const roomPath = mDirects.has(roomId)
|
||||||
? getDirectRoomPath(roomId, eventId)
|
? getDirectRoomPath(roomId, eventId)
|
||||||
@@ -294,7 +314,9 @@ function MessageNotifications() {
|
|||||||
silent: true,
|
silent: true,
|
||||||
// Coalesce repeated notifications for the same room (replaces the old
|
// Coalesce repeated notifications for the same room (replaces the old
|
||||||
// manual notifRef.close() dedup, which a SW notification can't hold).
|
// 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 },
|
data: { path: roomPath },
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
@@ -326,6 +348,69 @@ function MessageNotifications() {
|
|||||||
audioElement?.play();
|
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(() => {
|
useEffect(() => {
|
||||||
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
|
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = (
|
||||||
mEvent,
|
mEvent,
|
||||||
@@ -349,61 +434,64 @@ function MessageNotifications() {
|
|||||||
const sender = mEvent.getSender();
|
const sender = mEvent.getSender();
|
||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
|
if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return;
|
||||||
const unreadInfo = getUnreadInfo(room);
|
// Single-owner rule: thread replies are delivered by the ThreadEvent.NewReply
|
||||||
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
// handler below (per-thread gating), so ignore them here — a reply notifies once.
|
||||||
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
|
||||||
|
|
||||||
if (unreadInfo.total === 0) return;
|
deliverNotification(room, mEvent);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
return () => {
|
return () => {
|
||||||
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
};
|
};
|
||||||
}, [
|
}, [mx, notificationSelected, selectedRoomId, deliverNotification]);
|
||||||
mx,
|
|
||||||
notificationSound,
|
const handleNewReply = useCallback<RoomEventHandlerMap[ThreadEvent.NewReply]>(
|
||||||
notificationSelected,
|
(thread, mEvent) => {
|
||||||
showNotifications,
|
if (mx.getSyncState() !== 'SYNCING') return;
|
||||||
playSound,
|
const room = mx.getRoom(thread.roomId);
|
||||||
notify,
|
if (!room || room.isSpaceRoom()) return;
|
||||||
selectedRoomId,
|
if (!isNotificationEvent(mEvent) || mEvent.isSending()) return;
|
||||||
useAuthentication,
|
const sender = mEvent.getSender();
|
||||||
quietHoursEnabled,
|
if (!sender || sender === mx.getUserId()) return;
|
||||||
quietHoursStart,
|
// Suppress when the user is actively looking at this thread (or the inbox).
|
||||||
quietHoursEnd,
|
if (
|
||||||
focusAssistActive,
|
document.hasFocus() &&
|
||||||
messageSoundId,
|
(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 (
|
return (
|
||||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
|
|||||||
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
|
||||||
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
|
||||||
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
|
||||||
|
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
|
||||||
|
|
||||||
export const useBindAtoms = (mx: MatrixClient) => {
|
export const useBindAtoms = (mx: MatrixClient) => {
|
||||||
useBindMDirectAtom(mx, mDirectAtom);
|
useBindMDirectAtom(mx, mDirectAtom);
|
||||||
useBindAllInvitesAtom(mx, allInvitesAtom);
|
useBindAllInvitesAtom(mx, allInvitesAtom);
|
||||||
useBindAllRoomsAtom(mx, allRoomsAtom);
|
useBindAllRoomsAtom(mx, allRoomsAtom);
|
||||||
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
useBindRoomToParentsAtom(mx, roomToParentsAtom);
|
||||||
|
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
|
||||||
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
|
||||||
|
|
||||||
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { atom, useSetAtom } from 'jotai';
|
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
IRoomTimelineData,
|
IRoomTimelineData,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
NotificationCount,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
SyncState,
|
SyncState,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Membership,
|
Membership,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@@ -29,6 +30,9 @@ import { roomToParentsAtom } from './roomToParents';
|
|||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
|
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||||
|
import { threadNotificationsAtom } from '../threadNotifications';
|
||||||
|
import { getMutedThreads } from '../../utils/threadNotifications';
|
||||||
|
|
||||||
export type RoomToUnreadAction =
|
export type RoomToUnreadAction =
|
||||||
| {
|
| {
|
||||||
@@ -169,11 +173,17 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
|
|||||||
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
|
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
|
||||||
const setUnreadAtom = useSetAtom(unreadAtom);
|
const setUnreadAtom = useSetAtom(unreadAtom);
|
||||||
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
|
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(() => {
|
useEffect(() => {
|
||||||
setUnreadAtom({
|
setUnreadAtom({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
unreadInfos: getUnreadInfos(mx),
|
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
|
||||||
});
|
});
|
||||||
}, [mx, setUnreadAtom]);
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
@@ -187,7 +197,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
) {
|
) {
|
||||||
setUnreadAtom({
|
setUnreadAtom({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
unreadInfos: getUnreadInfos(mx),
|
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -204,6 +214,10 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
data: IRoomTimelineData,
|
data: IRoomTimelineData,
|
||||||
) => {
|
) => {
|
||||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
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) {
|
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||||
setUnreadAtom({
|
setUnreadAtom({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
@@ -213,7 +227,13 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mEvent.getSender() === mx.getUserId()) return;
|
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);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -246,10 +266,51 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnreadAtom({
|
setUnreadAtom({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
unreadInfos: getUnreadInfos(mx),
|
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
|
||||||
});
|
});
|
||||||
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
|
}, [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 appends the emitting Room as the final
|
||||||
|
// arg, making this a surgical per-room PUT (not a full RESET per emit) with
|
||||||
|
// muted-thread subtraction re-applied. Room-mute keeps its DELETE semantics.
|
||||||
|
useRoomsListener(
|
||||||
|
mx,
|
||||||
|
RoomEvent.UnreadNotifications,
|
||||||
|
useCallback(
|
||||||
|
(
|
||||||
|
_unreadNotifications: NotificationCount | undefined,
|
||||||
|
_threadId: string | undefined,
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const handleMembershipChange = (room: Room, membership: string) => {
|
const handleMembershipChange = (room: Room, membership: string) => {
|
||||||
if (membership !== Membership.Join) {
|
if (membership !== Membership.Join) {
|
||||||
|
|||||||
@@ -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 });
|
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 -------------------------------------------------------
|
// --- getRoomIconSrc -------------------------------------------------------
|
||||||
|
|
||||||
test('getRoomIconSrc selects icon by room type and join rule', () => {
|
test('getRoomIconSrc selects icon by room type and join rule', () => {
|
||||||
|
|||||||
+24
-5
@@ -29,6 +29,7 @@ import {
|
|||||||
StateEvent,
|
StateEvent,
|
||||||
UnreadInfo,
|
UnreadInfo,
|
||||||
} from '../../types/matrix/room';
|
} from '../../types/matrix/room';
|
||||||
|
import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications';
|
||||||
|
|
||||||
export const getStateEvent = (
|
export const getStateEvent = (
|
||||||
room: Room,
|
room: Room,
|
||||||
@@ -233,9 +234,23 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUnreadInfo = (room: Room): UnreadInfo => {
|
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
|
||||||
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
||||||
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
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 {
|
return {
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
highlight,
|
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) => {
|
const unreadInfos = mx.getRooms().reduce<UnreadInfo[]>((unread, room) => {
|
||||||
if (room.isSpaceRoom()) return unread;
|
if (room.isSpaceRoom()) return unread;
|
||||||
if (room.getMyMembership() !== 'join') return unread;
|
if (room.getMyMembership() !== 'join') return unread;
|
||||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
|
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
|
||||||
|
|
||||||
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
|
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;
|
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).
|
// devices like custom emoji/sticker packs).
|
||||||
LotusSoundboard = 'io.lotus.soundboard',
|
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',
|
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||||
|
|
||||||
CrossSigningMaster = 'm.cross_signing.master',
|
CrossSigningMaster = 'm.cross_signing.master',
|
||||||
|
|||||||
Reference in New Issue
Block a user