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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:53:32 -04:00

340 lines
10 KiB
TypeScript

import { produce } from 'immer';
import { atom, useAtomValue, 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, useRef } from 'react';
import {
Membership,
NotificationType,
RoomToUnread,
UnreadInfo,
Unread,
StateEvent,
} from '../../../types/matrix/room';
import {
getAllParents,
getNotificationType,
getUnreadInfo,
getUnreadInfos,
isNotificationEvent,
} from '../../utils/room';
import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { useRoomsNotificationPreferencesContext } from '../../hooks/useRoomsNotificationPreferences';
import { useRoomsListener } from '../../hooks/useRoomsListener';
import { threadNotificationsAtom } from '../threadNotifications';
import { getMutedThreads } from '../../utils/threadNotifications';
export type RoomToUnreadAction =
| {
type: 'RESET';
unreadInfos: UnreadInfo[];
}
| {
type: 'PUT';
unreadInfo: UnreadInfo;
}
| {
type: 'DELETE';
roomId: string;
};
export const unreadInfoToUnread = (unreadInfo: UnreadInfo): Unread => ({
highlight: unreadInfo.highlight,
total: unreadInfo.total,
from: null,
});
const putUnreadInfo = (
roomToUnread: RoomToUnread,
allParents: Set<string>,
unreadInfo: UnreadInfo,
) => {
const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
roomToUnread.set(unreadInfo.roomId, unreadInfoToUnread(unreadInfo));
const newH = unreadInfo.highlight - oldUnread.highlight;
const newT = unreadInfo.total - oldUnread.total;
allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
roomToUnread.set(parentId, {
highlight: (oldParentUnread.highlight += newH),
total: (oldParentUnread.total += newT),
from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
});
});
};
const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
const oldUnread = roomToUnread.get(roomId);
if (!oldUnread) return;
roomToUnread.delete(roomId);
allParents.forEach((parentId) => {
const oldParentUnread = roomToUnread.get(parentId);
if (!oldParentUnread) return;
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
newFrom.delete(roomId);
if (newFrom.size === 0) {
roomToUnread.delete(parentId);
return;
}
roomToUnread.set(parentId, {
highlight: oldParentUnread.highlight - oldUnread.highlight,
total: oldParentUnread.total - oldUnread.total,
from: newFrom,
});
});
};
export const unreadEqual = (u1: Unread, u2: Unread): boolean => {
const countEqual = u1.highlight === u2.highlight && u1.total === u2.total;
if (!countEqual) return false;
const f1 = u1.from;
const f2 = u2.from;
if (f1 === null && f2 === null) return true;
if (f1 === null || f2 === null) return false;
if (f1.size !== f2.size) return false;
let fromEqual = true;
f1?.forEach((item) => {
if (!f2?.has(item)) {
fromEqual = false;
}
});
return fromEqual;
};
const baseRoomToUnread = atom<RoomToUnread>(new Map());
export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
(get) => get(baseRoomToUnread),
(get, set, action) => {
if (action.type === 'RESET') {
const draftRoomToUnread: RoomToUnread = new Map();
action.unreadInfos.forEach((unreadInfo) => {
putUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo,
);
});
set(baseRoomToUnread, draftRoomToUnread);
return;
}
if (action.type === 'PUT') {
const { unreadInfo } = action;
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
// Do not update if unread data has not changes
// like total & highlight
return;
}
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
putUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo,
),
),
);
return;
}
if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), action.roomId),
action.roomId,
),
),
);
}
},
);
export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roomToUnreadAtom) => {
const setUnreadAtom = useSetAtom(unreadAtom);
const roomsNotificationPreferences = useRoomsNotificationPreferencesContext();
const threadNotifications = useAtomValue(threadNotificationsAtom);
// Latest thread-notification prefs for the SDK-event handlers below, read via a
// ref so changing prefs never re-attaches the (many) per-room listeners. The
// dedicated reset effect keyed on `threadNotifications` handles mute/unmute.
const threadNotificationsRef = useRef(threadNotifications);
threadNotificationsRef.current = threadNotifications;
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, threadNotificationsRef.current),
});
}, [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, threadNotificationsRef.current),
});
}
},
[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;
// Single-owner rule: thread replies drive the room badge via
// RoomEvent.UnreadNotifications below — ignore them here so the count is
// never double-driven / mis-attributed to the main timeline.
if (mEvent.threadRootId && mEvent.getId() !== mEvent.threadRootId) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({
type: 'DELETE',
roomId: room.roomId,
});
return;
}
if (mEvent.getSender() === mx.getUserId()) return;
setUnreadAtom({
type: 'PUT',
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
),
});
};
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, threadNotificationsRef.current),
});
}, [mx, setUnreadAtom, roomsNotificationPreferences]);
// Mute/unmute of a thread rewrites `threadNotificationsAtom`; recompute badges
// immediately so muted-thread subtraction takes effect without waiting for the
// next timeline / unread event (mirrors the notification-preferences reset).
useEffect(() => {
setUnreadAtom({
type: 'RESET',
unreadInfos: getUnreadInfos(mx, threadNotifications),
});
}, [mx, setUnreadAtom, threadNotifications]);
// RoomEvent.UnreadNotifications is emitted room-level only (never re-emitted
// client-side), so the main Timeline pathway misses thread-count changes and
// room badges lag. useRoomsListener PREPENDS the emitting Room (the SDK emits
// this event with variable arity — 0/1/2 args — so only a leading slot is
// positionally stable), making this a surgical per-room PUT with muted-thread
// subtraction re-applied. Room-mute keeps its DELETE semantics.
useRoomsListener(
mx,
RoomEvent.UnreadNotifications,
useCallback(
(room: Room) => {
if (room.isSpaceRoom()) return;
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
return;
}
setUnreadAtom({
type: 'PUT',
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
),
});
},
[mx, setUnreadAtom],
),
);
useEffect(() => {
const handleMembershipChange = (room: Room, membership: string) => {
if (membership !== Membership.Join) {
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, threadNotificationsRef.current),
});
}
},
[mx, setUnreadAtom],
),
);
};