CI / Build & Quality Checks (pull_request) Successful in 2m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m16s
The media gallery and activity log register filtered timeline sets on the room (getOrCreateFilteredTimelineSet); the room re-emits their events as live RoomEvent.Timeline, so every matching event reached "new event in the room" listeners twice for the rest of the session. The timeline window shifted by an extra message per event (loaded messages vanished), and notifications, unread counts, widget feeds and ring handling saw duplicates. isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered set; the room timeline, roomToUnread, message notifications, clock-skew sampling, call ring/decline handling and widget event feeds ignore them. Verified headless (40 messages, every 5th a file; gallery opened; Bob sends 3 files + 1 text): before, the first rendered message drifted from the room start to "file15"; now it stays put. Fixes #259 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
376 lines
12 KiB
TypeScript
376 lines
12 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,
|
|
isFromFilteredTimelineSet,
|
|
} 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;
|
|
// `from` is always a Set for parent aggregates; the fallback must be an
|
|
// iterable of ids, NOT the roomId string (which would spread into chars).
|
|
const newFrom = new Set([...(oldParentUnread.from ?? [])]);
|
|
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;
|
|
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
|
|
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
|
|
// the UnreadNotifications listener firing once the server zeroes counts)
|
|
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
|
|
// actually clears. Done before the unreadEqual short-circuit so an
|
|
// already-stuck { 0, 0 } gets removed too.
|
|
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
|
|
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
|
|
set(
|
|
baseRoomToUnread,
|
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
|
deleteUnreadInfo(
|
|
draftRoomToUnread,
|
|
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
|
|
unreadInfo.roomId,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
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;
|
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
|
// 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,
|
|
),
|
|
});
|
|
};
|
|
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) {
|
|
// Optimistically clear on our own receipt (upstream cinny behavior).
|
|
// Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
|
|
// server-computed and STALE on the synchronous synthetic receipt echo
|
|
// (the SDK only zeroes it immediately when the last live event is our own
|
|
// message), so recomputing PUTs the stale non-zero count back → the dot
|
|
// sticks / resurrects. The RoomEvent.UnreadNotifications listener below
|
|
// re-asserts the accurate badge (incl. restoring the main badge after a
|
|
// thread read) once the server acks, and a { 0, 0 } PUT collapses to a
|
|
// DELETE in the reducer.
|
|
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,
|
|
),
|
|
});
|
|
},
|
|
[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],
|
|
),
|
|
);
|
|
};
|