File zone: read-by receipts found anywhere in the room #107

Merged
void merged 2 commits from void/file-receipts-anywhere into dev 2026-11-11 22:39:14 +00:00
8 changed files with 303 additions and 57 deletions

View file

@ -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: <file 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,10 @@ 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.
- 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.

View file

@ -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) {
<Text size="T300" truncate>
{file.name}
</Text>
{file.readBy.length > 0 && (
<Text size="T200" className={css.Dim}>
{readLine(file.readBy, (id) => getMemberDisplayName(room, id) ?? id)}
</Text>
)}
<FileReadBy room={room} fileId={file.id} content={file.content} also={file.readBy} />
{audio ? (
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
) : (

View file

@ -0,0 +1,32 @@
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<string, unknown>;
/** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */
also?: FileReader[];
};
/** "read by <Mind>" 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()].sort(
(a, b) => a.ts - b.ts
);
if (readers.length === 0) return null;
return (
<Text size="T200" className={css.Dim}>
{readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)}
</Text>
);
}
/** 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);

View file

@ -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 (`@<mind>:<server>`, 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<typeof r> => !!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<string, unknown> | 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
@ -134,15 +93,16 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => {
const { root, replies } = input ?? {};
if (!live(root)) return undefined;
const seen = new Set<string>();
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;
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(list, f.id, f.content, server) }));
return {
id: root.event_id,
title: titleOf(root),

View file

@ -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<string, unknown> | 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']);
});
});
});

View file

@ -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<string, unknown>;
unsigned?: Record<string, unknown>;
};
/** 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<string | undefined> => {
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 (`@<mind>:<server>`, 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<string, unknown> | undefined,
server: string
): FileReader[] => {
const sent = (fileContent?.info as Record<string, unknown> | undefined)?.[FILE_HASH_KEY];
if (typeof sent !== 'string') return [];
const latest = new Map<string, FileReader>();
events
.map(receiptOf)
.filter(
(r): r is NonNullable<typeof r> =>
!!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);
};

View file

@ -0,0 +1,52 @@
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<string, unknown>
): FileReader[] => {
const mx = useMatrixClient();
const server = mx.getDomain() ?? '';
const [readers, setReaders] = useState<FileReader[]>([]);
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();
// 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 () => {
events.forEach((ev) => room.off(ev, scan));
};
}, [room, fileId, content, server]);
return readers;
};

View file

@ -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) && (
<FileReadBy room={room} fileId={mEventId} content={mEvent.getContent()} />
)}
</Message>
);
},