File zone: read-by receipts found anywhere in the room #107
8 changed files with 303 additions and 57 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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} />} />
|
||||
) : (
|
||||
|
|
|
|||
32
src/app/features/albums/FileReadBy.tsx
Normal file
32
src/app/features/albums/FileReadBy.tsx
Normal 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);
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
52
src/app/features/albums/useFileReaders.ts
Normal file
52
src/app/features/albums/useFileReaders.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue