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,