From 131224d66c0ea9309dc6c0b1de4012c3c2f038ca Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 16 Jul 2026 22:39:24 +0200 Subject: [PATCH] fix: render new messages divider when read receipt anchors on a filtered event --- ...ix-new-messages-divider-filtered-anchor.md | 5 + .../timeline/useProcessedTimeline.test.tsx | 227 ++++++++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 13 +- 3 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-new-messages-divider-filtered-anchor.md create mode 100644 src/app/hooks/timeline/useProcessedTimeline.test.tsx diff --git a/.changeset/fix-new-messages-divider-filtered-anchor.md b/.changeset/fix-new-messages-divider-filtered-anchor.md new file mode 100644 index 000000000..82e2a0cbf --- /dev/null +++ b/.changeset/fix-new-messages-divider-filtered-anchor.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix the new messages divider not appearing when the read receipt points at an event hidden from the timeline (reaction, edit, thread reply). diff --git a/src/app/hooks/timeline/useProcessedTimeline.test.tsx b/src/app/hooks/timeline/useProcessedTimeline.test.tsx new file mode 100644 index 000000000..e02a0d34a --- /dev/null +++ b/src/app/hooks/timeline/useProcessedTimeline.test.tsx @@ -0,0 +1,227 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { EventTimeline, EventTimelineSet, MatrixEvent } from '$types/matrix-sdk'; +import type { ResolvedHiddenEventSettings } from '$state/hooks/settings'; +import type { ProcessedEvent } from './useProcessedTimeline'; +import { useProcessedTimeline } from './useProcessedTimeline'; + +const MY_USER = '@alice:test'; +const OTHER_USER = '@bob:test'; + +const hiddenEvents: ResolvedHiddenEventSettings = { + showHiddenEvents: false, + showTombstoneEvents: false, + hiddenEventEdits: false, + hiddenEventRedactionTimeline: false, + hiddenEventReactions: false, + hiddenEventReactionTombstone: false, + hiddenEventReactionRedactionTimeline: false, + hiddenEventOther: false, +}; + +type FakeEventOptions = { + id: string; + type?: string; + sender?: string; + content?: Record; + prevContent?: Record; + relation?: { rel_type: string; event_id: string; key?: string }; + threadRootId?: string; + ts?: number; +}; + +function createEvent({ + id, + type = 'm.room.message', + sender = OTHER_USER, + content = { msgtype: 'm.text', body: 'hello' }, + prevContent = {}, + relation, + threadRootId, + ts = 1_000_000, +}: FakeEventOptions): MatrixEvent { + return { + getId: () => id, + getType: () => type, + getSender: () => sender, + getContent: () => content, + getPrevContent: () => prevContent, + getWireContent: () => content, + getTs: () => ts, + isRedacted: () => false, + isRedaction: () => false, + isEncrypted: () => false, + getRelation: () => relation ?? null, + threadRootId, + } as unknown as MatrixEvent; +} + +function createReaction(id: string, targetId: string): MatrixEvent { + const relation = { rel_type: 'm.annotation', event_id: targetId, key: '👍' }; + return createEvent({ + id, + type: 'm.reaction', + content: { 'm.relates_to': relation }, + relation, + }); +} + +function createEdit(id: string, targetId: string): MatrixEvent { + const relation = { rel_type: 'm.replace', event_id: targetId }; + return createEvent({ + id, + content: { + msgtype: 'm.text', + body: '* edited', + 'm.new_content': { msgtype: 'm.text', body: 'edited' }, + 'm.relates_to': relation, + }, + relation, + }); +} + +function createThreadReply(id: string, rootId: string): MatrixEvent { + const relation = { rel_type: 'm.thread', event_id: rootId }; + return createEvent({ + id, + content: { msgtype: 'm.text', body: 'thread reply', 'm.relates_to': relation }, + relation, + threadRootId: rootId, + }); +} + +function createMembership(id: string): MatrixEvent { + return createEvent({ + id, + type: 'm.room.member', + content: { membership: 'join' }, + }); +} + +function createTimeline(events: MatrixEvent[]): EventTimeline { + const timelineSet = { + relations: { + getChildEventsForEvent: () => null, + }, + } as unknown as EventTimelineSet; + return { + getEvents: () => events, + getTimelineSet: () => timelineSet, + } as unknown as EventTimeline; +} + +function processTimeline( + events: MatrixEvent[], + readUptoEventId: string | undefined +): ProcessedEvent[] { + const { result } = renderHook(() => + useProcessedTimeline({ + items: events.map((_, i) => i), + linkedTimelines: [createTimeline(events)], + ignoredUsersSet: new Set(), + hiddenEvents, + mxUserId: MY_USER, + readUptoEventId, + hideMembershipEvents: true, + hideNickAvatarEvents: true, + isReadOnly: false, + hideMemberInReadOnly: false, + }) + ); + return result.current; +} + +const renderedIds = (processed: ProcessedEvent[]) => processed.map((e) => e.id); +const dividerIds = (processed: ProcessedEvent[]) => + processed.filter((e) => e.willRenderNewDivider).map((e) => e.id); + +describe('useProcessedTimeline new-messages divider', () => { + it('renders exactly one divider after a receipt anchored on a rendered message', () => { + const processed = processTimeline([createEvent({ id: '$a' }), createEvent({ id: '$b' })], '$a'); + + expect(renderedIds(processed)).toEqual(['$a', '$b']); + expect(dividerIds(processed)).toEqual(['$b']); + }); + + it.each([ + ['reaction', () => createReaction('$anchor', '$a')], + ['edit', () => createEdit('$anchor', '$a')], + ['thread reply', () => createThreadReply('$anchor', '$a')], + ['hidden membership event', () => createMembership('$anchor')], + ])('renders the divider when the receipt is anchored on a filtered %s', (_kind, anchor) => { + const processed = processTimeline( + [createEvent({ id: '$a' }), anchor(), createEvent({ id: '$b' })], + '$anchor' + ); + + expect(renderedIds(processed)).toEqual(['$a', '$b']); + expect(dividerIds(processed)).toEqual(['$b']); + }); + + it('keeps the divider pending across consecutive filtered events', () => { + const processed = processTimeline( + [ + createEvent({ id: '$a' }), + createReaction('$r', '$a'), + createEdit('$e', '$a'), + createEvent({ id: '$b' }), + ], + '$r' + ); + + expect(renderedIds(processed)).toEqual(['$a', '$b']); + expect(dividerIds(processed)).toEqual(['$b']); + }); + + it('keeps the divider pending when a filtered event follows a rendered receipt', () => { + const processed = processTimeline( + [createEvent({ id: '$a' }), createReaction('$r', '$a'), createEvent({ id: '$b' })], + '$a' + ); + + expect(renderedIds(processed)).toEqual(['$a', '$b']); + expect(dividerIds(processed)).toEqual(['$b']); + }); + + it('skips own messages when placing the divider after a filtered anchor', () => { + const processed = processTimeline( + [ + createEvent({ id: '$a' }), + createReaction('$r', '$a'), + createEvent({ id: '$mine', sender: MY_USER }), + createEvent({ id: '$b' }), + ], + '$r' + ); + + expect(renderedIds(processed)).toEqual(['$a', '$mine', '$b']); + expect(dividerIds(processed)).toEqual(['$b']); + }); + + it('renders no divider when the receipt is on the newest event', () => { + const processed = processTimeline( + [createEvent({ id: '$a' }), createEvent({ id: '$b' }), createReaction('$r', '$b')], + '$r' + ); + + expect(dividerIds(processed)).toEqual([]); + }); + + it('renders no divider when the receipt event is not in the timeline', () => { + const processed = processTimeline( + [createEvent({ id: '$a' }), createEvent({ id: '$b' })], + '$elsewhere' + ); + + expect(dividerIds(processed)).toEqual([]); + }); + + it('renders no divider without a read receipt', () => { + const processed = processTimeline( + [createEvent({ id: '$a' }), createEvent({ id: '$b' })], + undefined + ); + + expect(dividerIds(processed)).toEqual([]); + }); +}); diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index c4e3f9868..6c221ff4a 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -355,6 +355,7 @@ export function useProcessedTimeline({ return useMemo(() => { let prevEvent: MatrixEvent | undefined; + let prevIteratedEventId: string | undefined; let isPrevRendered = false; let newDivider = false; let dayDivider = false; @@ -373,6 +374,13 @@ export function useProcessedTimeline({ const mEventId = mEvent.getId(); if (!mEventId) return acc; + // Track every iterated event, not just rendered ones: the read receipt + // may point at an event that never renders (reaction, edit, thread reply). + if (!newDivider && readUptoEventId) { + newDivider = prevIteratedEventId === readUptoEventId; + } + prevIteratedEventId = mEventId; + const eventSender = mEvent.getSender() ?? null; if (eventSender && ignoredUsersSet.has(eventSender)) return acc; @@ -466,11 +474,6 @@ export function useProcessedTimeline({ ) return acc; - if (!newDivider && readUptoEventId) { - const prevId = prevEvent ? prevEvent.getId() : undefined; - newDivider = prevId === readUptoEventId; - } - if (!dayDivider) { dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; }