From 00890b210857a44531c0a4dd865b25b50f74982b Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:33:36 +0000 Subject: [PATCH 1/2] File zone: 'read by' finds receipts anywhere in the room, in the album listing and on the timeline row readersOf (fileReceipt.ts) is the one reader: a receipt is a thread reply or a plain room event, matched by content.event_id; the sender must be @:, the hash the file event's, an m.reference present must name the same event, one entry per Mind. The timeline file message draws it too. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 16 ++- src/app/features/albums/AlbumView.tsx | 8 +- src/app/features/albums/FileReadBy.tsx | 30 +++++ src/app/features/albums/buildAlbums.ts | 49 +------- src/app/features/albums/fileReceipt.test.ts | 130 +++++++++++++++++++- src/app/features/albums/fileReceipt.ts | 64 +++++++++- src/app/features/albums/useFileReaders.ts | 50 ++++++++ src/app/features/room/RoomTimeline.tsx | 4 + 8 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 src/app/features/albums/FileReadBy.tsx create mode 100644 src/app/features/albums/useFileReaders.ts diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index 36bb7a80..dd02a8fc 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -45,7 +45,9 @@ reference; audio carries no duration. A Mind should not rely on any of those. ## Receipt: `org.gamecult.file_received` The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the -file, as a reply in the album's thread so it arrives with the album's own listing (no extra request): +file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's +own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both +count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread: ```json { @@ -58,6 +60,12 @@ file, as a reply in the album's thread so it arrives with the album's own listin } ``` +A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: }`, which lets +the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no +relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts +for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one +`read by` for that Mind. + Two rules, both enforced on each side: 1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: @@ -80,6 +88,8 @@ what the hash and the Mind's own account show. ## Not built yet - Files sent from the composer carry no thread relation, so they reach an album only if written into a - thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Receipts are - thread replies, so they show only where the file is. + thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain + room events, and the timeline row for the file shows `read by` from them. +- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not + seen there (an album's own listing reads the thread, which is paged separately). - Encrypted zones are not listed. diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 18673d6e..1bf30579 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -15,7 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; -import { readLine } from './fileReceipt'; +import { FileReadBy } from './FileReadBy'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -48,11 +48,7 @@ function FileRow({ room, file, author }: FileRowProps) { {file.name} - {file.readBy.length > 0 && ( - - {readLine(file.readBy, (id) => getMemberDisplayName(room, id) ?? id)} - - )} + {audio ? ( } /> ) : ( diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx new file mode 100644 index 00000000..0c81849a --- /dev/null +++ b/src/app/features/albums/FileReadBy.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { Text } from 'folds'; +import { getMemberDisplayName } from '../../utils/room'; +import { FileReader, readLine } from './fileReceipt'; +import { useFileReaders } from './useFileReaders'; +import * as css from './AlbumView.css'; + +type FileReadByProps = { + room: Room; + fileId: string; + content: Record; + /** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */ + also?: FileReader[]; +}; +/** "read by " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ +export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { + const seen = useFileReaders(room, fileId, content); + const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()]; + if (readers.length === 0) return null; + return ( + + {readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)} + + ); +} + +/** A timeline message that is a file: audio, image, video or file. */ +export const isFileMessage = (mEvent: MatrixEvent): boolean => + ['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 22a872ce..afd764e1 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,8 +1,8 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. -import { MINDS } from '../../components/message/pose/poses'; -import { FILE_HASH_KEY, FILE_RECEIVED } from './fileReceipt'; +import { readersOf } from './fileReceipt'; +import type { FileReader } from './fileReceipt'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -18,9 +18,6 @@ export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; export type FileKind = 'audio' | 'image' | 'video' | 'file'; -/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ -export type FileReader = { sender: string; ts: number }; - export type AlbumFile = { id: string; kind: FileKind; @@ -81,44 +78,6 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { }; }; -// A receipt is a thread reply of type org.gamecult.file_received whose content names a file of this -// album; it carries no msgtype, so it never passes `live`. -const receiptOf = (e: ZoneEvent) => { - if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; - if (e.unsigned?.redacted_because) return undefined; - const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; - if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; - if (typeof e.sender !== 'string') return undefined; - return { - file: body.event_id, - sender: e.sender, - ts: tsOf(e.origin_server_ts), - sha256: body.sha256, - }; -}; - -// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix -// identity on this homeserver (`@:`, the mind named in the Persona catalogue). "Read" means verified: the receipt's hash must be -// the file event's own. -const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); - -const attachReceipts = (files: AlbumFile[], replies: ZoneEvent[], server: string) => { - const byId = new Map(files.map((f) => [f.id, f])); - replies - .map(receiptOf) - .filter((r): r is NonNullable => !!r && isMind(r.sender, server)) - .sort((a, b) => a.ts - b.ts) - .forEach((r) => { - const file = byId.get(r.file); - const sent = (file?.content.info as Record | undefined)?.[FILE_HASH_KEY]; - if (!file || typeof sent !== 'string' || sent !== r.sha256) return; - file.readBy = [ - ...file.readBy.filter((x) => x.sender !== r.sender), - { sender: r.sender, ts: r.ts }, - ]; - }); -}; - export const UNTITLED = 'Untitled album'; // An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is @@ -141,8 +100,8 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { seen.add(f.id); return true; }) - .sort((a, b) => a.ts - b.ts); - attachReceipts(files, Array.isArray(replies) ? replies : [], server); + .sort((a, b) => a.ts - b.ts) + .map((f) => ({ ...f, readBy: readersOf(replies, f.id, f.content, server) })); return { id: root.event_id, title: titleOf(root), diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts index 01eb37c0..39386e4f 100644 --- a/src/app/features/albums/fileReceipt.test.ts +++ b/src/app/features/albums/fileReceipt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { hashForSend, readLine, sha256Hex } from './fileReceipt'; +import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt'; describe('sha256Hex', () => { it('hashes the bytes: the FIPS 180 vector for "abc"', async () => { @@ -35,3 +35,131 @@ describe('readLine', () => { ).toBe('read by void, eureka'); }); }); + +describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => { + const HASH = 'a'.repeat(64); + const file = { info: { sha256: HASH } }; + const receipt = ( + id: string, + ts: number, + sender: string, + sha: unknown | undefined, + fileId: string | undefined, + relates?: object + ): ReceiptEvent => ({ + event_id: id, + type: 'org.gamecult.file_received', + sender, + origin_server_ts: ts, + content: { + ...(relates ? { 'm.relates_to': relates } : {}), + 'org.gamecult.file_received': { + event_id: fileId ?? '$f', + sha256: sha === undefined ? HASH : sha, + }, + }, + }); + const readers = (events: ReceiptEvent[], content: Record | undefined) => + readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender); + + it('counts a threadless receipt, as a composer file with no album root gets', () => { + expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([ + '@void:gamecult.org', + ]); + }); + it('counts a thread reply the same way', () => { + const t = { rel_type: 'm.thread', event_id: '$root' }; + expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([ + '@eureka:gamecult.org', + ]); + }); + it('refuses lookalike localparts and foreign servers', () => { + expect( + readers( + [ + receipt('$1', 1, '@void2:gamecult.org', undefined, undefined), + receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined), + receipt('$3', 3, '@Void:gamecult.org', undefined, undefined), + receipt('$4', 4, '@void:evil.example', undefined, undefined), + receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined), + receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined), + ], + file + ) + ).toEqual([]); + }); + it('shows nothing for a mismatched hash, a file with no hash, or another file', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file) + ).toEqual([]); + expect( + readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} }) + ).toEqual([]); + expect( + readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined) + ).toEqual([]); + expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]); + }); + it('keeps one entry per Mind, the latest, oldest first', () => { + expect( + readersOf( + [ + receipt('$1', 30, '@void:gamecult.org', undefined, undefined), + receipt('$2', 10, '@void:gamecult.org', undefined, undefined), + receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined), + ], + '$f', + file, + 'gamecult.org' + ) + ).toEqual([ + { sender: '@aqua:gamecult.org', ts: 20 }, + { sender: '@void:gamecult.org', ts: 30 }, + ]); + }); + it('ignores a redacted receipt and a malformed one', () => { + const redacted = { + ...receipt('$1', 1, '@void:gamecult.org', undefined, undefined), + unsigned: { redacted_because: {} }, + }; + expect( + readers( + [ + redacted, + receipt('$2', 2, '@void:gamecult.org', 7, undefined), + { + ...receipt('$3', 3, '@void:gamecult.org', undefined, undefined), + type: 'm.room.message', + }, + ], + file + ) + ).toEqual([]); + }); + + describe('an m.reference to the file', () => { + const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id }); + it('counts when it names the same event as the content', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file) + ).toEqual(['@void:gamecult.org']); + }); + it('counts for nothing when it names another event', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file) + ).toEqual([]); + }); + it('draws one entry per Mind however many receipts exist', () => { + expect( + readers( + [ + receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')), + receipt('$2', 2, '@void:gamecult.org', HASH, '$f'), + receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')), + ], + file + ) + ).toEqual(['@void:gamecult.org']); + }); + }); +}); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts index 9a4d0ee5..053570e9 100644 --- a/src/app/features/albums/fileReceipt.ts +++ b/src/app/features/albums/fileReceipt.ts @@ -1,7 +1,20 @@ // The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes // into a file event, and the receipt a Mind answers with. -import type { FileReader } from './buildAlbums'; +import { MINDS } from '../../components/message/pose/poses'; + +/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ +export type FileReader = { sender: string; ts: number }; + +/** The slice of a Matrix event a receipt is read from. Every field may be missing. */ +export type ReceiptEvent = { + event_id?: string; + type?: string; + sender?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: Record; +}; /** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ export const FILE_HASH_KEY = 'sha256'; @@ -25,3 +38,52 @@ export const hashForSend = async (data: Blob): Promise => { export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => `read by ${readBy.map((r) => name(r.sender)).join(', ')}`; + +const receiptOf = (e: ReceiptEvent) => { + if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; + if (e.unsigned?.redacted_because) return undefined; + const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; + if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; + if (typeof e.sender !== 'string') return undefined; + // A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the + // receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read. + const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined; + if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined; + const ts = e.origin_server_ts; + return { + file: body.event_id, + sender: e.sender, + ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0, + sha256: body.sha256, + }; +}; + +// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix +// identity on this homeserver (`@:`, a mind named in the Persona catalogue). +const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); + +/** + * Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's + * timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count). + * "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a + * mismatch, shows nothing. One entry per Mind (its latest), oldest first. + */ +export const readersOf = ( + events: ReceiptEvent[], + fileId: string, + fileContent: Record | undefined, + server: string +): FileReader[] => { + const sent = (fileContent?.info as Record | undefined)?.[FILE_HASH_KEY]; + if (typeof sent !== 'string') return []; + const latest = new Map(); + events + .map(receiptOf) + .filter( + (r): r is NonNullable => + !!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server) + ) + .sort((a, b) => a.ts - b.ts) + .forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts })); + return [...latest.values()].sort((a, b) => a.ts - b.ts); +}; diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts new file mode 100644 index 00000000..b39471c8 --- /dev/null +++ b/src/app/features/albums/useFileReaders.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { FILE_RECEIVED, FileReader, ReceiptEvent, readersOf } from './fileReceipt'; + +const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({ + event_id: e.getId(), + type: e.getType(), + sender: e.getSender(), + origin_server_ts: e.getTs(), + content: e.getContent(), + unsigned: e.isRedacted() ? { redacted_because: true } : undefined, +}); + +/** + * Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both + * count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`. + */ +export const useFileReaders = ( + room: Room, + fileId: string, + content: Record +): FileReader[] => { + const mx = useMatrixClient(); + const server = mx.getDomain() ?? ''; + const [readers, setReaders] = useState([]); + useEffect(() => { + const scan = () => { + const receipts = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === FILE_RECEIVED) + .map(asReceiptEvent); + const next = readersOf(receipts, fileId, content, server); + setReaders((cur) => + cur.length === next.length && + cur.every((r, i) => r.sender === next[i].sender && r.ts === next[i].ts) + ? cur + : next + ); + }; + scan(); + room.on(RoomEvent.Timeline, scan); + return () => { + room.off(RoomEvent.Timeline, scan); + }; + }, [room, fileId, content, server]); + return readers; +}; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6cce595e..e9c13e2a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -106,6 +106,7 @@ import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/mat import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { FileReadBy, isFileMessage } from '../albums/FileReadBy'; import { Image } from '../../components/media'; import { ImageViewer } from '../../components/image-viewer'; import { roomToParentsAtom } from '../../state/room/roomToParents'; @@ -1111,6 +1112,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli outlineAttachment={messageLayout === MessageLayout.Bubble} /> )} + {!mEvent.isRedacted() && isFileMessage(mEvent) && ( + + )} ); }, -- 2.47.3 From da2445044da5dd4f9427e8ceb0614802c0c74408 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 11 Nov 2026 22:34:39 +0000 Subject: [PATCH 2/2] File zone: the readers hook rescans on redaction and reset; replies guard is one local; reader order by time Soul findings 1, 2 (doc), 4 and the merge order. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone-mind-contract.md | 2 ++ src/app/features/albums/FileReadBy.tsx | 4 +++- src/app/features/albums/buildAlbums.ts | 5 +++-- src/app/features/albums/useFileReaders.ts | 6 ++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md index dd02a8fc..00f73a4a 100644 --- a/docs/file-zone-mind-contract.md +++ b/docs/file-zone-mind-contract.md @@ -90,6 +90,8 @@ what the hash and the Mind's own account show. - Files sent from the composer carry no thread relation, so they reach an album only if written into a thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain room events, and the timeline row for the file shows `read by` from them. +- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are + unencrypted and the hash is written only there, so this is the encrypted-room limit again. - The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not seen there (an album's own listing reads the thread, which is paged separately). - Encrypted zones are not listed. diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx index 0c81849a..add99658 100644 --- a/src/app/features/albums/FileReadBy.tsx +++ b/src/app/features/albums/FileReadBy.tsx @@ -16,7 +16,9 @@ type FileReadByProps = { /** "read by " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { const seen = useFileReaders(room, fileId, content); - const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()]; + const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort( + (a, b) => a.ts - b.ts + ); if (readers.length === 0) return null; return ( diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index afd764e1..6fe8a452 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -93,7 +93,8 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { const { root, replies } = input ?? {}; if (!live(root)) return undefined; const seen = new Set(); - const files = (Array.isArray(replies) ? replies : []) + const list = Array.isArray(replies) ? replies : []; + const files = list .map(toFile) .filter((f): f is AlbumFile => { if (!f || seen.has(f.id)) return false; @@ -101,7 +102,7 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { return true; }) .sort((a, b) => a.ts - b.ts) - .map((f) => ({ ...f, readBy: readersOf(replies, f.id, f.content, server) })); + .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); return { id: root.event_id, title: titleOf(root), diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts index b39471c8..76517600 100644 --- a/src/app/features/albums/useFileReaders.ts +++ b/src/app/features/albums/useFileReaders.ts @@ -41,9 +41,11 @@ export const useFileReaders = ( ); }; scan(); - room.on(RoomEvent.Timeline, scan); + // A redaction removes a tick and a reset drops the window: both rescan, not only a new event. + const events = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const; + events.forEach((ev) => room.on(ev, scan)); return () => { - room.off(RoomEvent.Timeline, scan); + events.forEach((ev) => room.off(ev, scan)); }; }, [room, fileId, content, server]); return readers; -- 2.47.3