fix(threads): review-wave fixes for per-thread notifications

- useRoomsListener now PREPENDS the emitting Room (was appended): the SDK emits
  RoomEvent.UnreadNotifications with VARIABLE arity (0/1/2 args), so a trailing
  extra arg landed in the wrong positional slot on the most common room-count
  sync path — room.isSpaceRoom() threw inside the SDK emit loop and the badge
  PUT never ran. Both consumers updated (CONFIRMED HIGH review finding).
- roomToUnread: SpaceChild RESET now passes the thread prefs so muted-thread
  subtraction survives space-child state changes.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:53:32 -04:00
parent 501d493ca4
commit 0adce52d37
3 changed files with 20 additions and 20 deletions
+8 -6
View File
@@ -16,16 +16,18 @@ import {
* 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
* The emitting {@link Room} is PREPENDED as the first argument, before the
* event's own args: several room-level SDK events (e.g.
* `RoomEvent.UnreadNotifications`) don't include the room in their payload,
* which callers need for per-room updates. Handlers that don't care can simply
* ignore it.
* which callers need for per-room updates. Prepending (not appending) is
* load-bearing — some SDK events emit with VARIABLE arity
* (UnreadNotifications fires with 0, 1, or 2 args), so a trailing extra arg
* would land in a different positional slot per emit.
*/
export function useRoomsListener<E extends RoomEmittedEvents>(
mx: MatrixClient,
event: E,
handler: (...args: [...Parameters<RoomEventHandlerMap[E]>, Room]) => void,
handler: (room: Room, ...args: Parameters<RoomEventHandlerMap[E]>) => void,
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
@@ -39,9 +41,9 @@ export function useRoomsListener<E extends RoomEmittedEvents>(
const attach = (room: Room) => {
if (attached.has(room.roomId)) return;
// Per-room trampoline: forwards to the current ref value with the
// emitting room appended.
// emitting room PREPENDED (stable slot regardless of emit arity).
const roomHandler = (...args: unknown[]) =>
(handlerRef.current as (...a: unknown[]) => void)(...args, room);
(handlerRef.current as (...a: unknown[]) => void)(room, ...args);
attached.set(room.roomId, roomHandler);
// `event`/`roomHandler` are correlated through E but TS can't prove it
// for the open generic, so we assert at the boundary.