Files desk: name loose files the album list cannot show; pin the old publisher wire shape #161
3 changed files with 213 additions and 2 deletions
|
|
@ -14,7 +14,14 @@ import { markAsRead } from '../../utils/notifications';
|
|||
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { timeDayMonYear } from '../../utils/time';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { Album, AlbumFile, lastEvent } from './buildAlbums';
|
||||
import {
|
||||
Album,
|
||||
AlbumFile,
|
||||
TimelineEvent,
|
||||
lastEvent,
|
||||
looseFiles,
|
||||
looseNoteFor,
|
||||
} from './buildAlbums';
|
||||
import { folderOf, listFolder, pathLabel } from './albumFolders';
|
||||
import { FileReadBy } from './FileReadBy';
|
||||
import { ShareSet, useShareEnabled } from './ShareSet';
|
||||
|
|
@ -106,6 +113,33 @@ function useMarkZoneRead(room: Room) {
|
|||
}, [mx, room, hideActivity]);
|
||||
}
|
||||
|
||||
/** The file events in the room's live timeline that no album thread holds, kept current as it syncs. */
|
||||
function useLooseFiles(room: Room): AlbumFile[] {
|
||||
const [, bump] = useState(0);
|
||||
useEffect(() => {
|
||||
const on = () => bump((n) => n + 1);
|
||||
room.on(RoomEvent.Timeline, on);
|
||||
room.on(RoomEvent.Redaction, on);
|
||||
return () => {
|
||||
room.off(RoomEvent.Timeline, on);
|
||||
room.off(RoomEvent.Redaction, on);
|
||||
};
|
||||
}, [room]);
|
||||
const events: TimelineEvent[] = room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.map((e) => ({
|
||||
event_id: e.getId(),
|
||||
type: e.getType(),
|
||||
sender: e.getSender(),
|
||||
origin_server_ts: e.getTs(),
|
||||
content: e.getContent(),
|
||||
unsigned: e.getUnsigned() as Record<string, unknown>,
|
||||
threadRoot: e.threadRootId,
|
||||
}));
|
||||
return looseFiles(events);
|
||||
}
|
||||
|
||||
type AlbumViewProps = { room: Room; onTimeline: () => void };
|
||||
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
||||
useMarkZoneRead(room);
|
||||
|
|
@ -117,6 +151,13 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
|||
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
|
||||
const encrypted = room.hasEncryptionStateEvent();
|
||||
const canShare = useShareEnabled(room);
|
||||
const loose = useLooseFiles(room);
|
||||
const looseNote = looseNoteFor(loose, {
|
||||
open: !!open,
|
||||
encrypted,
|
||||
loading,
|
||||
error: !!error,
|
||||
});
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading) body = <Text>Loading albums…</Text>;
|
||||
|
|
@ -206,6 +247,11 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
|||
<Scroll hideTrack visibility="Hover">
|
||||
<div className={css.Column}>
|
||||
{body}
|
||||
{looseNote && (
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{looseNote}
|
||||
</Text>
|
||||
)}
|
||||
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
|
||||
{canShare && (
|
||||
<div hidden={!!open}>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v
|
|||
|
||||
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
|
||||
export const toFile = (e: ZoneEvent): AlbumFile | undefined => {
|
||||
if (!live(e)) return undefined;
|
||||
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
|
||||
if (!kind) return undefined;
|
||||
|
|
@ -143,3 +143,42 @@ export const lastEvent = (album: Album): { id: string; ts: number } =>
|
|||
id: album.id,
|
||||
ts: album.ts,
|
||||
});
|
||||
|
||||
/** A room event with the thread root the SDK resolved for it, when it has one. */
|
||||
export type TimelineEvent = ZoneEvent & { threadRoot?: string };
|
||||
|
||||
/**
|
||||
* File events that sit in the room outside any album thread: what a poster that does not thread
|
||||
* its files leaves behind. The album list never reads them (it lists threads), so they are named
|
||||
* to the sender instead of left out silently.
|
||||
*/
|
||||
export const looseFiles = (events: TimelineEvent[]): AlbumFile[] =>
|
||||
events
|
||||
.filter((e) => {
|
||||
const rel = e?.content?.['m.relates_to'] as { rel_type?: unknown } | undefined;
|
||||
return !e?.threadRoot && rel?.rel_type !== 'm.thread';
|
||||
})
|
||||
.map(toFile)
|
||||
.filter((f): f is AlbumFile => !!f);
|
||||
|
||||
const NAMED = 3;
|
||||
|
||||
/** The line that says loose files exist and are not listed; empty when there are none. */
|
||||
export const looseNote = (files: AlbumFile[]): string => {
|
||||
if (files.length === 0) return '';
|
||||
const names = files.slice(0, NAMED).map((f) => f.name || 'unnamed');
|
||||
const more = files.length > NAMED ? ` and ${files.length - NAMED} more` : '';
|
||||
const what = files.length === 1 ? '1 file was' : `${files.length} files were`;
|
||||
return `${what} posted outside an album and ${
|
||||
files.length === 1 ? 'is' : 'are'
|
||||
} not listed here: ${names.join(', ')}${more}. ${
|
||||
files.length === 1 ? 'It is' : 'They are'
|
||||
} in the Timeline.`;
|
||||
};
|
||||
|
||||
/** Where the album view stands when it decides whether to show the loose-files note. */
|
||||
export type ViewState = { open: boolean; encrypted: boolean; loading: boolean; error: boolean };
|
||||
|
||||
/** The note for the list view only: never inside an open album, an encrypted zone, or a load that is not finished. */
|
||||
export const looseNoteFor = (files: AlbumFile[], view: ViewState): string =>
|
||||
view.open || view.encrypted || view.loading || view.error ? '' : looseNote(files);
|
||||
|
|
|
|||
126
src/app/features/albums/publisherWire.test.ts
Normal file
126
src/app/features/albums/publisherWire.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAlbums, looseFiles, looseNote, looseNoteFor, TimelineEvent } from './buildAlbums';
|
||||
|
||||
// The Commons publisher as it posted before gamecult-ops #121: loose files (no album thread), a
|
||||
// flat `org.gamecult.publish.replaces` key, no info.sha256. The sender must see that, not a
|
||||
// silently empty or silently unfolded zone.
|
||||
const MIND = '@eureka:gamecult.org';
|
||||
const file = (
|
||||
id: string,
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
ts = 1
|
||||
): TimelineEvent => ({
|
||||
event_id: id,
|
||||
type: 'm.room.message',
|
||||
sender: MIND,
|
||||
origin_server_ts: ts,
|
||||
content: { msgtype: 'm.file', body: name, filename: name, url: `mxc://x/${id}`, ...extra },
|
||||
});
|
||||
const text = (id: string, body: string): TimelineEvent => ({
|
||||
event_id: id,
|
||||
type: 'm.room.message',
|
||||
sender: MIND,
|
||||
origin_server_ts: 1,
|
||||
content: { msgtype: 'm.text', body },
|
||||
});
|
||||
|
||||
describe('loose files from a poster that does not thread', () => {
|
||||
it('are found, with the names the sender sees', () => {
|
||||
const loose = looseFiles([file('$a', 'notes.md'), file('$b', 'plan.pdf')]);
|
||||
expect(loose.map((f) => f.name)).toEqual(['notes.md', 'plan.pdf']);
|
||||
});
|
||||
|
||||
it('are not the album roots, thread replies (by SDK root or by relation), edits or redactions', () => {
|
||||
const events: TimelineEvent[] = [
|
||||
text('$root', 'handoff/eureka'),
|
||||
{ ...file('$r1', 'a.md'), threadRoot: '$root' },
|
||||
file('$r2', 'b.md', {
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
||||
}),
|
||||
file('$edit', 'c.md', { 'm.relates_to': { rel_type: 'm.replace', event_id: '$r1' } }),
|
||||
{ ...file('$gone', 'd.md'), unsigned: { redacted_because: {} } },
|
||||
file('$loose', 'e.md'),
|
||||
];
|
||||
expect(looseFiles(events).map((f) => f.id)).toEqual(['$loose']);
|
||||
});
|
||||
|
||||
it('a zone holding only loose files still has no albums, and the note says why', () => {
|
||||
const events = [file('$a', 'notes.md')];
|
||||
expect(buildAlbums([])).toEqual([]);
|
||||
expect(looseNote(looseFiles(events))).toBe(
|
||||
'1 file was posted outside an album and is not listed here: notes.md. It is in the Timeline.'
|
||||
);
|
||||
});
|
||||
|
||||
it('the note names three and counts the rest', () => {
|
||||
const many = looseFiles(['a', 'b', 'c', 'd', 'e'].map((n) => file(`$${n}`, `${n}.md`)));
|
||||
expect(looseNote(many)).toBe(
|
||||
'5 files were posted outside an album and are not listed here: a.md, b.md, c.md and 2 more. They are in the Timeline.'
|
||||
);
|
||||
expect(looseNote([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('where the loose-files note shows', () => {
|
||||
const loose = looseFiles([file('$a', 'notes.md')]);
|
||||
const list = { open: false, encrypted: false, loading: false, error: false };
|
||||
|
||||
it('on the album list, and only there', () => {
|
||||
expect(looseNoteFor(loose, list)).toContain('notes.md');
|
||||
expect(looseNoteFor(loose, { ...list, open: true })).toBe('');
|
||||
expect(looseNoteFor(loose, { ...list, encrypted: true })).toBe('');
|
||||
expect(looseNoteFor(loose, { ...list, loading: true })).toBe('');
|
||||
expect(looseNoteFor(loose, { ...list, error: true })).toBe('');
|
||||
});
|
||||
|
||||
it('says nothing when there are no loose files', () => {
|
||||
expect(looseNoteFor([], list)).toBe('');
|
||||
});
|
||||
|
||||
it('names m.notice and m.sticker posts as not files', () => {
|
||||
const notice = { ...text('$n', 'hi'), content: { msgtype: 'm.notice', body: 'hi' } };
|
||||
expect(looseFiles([notice])).toEqual([]);
|
||||
});
|
||||
|
||||
it('a file with no name reads as unnamed', () => {
|
||||
const bare: TimelineEvent = { ...file('$b', ''), content: { msgtype: 'm.file', body: '' } };
|
||||
expect(looseNote(looseFiles([bare]))).toContain('unnamed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the old wire shape inside an album', () => {
|
||||
const root = text('$root', 'handoff/eureka');
|
||||
const albumOf = (replies: TimelineEvent[]) => buildAlbums([{ root, replies }], 'gamecult.org')[0];
|
||||
|
||||
it('a flat replaces key folds nothing: both versions show, none is hidden', () => {
|
||||
const album = albumOf([
|
||||
file('$v1', 'plan.md', { 'org.gamecult.publish.sha256': 'aa' }, 1),
|
||||
file('$v2', 'plan.md', { 'org.gamecult.publish.replaces': '$v1' }, 2),
|
||||
]);
|
||||
expect(album.files.map((f) => f.id)).toEqual(['$v1', '$v2']);
|
||||
expect(album.files.every((f) => f.earlier.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('the nested shape from the fixed publisher folds as before', () => {
|
||||
const album = albumOf([
|
||||
file('$v1', 'plan.md', {}, 1),
|
||||
file('$v2', 'plan.md', { 'org.gamecult.publish': { replaces: '$v1' } }, 2),
|
||||
]);
|
||||
expect(album.files.map((f) => f.id)).toEqual(['$v2']);
|
||||
expect(album.files[0].earlier.map((f) => f.id)).toEqual(['$v1']);
|
||||
});
|
||||
|
||||
it('a file with no info.sha256 lists fine, with no readers even when a receipt names it', () => {
|
||||
const receipt: TimelineEvent = {
|
||||
event_id: '$rc',
|
||||
type: 'org.gamecult.file_received',
|
||||
sender: '@void:gamecult.org',
|
||||
origin_server_ts: 5,
|
||||
content: { 'org.gamecult.file_received': { event_id: '$v1', sha256: 'aa', size: 1 } },
|
||||
};
|
||||
const album = albumOf([file('$v1', 'plan.md'), receipt]);
|
||||
expect(album.count).toBe(1);
|
||||
expect(album.files[0].readBy).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue