feat(channels): ship M4 per-thread unread with thread receipts mute-aware atom and architectural cleanup

This commit is contained in:
v.lagerev 2026-05-10 14:35:51 +03:00
parent e84c4da093
commit 01107c0656
14 changed files with 564 additions and 209 deletions

View file

@ -520,6 +520,10 @@
"thread_summary_count_other": "{{count}} replies",
"thread_summary_open_thread": "Open thread",
"thread_summary_last_reply_by": "last reply from {{name}}",
"thread_summary_unread_one": "{{count}} unread",
"thread_summary_unread_other": "{{count}} unread",
"thread_summary_highlight_one": "{{count}} mention",
"thread_summary_highlight_other": "{{count}} mentions",
"no_post_permission": "You do not have permission to post in this room",
"conversation_beginning": "This is the beginning of conversation.",

View file

@ -526,6 +526,14 @@
"thread_summary_count_other": "{{count}} ответа",
"thread_summary_open_thread": "Открыть тред",
"thread_summary_last_reply_by": "последний ответ от {{name}}",
"thread_summary_unread_one": "{{count}} непрочитанное",
"thread_summary_unread_few": "{{count}} непрочитанных",
"thread_summary_unread_many": "{{count}} непрочитанных",
"thread_summary_unread_other": "{{count}} непрочитанных",
"thread_summary_highlight_one": "{{count}} упоминание",
"thread_summary_highlight_few": "{{count}} упоминания",
"thread_summary_highlight_many": "{{count}} упоминаний",
"thread_summary_highlight_other": "{{count}} упоминания",
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
"conversation_beginning": "Начало переписки.",

View file

@ -25,6 +25,7 @@ import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChanne
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
import { getChannelsRoomPath } from '../../pages/pathUtils';
import { isBridgedRoom } from '../../utils/room';
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
type RoomProps = {
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
@ -88,22 +89,29 @@ export function Room({ renderRoomView }: RoomProps) {
// changes (provider is reactive via `useIsOneOnOneRoom` upstream).
const isOneOnOne = useIsOneOnOne();
const unreadThreadingEnabled = useUnreadThreadingEnabled();
useKeyDown(
window,
useCallback(
(evt) => {
// Skip Escape-markAsRead when the thread drawer is open. The
// existing handler in `state/room/roomToUnread.ts` blind-deletes
// ALL room unread on any own receipt — so firing markAsRead
// here while the drawer is the active surface would also wipe
// unrelated channel-main unread that the user hasn't seen.
// M4 will refactor the receipt handler to partition by
// thread_id; until then, gate the Escape shortcut.
if (isKeyHotkey('escape', evt) && !showThreadDrawer) {
// Escape marks the room as read. Pre-M4 the receipt handler
// blind-deleted ALL room unread on any own receipt, so the
// drawer-open case had to be skipped to avoid wiping thread
// unread the user hadn't seen. After the M4 receipt-handler
// refactor (PUT-with-fresh, partitioned by thread_id) main and
// thread unread are independent, so Escape on the channel main
// surface marks main as read and leaves threads alone — works
// unconditionally. Under the kill switch the handler reverts
// to blind DELETE, so we keep the drawer-open guard there.
if (
isKeyHotkey('escape', evt) &&
(unreadThreadingEnabled || !showThreadDrawer)
) {
markAsRead(mx, room.roomId, hideActivity);
}
},
[mx, room.roomId, hideActivity, showThreadDrawer]
[mx, room.roomId, hideActivity, showThreadDrawer, unreadThreadingEnabled]
)
);

View file

@ -186,7 +186,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
// Suppress typing notifications from the drawer composer — see
// useTypingStatusUpdater for the spec-level reason (m.typing has
// no thread_id, so a thread-composer typing event would surface
// in the main channel chat as if the user were typing there).
const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId);
const handleFiles = useCallback(
async (files: File[]) => {

View file

@ -751,7 +751,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId();
// Always instant — never smooth on incoming. Smooth animation
// briefly takes the at-bottom anchor out of the
// IntersectionObserver's intersection box during the
// animation frame, which the 1s debounce sometimes turns
// into a false-positive «user scrolled away» → atBottom flips
// false → next message stops auto-following. Direct snap
// matches Discord / Telegram and the drawer (M4 fix in
// ThreadDrawer.tsx) — predictable, no animation surface for
// the observer to misinterpret.
scrollToBottomRef.current.smooth = false;
if (isOwnLiveStreamMessage) {
setAtBottom(true);
if (!atLiveEndRef.current) {
@ -904,7 +913,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback(
() => ({
root: getScrollElement(),
rootMargin: '100px',
// Forgiving threshold: 100px was too tight — single tall
// message + minor touch-scroll inertia easily took users out
// of «at-bottom» state, after which incoming events stopped
// auto-following. 400px gives roughly two-three Stream rows
// of slack before treating the scroll as a deliberate detach,
// matching Discord / Slack auto-follow generosity.
rootMargin: '400px',
}),
[getScrollElement]
),

View file

@ -32,6 +32,10 @@ import { Opts as LinkifyOpts } from 'linkifyjs';
import * as css from './ThreadDrawer.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { scrollToBottom } from '../../utils/dom';
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
import { markAsThreadRead } from '../../utils/notifications';
import { useEditor } from '../../components/editor';
import {
getEditedEvent,
@ -231,8 +235,11 @@ export function ThreadDrawer({
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const unreadThreadingEnabled = useUnreadThreadingEnabled();
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
@ -610,14 +617,15 @@ export function ThreadDrawer({
})
: [];
// Read receipts intentionally NOT fired here in M2. With
// threadSupport: true the SDK auto-attaches `thread_id: rootId` to
// any sendReadReceipt call from a thread event, but the existing
// receipt handler in `state/room/roomToUnread.ts` does a blind
// DELETE on any own receipt — so a thread read would wipe the
// whole channel's unread badge including main-timeline messages
// the user has not seen. M4 will refactor the handler to partition
// by thread_id; until then the drawer leaves unread state alone.
// Read receipts (M4): fired on first reveal at-bottom, on reply
// growth while at-bottom, and on own send via the layout/effect
// pair below. The receipt handler in `state/room/roomToUnread.ts`
// partitions by thread_id (PUT-with-fresh instead of blind DELETE)
// so a thread receipt no longer wipes main-timeline unread. SDK
// auto-attaches `thread_id: rootId` via `threadIdForReceipt(event)`.
// Gated on `unreadThreadingEnabled` so the kill switch falls back
// to pre-M4 silence (no thread receipts → channel-list badge
// semantics match the pre-M4 blind-DELETE handler).
// Track whether the user is parked at the bottom of the replies
// list via IntersectionObserver on the sentinel. Element-web's
@ -670,6 +678,23 @@ export function ThreadDrawer({
const repliesCount = replies.length;
const lastReplyCountRef = useRef(0);
const myUserId = mx.getUserId();
// Fire a thread-scoped read receipt when the user lands at-bottom on
// a thread with replies. SDK auto-routes `thread_id: rootId` via
// `threadIdForReceipt(event)` (client.js:2655). Skipped when the
// kill switch (`channels.unreadThreading: false`) is set — the
// receipt handler in roomToUnread.ts then runs in pre-M4
// blind-DELETE mode, so a thread receipt would wipe channel-main
// unread (the M2 reason this was deferred). Also skipped when the
// tab is backgrounded, mirroring RoomTimeline's `document.hasFocus`
// gate so push notifications keep firing for unfocused tabs.
const tryMarkThreadRead = useCallback(() => {
if (!unreadThreadingEnabled) return;
if (!thread) return;
if (!document.hasFocus()) return;
markAsThreadRead(mx, thread, hideActivity);
}, [mx, thread, hideActivity, unreadThreadingEnabled]);
useLayoutEffect(() => {
const host = scrollHostRef.current;
if (!host) return;
@ -678,7 +703,8 @@ export function ThreadDrawer({
host.scrollTop = host.scrollHeight;
isAtBottomRef.current = true;
lastReplyCountRef.current = repliesCount;
}, [repliesCount]);
tryMarkThreadRead();
}, [repliesCount, tryMarkThreadRead]);
useEffect(() => {
const host = scrollHostRef.current;
@ -696,25 +722,49 @@ export function ThreadDrawer({
// new arrival from someone else.
return;
}
if (lastIsOwn) {
// Own send: jump to bottom instantly — the user just hit Enter,
// a smooth scroll animation feels like the input «kicks back»
// before settling. Slack/iMessage do the same. Direct scrollTop
// write is synchronous; no animation.
host.scrollTop = host.scrollHeight;
} else {
// Reply from someone else while at-bottom: smooth scroll keeps
// a visual cue («new reply landed»).
bottomSentinelRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}
// Always instant — never smooth. Cold-load arrives in 1-2 waves
// (SDK cache + mx.relations resolve), so the first growth pass
// after initial reveal would otherwise smooth-scroll a few rows
// and leak a visible animation on drawer-open. Even for
// steady-state live replies a snap-to-bottom matches Discord /
// Telegram and beats the «kicks back before settling» feel of a
// smooth scrollIntoView. Direct scrollTop write is synchronous —
// browser repaints with the new position in the same frame.
host.scrollTop = host.scrollHeight;
isAtBottomRef.current = true;
tryMarkThreadRead();
// Intentional: replies array read inside is stable identity-wise
// because computed inline; lint disable for the false-positive.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [repliesCount, myUserId]);
}, [repliesCount, myUserId, tryMarkThreadRead]);
// Stay at-bottom when the drawer scroll container resizes — Android
// soft-keyboard open/close shrinks the viewport, and Capacitor's
// default `windowSoftInputMode=adjustResize` shrinks the WebView
// so our scroll host's clientHeight changes. Without this, the
// composer pushes up to roughly mid-replies because `scrollHeight`
// grows past the new shrunk viewport while `scrollTop` stays put.
// Mirrors the keyboard-open fix in `RoomTimeline.tsx::useResizeObserver`
// (line 858+). Skip the first observer callback (initial mount fires
// synthetic resize) so we don't fight the first-reveal layout-effect.
useResizeObserver(
useMemo(() => {
let mounted = false;
return (_entries: ResizeObserverEntry[]) => {
if (!mounted) {
mounted = true;
return;
}
if (isAtBottomRef.current) {
requestAnimationFrame(() => {
const el = scrollHostRef.current;
if (el) scrollToBottom(el);
});
}
};
}, []),
useCallback(() => scrollHostRef.current, [])
);
// Autofocus: drop focus on the close button ONCE per drawer
// lifetime so screen-reader / keyboard users land somewhere

View file

@ -39,14 +39,32 @@ export const Root = style({
},
});
// 6px dot indicator. Filled `Primary.Main` — on M4 will flip to
// `OnContainerLine` for read threads, kept static on M3.
// 6px dot indicator. Visible ONLY when the thread has unread content
// (M4): `unread` → `Primary.Main` (lavender, total > 0); `highlight`
// → `Critical.Main` (mention). On `read` state the dot is hidden
// outright via `display: none` — a dim-coloured dot against the pill
// bg (`SurfaceVariant.ContainerHover`) rendered at ~1.28:1 contrast,
// effectively invisible on dim screens, so the user couldn't tell
// «read» apart from «no indicator». Hiding makes the absence
// load-bearing: dot present → unread, dot absent → read. Under the
// kill switch the parent always passes `read`; the card collapses to
// a clean count + time + chevron pill.
export const Dot = style({
flexShrink: 0,
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Primary.Main,
display: 'none',
selectors: {
'&[data-unread-state="unread"]': {
display: 'block',
backgroundColor: color.Primary.Main,
},
'&[data-unread-state="highlight"]': {
display: 'block',
backgroundColor: color.Critical.Main,
},
},
});
export const Count = style({

View file

@ -6,8 +6,10 @@ import { Icon, Icons, Text, as } from 'folds';
import {
type IThreadBundledRelationship,
type MatrixEvent,
NotificationCountType,
RelationType,
type Room,
RoomEvent,
type Thread,
ThreadEvent,
} from 'matrix-js-sdk';
@ -17,6 +19,7 @@ import { Time } from '../../components/message';
import { getChannelsThreadPath } from '../../pages/pathUtils';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
export type ThreadSummaryCardProps = {
room: Room;
@ -94,6 +97,33 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
};
}, [thread]);
// Per-thread unread refresh (M4). `RoomEvent.UnreadNotifications`
// fires from three SDK call sites (matrix-js-sdk room.js:1354/1398
// /1426): per-thread set (with threadId), per-thread reset from
// /sync (NO threadId, room.js:1398), and main-timeline set (no
// threadId, room.js:1426). Accept `firedThreadId === rootId` AND
// the no-threadId reset/main-timeline cases so a /sync that zeros
// all thread counts in one shot still ticks our card. The cost of
// a no-op tick on unrelated main-timeline updates is negligible
// because we only read a couple of cheap counters. Skipped under
// the kill switch — pre-M4 cards don't surface unread state.
const unreadThreadingEnabled = useUnreadThreadingEnabled();
useEffect(() => {
if (!unreadThreadingEnabled || !rootId) return undefined;
const handle = (
_count: unknown,
firedThreadId: string | undefined
) => {
if (firedThreadId === undefined || firedThreadId === rootId) {
setVersion((v) => v + 1);
}
};
room.on(RoomEvent.UnreadNotifications, handle);
return () => {
room.off(RoomEvent.UnreadNotifications, handle);
};
}, [room, rootId, unreadThreadingEnabled]);
const bundled = rootId
? rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread)
: undefined;
@ -123,8 +153,32 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
: null;
// Per-thread unread state. Server-derived totals via SDK; only
// consulted when the kill switch is on, so disabled-config or
// MSC3773-less servers fall through to the static «read» dot.
const unreadTotal = unreadThreadingEnabled
? room.getThreadUnreadNotificationCount(rootId, NotificationCountType.Total)
: 0;
const unreadHighlight = unreadThreadingEnabled
? room.getThreadUnreadNotificationCount(rootId, NotificationCountType.Highlight)
: 0;
let dotState: 'read' | 'unread' | 'highlight' = 'read';
if (unreadHighlight > 0) dotState = 'highlight';
else if (unreadTotal > 0) dotState = 'unread';
const countLabel = t('Room.thread_summary_count', { count });
const ariaParts = [t('Room.thread_summary_open_thread'), countLabel];
if (unreadThreadingEnabled) {
// Highlight (mention) is a strict superset of unread for screen
// readers: prefer the mention phrasing when applicable so SR
// users get parity with sighted users (red dot vs lavender dot).
// Fall back to plain unread phrasing if total > 0 but no mention.
if (unreadHighlight > 0) {
ariaParts.push(t('Room.thread_summary_highlight', { count: unreadHighlight }));
} else if (unreadTotal > 0) {
ariaParts.push(t('Room.thread_summary_unread', { count: unreadTotal }));
}
}
if (senderDisplayName) {
ariaParts.push(t('Room.thread_summary_last_reply_by', { name: senderDisplayName }));
}
@ -143,9 +197,10 @@ export const ThreadSummaryCard = as<'button', ThreadSummaryCardProps>(
onClick={handleOpen}
aria-label={ariaLabel}
data-event-id={rootId}
data-unread-state={dotState}
{...props}
>
<span className={css.Dot} aria-hidden />
<span className={css.Dot} data-unread-state={dotState} aria-hidden />
<Text className={css.Count} as="span" size="T200">
<b>{countLabel}</b>
</Text>

View file

@ -26,6 +26,15 @@ export type BotConfig = {
};
};
export type ChannelsConfig = {
/** Default true. When false, M4 per-thread unread machinery (badges,
* thread-aware receipt handler, drawer markAsRead) falls back to
* pre-M4 blind-DELETE semantics. `threadSupport: true` stays on
* unconditionally drawer (M2) reads `room.getThread()` and
* ThreadEvent emitters which silently NO-OP without it. */
unreadThreading?: boolean;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@ -43,8 +52,13 @@ export type ClientConfig = {
push?: PushConfig;
bots?: BotConfig[];
channels?: ChannelsConfig;
};
export const isUnreadThreadingEnabled = (clientConfig: ClientConfig): boolean =>
clientConfig.channels?.unreadThreading !== false;
const ClientConfigContext = createContext<ClientConfig | null>(null);
export const ClientConfigProvider = ClientConfigContext.Provider;
@ -55,6 +69,15 @@ export function useClientConfig(): ClientConfig {
return config;
}
// Single source of truth for the M4 kill switch. Default true (absent
// config = enabled). Consumers should call this hook instead of reading
// `useClientConfig()` + `isUnreadThreadingEnabled(...)` separately —
// keeps the predicate in one place and lets future telemetry / runtime
// flips slot in transparently.
export function useUnreadThreadingEnabled(): boolean {
return isUnreadThreadingEnabled(useClientConfig());
}
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';

View file

@ -4,11 +4,29 @@ import { TYPING_TIMEOUT_MS } from '../state/typingMembers';
type TypingStatusUpdater = (typing: boolean) => void;
export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
// `disabled` is the caller's opt-out for surfaces where typing
// notifications would leak to the wrong audience. The Matrix
// `m.typing` EDU is strictly room-scoped (the spec has no `thread_id`
// field), so a thread composer that called `mx.sendTyping(roomId, ...)`
// would broadcast «X is typing» to every member watching the main
// channel chat — even though the user is privately drafting in a
// thread drawer. The drawer passes `disabled=true` to keep the
// indicator absent rather than misleading. Returns a stable noop
// when disabled so call sites keep their stable identity.
export const useTypingStatusUpdater = (
mx: MatrixClient,
roomId: string,
disabled = false
): TypingStatusUpdater => {
const statusSentTsRef = useRef<number>(0);
const sendTypingStatus: TypingStatusUpdater = useMemo(() => {
statusSentTsRef.current = 0;
if (disabled) {
return () => {
/* noop: typing leaks across thread/main boundary by spec */
};
}
return (typing) => {
if (typing) {
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
@ -35,7 +53,7 @@ export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): Typing
}
statusSentTsRef.current = 0;
};
}, [mx, roomId]);
}, [mx, roomId, disabled]);
return sendTypingStatus;
};

View file

@ -2,18 +2,22 @@ import { MatrixClient } from 'matrix-js-sdk';
import { allInvitesAtom, useBindAllInvitesAtom } from '../room-list/inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '../room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '../mDirectList';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../room/roomToUnread';
import { roomToUnreadAtom } from '../room/roomToUnread';
import { useBindRoomToUnreadAtom } from '../room/useBindRoomToUnreadAtom';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { useAutoDirectSync } from '../../hooks/useAutoDirectSync';
import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig';
export const useBindAtoms = (mx: MatrixClient) => {
const unreadThreadingEnabled = useUnreadThreadingEnabled();
useBindMDirectAtom(mx, mDirectAtom);
useAutoDirectSync(mx);
useBindAllInvitesAtom(mx, allInvitesAtom);
useBindAllRoomsAtom(mx, allRoomsAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom, unreadThreadingEnabled);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
};

View file

@ -1,34 +1,14 @@
import produce from 'immer';
import { atom, useSetAtom } from 'jotai';
import {
IRoomTimelineData,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
SyncState,
} from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useCallback, useEffect } from 'react';
import {
Membership,
NotificationType,
RoomToUnread,
UnreadInfo,
Unread,
StateEvent,
} from '../../../types/matrix/room';
import {
getAllParents,
getNotificationType,
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
} from '../../utils/room';
import { atom } from 'jotai';
import { RoomToUnread, UnreadInfo, Unread } from '../../../types/matrix/room';
import { getAllParents } from '../../utils/room';
import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
// Pure reducer + helpers for the room → unread aggregate. The listener
// wiring that drives this atom lives next door in
// `useBindRoomToUnreadAtom.ts` — keeping the data shape file free of
// MatrixClient / React lets the reducer stay testable in isolation
// and shrinks the surface area each module is responsible for.
export type RoomToUnreadAction =
| {
@ -165,118 +145,3 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
}
}
);
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
const setUnreadAtom = useSetAtom(unreadAtom);
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}, [mx, setUnreadAtom]);
useSyncState(
mx,
useCallback(
(state, prevState) => {
if (
(state === SyncState.Prepared && prevState === null) ||
(state === SyncState.Syncing && prevState !== SyncState.Syncing)
) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}
},
[mx, setUnreadAtom]
)
);
useEffect(() => {
const handleTimelineEvent = (
mEvent: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
removed: boolean,
data: IRoomTimelineData
) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
return;
}
if (mEvent.getSender() === mx.getUserId()) return;
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, setUnreadAtom]);
useEffect(() => {
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
const myUserId = mx.getUserId();
if (!myUserId) return;
if (room.isSpaceRoom()) return;
const content = mEvent.getContent<ReceiptContent>();
const isMyReceipt = Object.keys(content).find((eventId) =>
(Object.keys(content[eventId]) as ReceiptType[]).find(
(receiptType) => content[eventId][receiptType][myUserId]
)
);
if (isMyReceipt) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
}
};
mx.on(RoomEvent.Receipt, handleReceipt);
return () => {
mx.removeListener(RoomEvent.Receipt, handleReceipt);
};
}, [mx, setUnreadAtom]);
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== Membership.Join) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
}
};
mx.on(RoomEvent.MyMembership, handleMembershipChange);
return () => {
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
};
}, [mx, setUnreadAtom]);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === StateEvent.SpaceChild) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}
},
[mx, setUnreadAtom]
)
);
};

View file

@ -0,0 +1,260 @@
import { useCallback, useEffect } from 'react';
import { useSetAtom } from 'jotai';
import {
ClientEvent,
IRoomTimelineData,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
SyncState,
} from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { Membership, NotificationType, StateEvent } from '../../../types/matrix/room';
import {
getNotificationType,
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
} from '../../utils/room';
import { roomToUnreadAtom } from './roomToUnread';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
// Wires the SDK signals (Timeline, Receipt, UnreadNotifications,
// MyMembership, sync state, space-child) into `roomToUnreadAtom`.
// Split from the atom file so the reducer + atom shape stay free of
// MatrixClient / React imports — see `roomToUnread.ts` for the data
// layer.
export const useBindRoomToUnreadAtom = (
mx: MatrixClient,
unreadAtom: typeof roomToUnreadAtom,
unreadThreadingEnabled: boolean
) => {
const setUnreadAtom = useSetAtom(unreadAtom);
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}, [mx, setUnreadAtom]);
useSyncState(
mx,
useCallback(
(state, prevState) => {
if (
(state === SyncState.Prepared && prevState === null) ||
(state === SyncState.Syncing && prevState !== SyncState.Syncing)
) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}
},
[mx, setUnreadAtom]
)
);
useEffect(() => {
const handleTimelineEvent = (
mEvent: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
removed: boolean,
data: IRoomTimelineData
) => {
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
return;
}
if (mEvent.getSender() === mx.getUserId()) return;
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [mx, setUnreadAtom]);
useEffect(() => {
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
const myUserId = mx.getUserId();
if (!myUserId) return;
if (room.isSpaceRoom()) return;
const content = mEvent.getContent<ReceiptContent>();
const isMyReceipt = Object.keys(content).find((eventId) =>
(Object.keys(content[eventId]) as ReceiptType[]).find(
(receiptType) => content[eventId][receiptType][myUserId]
)
);
if (!isMyReceipt) return;
// Pre-M4 fallback: blind DELETE wipes the whole room's unread on
// any own receipt. Acceptable when threading is disabled because
// there are no per-thread receipts to honour separately.
if (!unreadThreadingEnabled) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
return;
}
// Mute gate — pre-M4 the blind-DELETE path implicitly respected
// mute (DELETE is a no-op for muted rooms which were never in the
// atom). PUT-with-fresh would re-introduce a muted room into the
// unread index whenever its `getUnreadInfo` returned non-zero
// (e.g. server-side counters for a muted room still tick on
// pings). Mirrors the mute branch in the Timeline + UnreadNotifications
// handlers above/below.
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
return;
}
// With per-thread unread on, an own receipt no longer implies the
// user read every thread in the room — SDK already partitioned
// counters by thread_id before firing this event, so trust it.
// `getUnreadInfo` reads `room.getUnreadNotificationCount(Total)`
// which sums main + all threads. If the receipt was for the main
// timeline, threaded counts stay; if for a thread, the rest stays.
const fresh = getUnreadInfo(room);
if (fresh.total === 0 && fresh.highlight === 0) {
// Explicit DELETE: putUnreadInfo would write a `{ total: 0,
// highlight: 0, from: null }` entry into the Map, which keeps
// the room in the index and breaks parent rollup deltas on the
// next PUT.
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} else {
setUnreadAtom({ type: 'PUT', unreadInfo: fresh });
}
};
mx.on(RoomEvent.Receipt, handleReceipt);
return () => {
mx.removeListener(RoomEvent.Receipt, handleReceipt);
};
}, [mx, setUnreadAtom, unreadThreadingEnabled]);
// RoomEvent.UnreadNotifications is the canonical signal for unread
// count change — fired by the SDK whenever main-timeline or
// per-thread totals shift (room.js:1354 / 1398 / 1426). Catches three
// scenarios the Receipt + Timeline handlers miss:
//
// (a) Local-echo own receipt: `addLocalEchoReceipt` emits
// `RoomEvent.Receipt` synchronously but only zeros notification
// counters when reading own last event AND non-synthetic — for
// drawer-fired thread receipts, counters stay stale until the
// server echoes back via /sync. The /sync echo updates counters
// before firing UnreadNotifications, so handler reads fresh.
// (b) Cross-device own receipt arriving via /sync ephemeral.
// (c) Encrypted-room decryption fixing notification counts after the
// fact (`fixNotificationCountOnDecryption` in client.js).
//
// Per-Room event — not re-emitted to the client. Attach to existing
// rooms on mount + listen to ClientEvent.Room for new joins. Mirrors
// pattern in roomToParents.ts. Skipped when the kill switch is off
// — pre-M4 only listened to Receipt (blind DELETE), so the legacy
// path stays bit-equivalent without this listener.
useEffect(() => {
if (!unreadThreadingEnabled) return undefined;
const recompute = (room: Room) => {
if (room.isSpaceRoom()) return;
// Skip rooms the user no longer belongs to — late UnreadNotifications
// emits (e.g. queued before MyMembership DELETE flushed) would
// otherwise re-PUT a left room into the unread index.
if (room.getMyMembership() !== Membership.Join) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
return;
}
const fresh = getUnreadInfo(room);
if (fresh.total === 0 && fresh.highlight === 0) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} else {
setUnreadAtom({ type: 'PUT', unreadInfo: fresh });
}
};
const handlers = new Map<Room, () => void>();
const attach = (room: Room) => {
if (handlers.has(room)) return;
const handler = () => recompute(room);
room.on(RoomEvent.UnreadNotifications, handler);
handlers.set(room, handler);
};
const detach = (room: Room) => {
const handler = handlers.get(room);
if (!handler) return;
room.removeListener(RoomEvent.UnreadNotifications, handler);
handlers.delete(room);
};
const handleNewRoom = (room: Room) => attach(room);
// ClientEvent.DeleteRoom fires when the SDK drops a room (rejected
// invite, forget, etc) — release the per-room handler ref so the
// Map doesn't grow unbounded across long-running sessions.
const handleDeleteRoom = (deletedRoomId: string) => {
handlers.forEach((_handler, room) => {
if (room.roomId === deletedRoomId) detach(room);
});
};
mx.getRooms().forEach(attach);
mx.on(ClientEvent.Room, handleNewRoom);
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
return () => {
mx.removeListener(ClientEvent.Room, handleNewRoom);
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
handlers.forEach((handler, room) => {
room.removeListener(RoomEvent.UnreadNotifications, handler);
});
handlers.clear();
};
}, [mx, setUnreadAtom, unreadThreadingEnabled]);
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== Membership.Join) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
}
};
mx.on(RoomEvent.MyMembership, handleMembershipChange);
return () => {
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
};
}, [mx, setUnreadAtom]);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === StateEvent.SpaceChild) {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx),
});
}
},
[mx, setUnreadAtom]
)
);
};

