- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance of the read-marker-corruption regression — opening a thread whose root is old re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression tests. - N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double- counting every ancestor-space aggregate in roomToUnread). - N2 (🔴): notifications/sounds dedupe on the event id, not the unread count — fixes "read a DM, next message never notifies again". - T4 (🟠): the thread notification path no longer re-gates on the room count, so an explicit per-thread "All replies" override in a Mentions-only room fires. - N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no longer light the nav row / pollute unread filters). - N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a threaded receipt can't wipe a room's valid main-timeline badge. - T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on a stale mid/old event). Gates: tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
EventStatus,
|
|
EventTimeline,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
Room,
|
|
RoomEvent,
|
|
RoomEventHandlerMap,
|
|
Thread,
|
|
ThreadEvent,
|
|
} from 'matrix-js-sdk';
|
|
import { getLinkedTimelines } from '../RoomTimeline';
|
|
import { isPendingThreadReply } from './threadSummaryData';
|
|
|
|
/**
|
|
* Resolve (or bootstrap) the live {@link Thread} for a root event.
|
|
*
|
|
* Uses the existing thread when present, otherwise creates one via
|
|
* `room.createThread` — the SDK then auto-fetches the thread's events via
|
|
* `/relations` and inserts the root at the top. If the root event isn't loaded
|
|
* locally the Thread handles the root fetch itself, so passing `undefined` is
|
|
* safe. Re-resolves when a matching thread later appears/updates on the room.
|
|
*/
|
|
export const useThreadInstance = (room: Room, threadRootId: string): Thread | undefined => {
|
|
const getInstance = useCallback((): Thread | undefined => {
|
|
const existing = room.getThread(threadRootId);
|
|
if (existing) return existing;
|
|
const rootEvent = room.findEventById(threadRootId);
|
|
return room.createThread(threadRootId, rootEvent, [], false) ?? undefined;
|
|
}, [room, threadRootId]);
|
|
|
|
const [thread, setThread] = useState<Thread | undefined>(getInstance);
|
|
|
|
useEffect(() => {
|
|
setThread(getInstance());
|
|
|
|
const handleThread: RoomEventHandlerMap[ThreadEvent.New] = (newThread) => {
|
|
if (newThread.id === threadRootId) setThread(newThread);
|
|
};
|
|
const handleThreadUpdate: RoomEventHandlerMap[ThreadEvent.Update] = (updatedThread) => {
|
|
if (updatedThread.id === threadRootId) setThread(updatedThread);
|
|
};
|
|
|
|
room.on(ThreadEvent.New, handleThread);
|
|
room.on(ThreadEvent.Update, handleThreadUpdate);
|
|
return () => {
|
|
room.removeListener(ThreadEvent.New, handleThread);
|
|
room.removeListener(ThreadEvent.Update, handleThreadUpdate);
|
|
};
|
|
}, [room, threadRootId, getInstance]);
|
|
|
|
return thread;
|
|
};
|
|
|
|
/**
|
|
* Build the ordered list of linked {@link EventTimeline}s for a thread's live
|
|
* timeline and track readiness (`thread.initialEventsFetched`). Subscribes to
|
|
* the Thread's re-emitted timeline events so callers repaginate/re-render as
|
|
* the thread fills in.
|
|
*/
|
|
export const useThreadLinkedTimelines = (
|
|
mx: MatrixClient,
|
|
thread: Thread,
|
|
): { timelines: EventTimeline[]; ready: boolean; refresh: () => void } => {
|
|
const [timelines, setTimelines] = useState<EventTimeline[]>(() =>
|
|
getLinkedTimelines(thread.liveTimeline),
|
|
);
|
|
const [ready, setReady] = useState<boolean>(() => thread.initialEventsFetched);
|
|
|
|
const refresh = useCallback(() => {
|
|
setTimelines(getLinkedTimelines(thread.liveTimeline));
|
|
setReady(thread.initialEventsFetched);
|
|
}, [thread]);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
|
|
const handleTimeline = () => refresh();
|
|
// Thread re-emits RoomEvent.Timeline / RoomEvent.TimelineReset from its
|
|
// timelineSet, and fires ThreadEvent.Update as it (re)populates.
|
|
thread.on(RoomEvent.Timeline, handleTimeline);
|
|
thread.on(RoomEvent.TimelineReset, handleTimeline);
|
|
thread.on(ThreadEvent.Update, handleTimeline);
|
|
return () => {
|
|
thread.removeListener(RoomEvent.Timeline, handleTimeline);
|
|
thread.removeListener(RoomEvent.TimelineReset, handleTimeline);
|
|
thread.removeListener(ThreadEvent.Update, handleTimeline);
|
|
};
|
|
}, [thread, refresh]);
|
|
|
|
return { timelines, ready, refresh };
|
|
};
|
|
|
|
/**
|
|
* Track in-flight (local echo) replies for a thread.
|
|
*
|
|
* Pending thread sends never enter the thread's timelineSet (chronological
|
|
* pending ordering rejects them; `room.getPendingEvents()` THROWS in this
|
|
* mode). We instead watch `RoomEvent.LocalEchoUpdated` on the room and keep our
|
|
* own list of events that are pending replies to this thread and not yet in the
|
|
* thread timeline. When an event's remote echo arrives (status flips to SENT,
|
|
* or it lands in the thread) it drops out of the list.
|
|
*/
|
|
export const useThreadPendingEvents = (
|
|
room: Room,
|
|
threadRootId: string,
|
|
thread: Thread | undefined,
|
|
): MatrixEvent[] => {
|
|
const [pending, setPending] = useState<MatrixEvent[]>([]);
|
|
|
|
useEffect(() => {
|
|
setPending([]);
|
|
|
|
const handleLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event) => {
|
|
const eventId = event.getId();
|
|
setPending((prev) => {
|
|
// Drop any previous entry for this event (same instance across the
|
|
// temp-id -> real-id transition, or matched by id).
|
|
const without = prev.filter((e) => e !== event && e.getId() !== eventId);
|
|
|
|
const alreadyInThread =
|
|
eventId !== undefined && thread?.findEventById(eventId) !== undefined;
|
|
// Keep a tracked event through the SENT window too: the /send response
|
|
// flips status to SENT before /sync delivers the event into the thread
|
|
// timeline — dropping it there would make the message flash out of view.
|
|
// It falls out on the next LocalEchoUpdated once findEventById sees it.
|
|
const trackedAndAwaitingSync =
|
|
event.status === EventStatus.SENT &&
|
|
prev.some((e) => e === event || (eventId !== undefined && e.getId() === eventId));
|
|
const stillPending =
|
|
!alreadyInThread && (isPendingThreadReply(event, threadRootId) || trackedAndAwaitingSync);
|
|
|
|
if (stillPending) return [...without, event];
|
|
return without.length === prev.length ? prev : without;
|
|
});
|
|
};
|
|
|
|
room.on(RoomEvent.LocalEchoUpdated, handleLocalEcho);
|
|
return () => {
|
|
room.removeListener(RoomEvent.LocalEchoUpdated, handleLocalEcho);
|
|
};
|
|
}, [room, threadRootId, thread]);
|
|
|
|
return pending;
|
|
};
|
|
|
|
// markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
|
|
// here for existing import sites.
|
|
export { markThreadAsRead } from './threadReceipt';
|