diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index e341644b96..aa6820f707 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -20,6 +20,7 @@ const { windowFocused, rowItemIndex, rowRenders, + eventRedacted, } = vi.hoisted(() => ({ vListHandle: { scrollSize: 1000, @@ -55,6 +56,7 @@ const { windowFocused: { current: false }, rowItemIndex: { current: 0 }, rowRenders: { count: 0 }, + eventRedacted: { current: false }, })); let lastOnScroll: ((offset: number) => void) | undefined; @@ -152,10 +154,11 @@ vi.mock('$hooks/timeline/useProcessedTimeline', async (importOriginal) => { getTs: () => Date.now(), getSender: () => '@me:example.org', getId: () => '$evt1', - isRedacted: () => false, + isRedacted: () => eventRedacted.current, }, timelineSet: undefined, eventSender: '@me:example.org', + isRedacted: eventRedacted.current, editId: undefined, reactionsKey: '', content: undefined, @@ -166,6 +169,7 @@ vi.mock('$hooks/timeline/useProcessedTimeline', async (importOriginal) => { processedTimelineOptions.current = options; // Same object every call so the row memo can compare eventData by identity. fakeEvent.itemIndex = rowItemIndex.current; + fakeEvent.isRedacted = eventRedacted.current; return (options.items as number[]).length === 0 ? [] : [fakeEvent]; }, }; @@ -292,6 +296,7 @@ beforeEach(() => { windowFocused.current = false; rowItemIndex.current = 0; rowRenders.count = 0; + eventRedacted.current = false; timelineSync.eventsLength = 1; timelineSync.focusItem = undefined; timelineSync.canPaginateBack = false; @@ -504,6 +509,16 @@ describe('MemoizedTimelineItem', () => { expect(rowRenders.count).toBe(before); }); + + it('re-renders a row when its event is redacted in place', () => { + const { rerender } = renderTimeline(); + const before = rowRenders.count; + + eventRedacted.current = true; + rerender(); + + expect(rowRenders.count).toBeGreaterThan(before); + }); }); describe('unread read marker (normal sync)', () => { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 378d2fb733..2d59d75a25 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -291,6 +291,7 @@ const MemoizedTimelineItem = memo( prev.eventData.id === next.eventData.id && // A filtered mid-timeline insert shifts this without changing `index`. prev.eventData.itemIndex === next.eventData.itemIndex && + prev.eventData.isRedacted === next.eventData.isRedacted && prev.eventData.collapsed === next.eventData.collapsed && prev.eventData.willRenderNewDivider === next.eventData.willRenderNewDivider && prev.eventData.willRenderDayDivider === next.eventData.willRenderDayDivider && @@ -1281,7 +1282,11 @@ export function RoomTimeline({ > {(eventData, index) => ( describe('useProcessedTimeline decryption', () => { // Stable so the append-only fast path is reachable. const ignoredUsersSet = new Set(); - const renderTimeline = (getEvents: () => MatrixEvent[]) => + const renderTimeline = ( + getEvents: () => MatrixEvent[], + timelineHiddenEvents: ResolvedHiddenEventSettings = hiddenEvents + ) => renderHook(() => useProcessedTimeline({ items: getEvents().map((_, i) => i), linkedTimelines: [createTimeline(getEvents())], ignoredUsersSet, - hiddenEvents, + hiddenEvents: timelineHiddenEvents, mxUserId: MY_USER, readUptoEventId: undefined, hideMembershipEvents: true, @@ -913,6 +916,23 @@ describe('useProcessedTimeline decryption', () => { expect(bodyOf(result.current, '$enc')).toBe('the secret'); }); + it('refreshes a row whose event was redacted since it was cached', () => { + let redacted = false; + const event = createEvent({ id: '$redacted' }); + event.isRedacted = () => redacted; + const { result, rerender } = renderTimeline(() => [event], { + ...hiddenEvents, + showTombstoneEvents: true, + }); + + expect(result.current[0]?.isRedacted).toBe(false); + + redacted = true; + rerender(); + + expect(result.current[0]?.isRedacted).toBe(true); + }); + it('keeps the append-only fast path for unencrypted events', () => { let events: MatrixEvent[] = [createEvent({ id: '$a', ts: 999_000 })]; const { result, rerender } = renderTimeline(() => events); diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index ef3ab9a5c4..137655db67 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -41,6 +41,7 @@ export interface ProcessedEvent { id: string; itemIndex: number; mEvent: MatrixEvent; + isRedacted: boolean; timelineSet: EventTimelineSet; eventSender: string | null; collapsed: boolean; @@ -110,6 +111,7 @@ type ProcessedEventDraft = Omit< type TimelineEventEntry = { mEvent: MatrixEvent; timelineSet: EventTimelineSet; + isRedacted: boolean; // Decryption rewrites a MatrixEvent in place, so identity alone does not prove a cached // row still matches it. Undefined for unencrypted events. clearType: string | undefined; @@ -125,6 +127,7 @@ const flattenTimelineEvents = (linkedTimelines: EventTimeline[]): TimelineEventE entries.push({ mEvent, timelineSet, + isRedacted: mEvent.isRedacted(), clearType: encrypted ? mEvent.getType() : undefined, clearContent: encrypted ? mEvent.getContent() : undefined, }); @@ -138,6 +141,7 @@ const isCachedEntryCurrent = ( current: TimelineEventEntry | undefined ): boolean => cached.mEvent === current?.mEvent && + cached.isRedacted === current.isRedacted && cached.clearType === current.clearType && cached.clearContent === current.clearContent; @@ -233,6 +237,7 @@ const mergeDraftsAndExtras = ( id: mEvent.getId()!, itemIndex, mEvent, + isRedacted: mEvent.isRedacted(), timelineSet, eventSender: mEvent.getSender() ?? null, }, @@ -433,7 +438,7 @@ const processTimelineItems = ( for (const item of items) { const entry = timelineEvents[item]; if (!entry) continue; - const { mEvent, timelineSet } = entry; + const { mEvent, timelineSet, isRedacted } = entry; const { threadRootId } = mEvent; const mEventId = mEvent.getId(); if (!mEventId) continue; @@ -461,7 +466,7 @@ const processTimelineItems = ( } } - if (mEvent.isRedacted()) { + if (isRedacted) { const showMessageTombstone = showTombstoneEvents && isRedactableMessageType(type); const showReactionTombstone = hiddenEventReactionTombstone && isReaction; if (!showMessageTombstone && !showReactionTombstone) continue; @@ -476,8 +481,8 @@ const processTimelineItems = ( const allowSpecificHiddenEvent = (isEdit && hiddenEventEdits) || - (isReaction && !mEvent.isRedacted() && hiddenEventReactions) || - (isReaction && mEvent.isRedacted() && hiddenEventReactionTombstone) || + (isReaction && !isRedacted && hiddenEventReactions) || + (isReaction && isRedacted && hiddenEventReactionTombstone) || (isRedactionEvt && shouldShowRedactionTimelineEvent( mEvent, @@ -504,7 +509,7 @@ const processTimelineItems = ( if (isEdit && !hiddenEventEdits) continue; if (isReaction) { - if (mEvent.isRedacted()) { + if (isRedacted) { if (!hiddenEventReactionTombstone) continue; } else if (!hiddenEventReactions) { continue; @@ -555,6 +560,7 @@ const processTimelineItems = ( id: mEventId, itemIndex: item, mEvent, + isRedacted, timelineSet, eventSender, collapsed,