View file

@ -1,26 +1,49 @@
import { MatrixClient, ReceiptType } from 'matrix-js-sdk';
import { MatrixClient, MatrixEvent, ReceiptType, Thread } from 'matrix-js-sdk';
// Walks a timeline backwards and returns the latest event that is
// fully sent to the homeserver (not local-echo / pending). Shared by
// the main-room and thread receipt senders so the «which event is
// the read marker pointed at» semantic stays consistent.
//
// Note: deliberately does NOT skip on `evt.getId() === readEventId`.
// SDK's `addLocalEchoReceipt` updates the local read-up-to marker
// synchronously on every `sendReadReceipt` call (read-receipt.js:329-332),
// so a readEventId-match short-circuit would bail on the second
// invocation even when the server failed to process the first one
// (network failure, 5xx, MSC3773-less server) — leaving the unread
// counters stuck. The server is idempotent on duplicate receipts, so
// re-sending costs at most one round-trip; callers gate on
// `document.hasFocus()` to avoid spam from backgrounded tabs.
const pickLatestNonPending = (events: MatrixEvent[]): MatrixEvent | null => {
for (let i = events.length - 1; i >= 0; i -= 1) {
const evt = events[i];
if (evt && !evt.isSending()) {
return evt;
}
}
return null;
};
const receiptType = (privateReceipt: boolean): ReceiptType =>
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read;
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const room = mx.getRoom(roomId);
if (!room) return;
const timeline = room.getLiveTimeline().getEvents();
const readEventId = room.getEventReadUpTo(mx.getUserId()!);
const getLatestValidEvent = () => {
for (let i = timeline.length - 1; i >= 0; i -= 1) {
const latestEvent = timeline[i];
if (latestEvent.getId() === readEventId) return null;
if (!latestEvent.isSending()) return latestEvent;
}
return null;
};
if (timeline.length === 0) return;
const latestEvent = getLatestValidEvent();
if (latestEvent === null) return;
await mx.sendReadReceipt(
latestEvent,
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read
);
const latestEvent = pickLatestNonPending(room.getLiveTimeline().getEvents());
if (!latestEvent) return;
await mx.sendReadReceipt(latestEvent, receiptType(privateReceipt));
}
// Thread-scoped variant. SDK auto-attaches `thread_id: <rootId>` via
// `threadIdForReceipt(event)` (matrix-js-sdk client.js:2655).
export async function markAsThreadRead(
mx: MatrixClient,
thread: Thread,
privateReceipt: boolean
) {
if (!mx.getUserId()) return;
const latestEvent = pickLatestNonPending(thread.liveTimeline.getEvents());
if (!latestEvent) return;
await mx.sendReadReceipt(latestEvent, receiptType(privateReceipt));
}