Files desk cut 1: folders by album title path, share into the current folder #147

Merged
void merged 2 commits from void/files-desk-view into dev 2026-11-13 21:46:38 +00:00
4 changed files with 262 additions and 16 deletions

View file

@ -15,6 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { folderOf, listFolder, pathLabel } from './albumFolders';
import { FileReadBy } from './FileReadBy';
import { ShareSet, useShareEnabled } from './ShareSet';
import { useAlbums } from './useAlbums';
@ -91,6 +92,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
const { loading, error, albums } = useAlbums(room);
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
const [openId, setOpenId] = useState<string>();
const [path, setPath] = useState<string[]>([]);
const open: Album | undefined = albums.find((a) => a.id === openId);
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
const encrypted = room.hasEncryptionStateEvent();
@ -103,8 +105,17 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
body = (
<>
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
<Text size="B400">‹ Albums</Text>
<button
type="button"
className={css.Back}
onClick={() => {
setPath(folderOf(open));
setOpenId(undefined);
}}
>
<Text size="B400">{`‹ ${
folderOf(open).length ? pathLabel(folderOf(open)) : 'Albums'
}`}</Text>
</button>
<Text size="H4">{open.title}</Text>
<Text size="T200" className={css.Dim}>
@ -120,16 +131,43 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
);
} else if (encrypted)
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
else if (albums.length === 0 && path.length === 0) body = <Text>No albums yet.</Text>;
else {
body = albums.map((a) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{a.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
));
const here = listFolder(albums, path);
body = (
<>
{path.length > 0 && (
<button type="button" className={css.Back} onClick={() => setPath(path.slice(0, -1))}>
<Text size="B400">{`‹ ${
path.length > 1 ? pathLabel(path.slice(0, -1)) : 'Albums'
}`}</Text>
</button>
)}
{path.length > 0 && <Text size="H4">{pathLabel(path)}</Text>}
{here.folders.map((f) => (
<button
key={`d:${f.name}`}
type="button"
className={css.Row}
onClick={() => setPath(f.path)}
>
<Text size="T400">{`${f.name} /`}</Text>
<Text size="T200" className={css.Dim}>
{f.albums} {f.albums === 1 ? 'album' : 'albums'} · {timeDayMonYear(f.ts, dateFormat)}
</Text>
</button>
))}
{here.albums.map(({ album: a, leaf }) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{leaf}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
))}
{here.folders.length === 0 && here.albums.length === 0 && <Text>Nothing here.</Text>}
</>
);
}
return (
@ -139,7 +177,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
{canShare && (
<div hidden={!!open}>
<ShareSet room={room} />
<ShareSet room={room} folder={path} />
</div>
)}
<button type="button" className={css.Row} onClick={onTimeline}>

View file

@ -9,6 +9,7 @@ import { safeFile } from '../../utils/mimeTypes';
import { getMsgContent } from '../room/msgContent';
import { FILE_HASH_KEY, hashForSend } from './fileReceipt';
import { ShareController, ShareState, createShareController } from './shareController';
import { titleIn } from './albumFolders';
import { DEFAULT_TITLE, sendShareSet } from './shareSet';
import * as css from './AlbumView.css';
@ -100,14 +101,15 @@ export function ShareSetView({
);
}
export function ShareSet({ room }: { room: Room }) {
/** `folder` is the path the share lands in: its albums are titled under it (docs/commons-files-desk.md). */
export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] }) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const input = useRef<HTMLInputElement>(null);
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
// rerender with a new client or Room object must not build another. Live values go through `live`.
const live = useRef({ mx, room, queryClient });
live.current = { mx, room, queryClient };
const live = useRef({ mx, room, queryClient, folder });
live.current = { mx, room, queryClient, folder };
const [controller]: [ShareController<File>, unknown] = useState(() =>
createShareController<File>({
roomId: room.roomId,
@ -140,7 +142,7 @@ export function ShareSet({ room }: { room: Room }) {
},
onFile,
},
title,
titleIn(live.current.folder, title, DEFAULT_TITLE),
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
),
})

View file

@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import { buildAlbums } from './buildAlbums';
import type { Album, ZoneEvent } from './buildAlbums';
import { folderOf, listFolder, splitPath, titleIn } from './albumFolders';
import { DEFAULT_TITLE, sendShareSet } from './shareSet';
const album = (id: string, title: string, ts = 1): Album => ({
id,
title,
author: '@a:x',
ts,
files: [],
count: 0,
});
const names = (l: ReturnType<typeof listFolder>) => ({
folders: l.folders.map((f) => `${f.name}:${f.albums}`),
albums: l.albums.map((a) => `${a.album.id}=${a.leaf}`),
});
describe('splitPath', () => {
it('drops empty segments and padding; dots are names, not navigation', () => {
expect(splitPath(' a // b /')).toEqual(['a', 'b']);
expect(splitPath('../x')).toEqual(['..', 'x']);
expect(splitPath('///')).toEqual([]);
});
});
describe('listFolder', () => {
const all = [
album('$1', 'handoff/2026-11/notes', 10),
album('$2', 'handoff/2026-11/plan', 30),
album('$3', 'handoff/readme', 20),
album('$4', 'cast-art', 5),
album('$5', 'Plain album', 40),
];
it('root: first segments become folders (counted below, newest first); single-segment titles are albums', () => {
expect(names(listFolder(all, []))).toEqual({
folders: ['handoff:3'],
albums: ['$5=Plain album', '$4=cast-art'],
});
});
it('nested: a folder shows its sub-folders and its own albums under their leaf names', () => {
expect(names(listFolder(all, ['handoff']))).toEqual({
folders: ['2026-11:2'],
albums: ['$3=readme'],
});
expect(names(listFolder(all, ['handoff', '2026-11']))).toEqual({
folders: [],
albums: ['$2=plan', '$1=notes'],
});
});
it('an album titled exactly like a folder is not hidden by it', () => {
const l = listFolder([album('$a', 'x'), album('$b', 'x/y')], []);
expect(names(l)).toEqual({ folders: ['x:1'], albums: ['$a=x'] });
expect(names(listFolder([album('$a', 'x'), album('$b', 'x/y')], ['x']))).toEqual({
folders: [],
albums: ['$b=y'],
});
});
it('a path that exists nowhere lists nothing; the prefix match is by whole segment', () => {
expect(names(listFolder(all, ['hand']))).toEqual({ folders: [], albums: [] });
});
it('an untitled or all-slash album stays at the root, nowhere else', () => {
const odd = [album('$u', '///')];
expect(names(listFolder(odd, []))).toEqual({ folders: [], albums: ['$u=///'] });
expect(names(listFolder(odd, ['x']))).toEqual({ folders: [], albums: [] });
expect(names(listFolder([album('$w', ' ')], []))).toEqual({
folders: [],
albums: ['$w=Untitled album'],
});
});
it('server-supplied names like "constructor" are folders, not prototype hits', () => {
expect(names(listFolder([album('$c', 'constructor/a')], []))).toEqual({
folders: ['constructor:1'],
albums: [],
});
});
});
describe('titleIn and folderOf', () => {
it('prefixes the folder, keeps a typed sub-path, falls back to the default title', () => {
expect(titleIn([], '', DEFAULT_TITLE)).toBe(DEFAULT_TITLE);
expect(titleIn([], ' Mine ', DEFAULT_TITLE)).toBe('Mine');
expect(titleIn(['uploads', 'me'], '', DEFAULT_TITLE)).toBe(`uploads/me/${DEFAULT_TITLE}`);
expect(titleIn(['uploads'], 'a/b', DEFAULT_TITLE)).toBe('uploads/a/b');
});
it('folderOf is the title path without its leaf', () => {
expect(folderOf(album('$', 'a/b/c'))).toEqual(['a', 'b']);
expect(folderOf(album('$', 'c'))).toEqual([]);
});
});
describe('a share into a folder, read back as the viewer reads it', () => {
it('posts a root titled under the folder, and buildAlbums + listFolder put it in that folder', async () => {
const sent: Record<string, unknown>[] = [];
const res = await sendShareSet(
{
send: async (c) => {
sent.push(c);
return `$e${sent.length}`;
},
contentOf: async (f) => ({ msgtype: 'm.file', body: f.name, filename: f.name }),
},
titleIn(['uploads', 'meta'], 'Photos', DEFAULT_TITLE),
[{ name: 'a.png', mimeType: 'image/png' }]
);
expect(res.sent).toBe(1);
expect(sent[0]).toEqual({ msgtype: 'm.text', body: 'uploads/meta/Photos' });
const asEvent = (c: Record<string, unknown>, id: string): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@a:x',
origin_server_ts: 1,
content: c,
});
const albums = buildAlbums([
{ root: asEvent(sent[0], '$e1'), replies: [asEvent(sent[1], '$e2')] },
]);
expect(names(listFolder(albums, []))).toEqual({ folders: ['uploads:1'], albums: [] });
expect(names(listFolder(albums, ['uploads', 'meta']))).toEqual({
folders: [],
albums: ['$e1=Photos'],
});
});
});

View file

@ -0,0 +1,73 @@
// Pure: folders are albums whose title is a `/`-separated path (docs/commons-files-desk.md, "Folders").
// No wire change: the album is still one thread root; the folder tree is only how the list is grouped.
import { Album, UNTITLED } from './buildAlbums';
/** A path as segments. Empty segments and surrounding space drop out; `.` and `..` are ordinary names, never navigation. */
export const splitPath = (title: string): string[] =>
title
.split('/')
.map((s) => s.trim())
.filter(Boolean);
export type FolderEntry = {
name: string;
/** Segments from the root to this folder. */
path: string[];
/** Albums at any depth below. */
albums: number;
/** The newest album ts below, for ordering. */
ts: number;
};
export type AlbumEntry = { album: Album; leaf: string };
export type FolderListing = { folders: FolderEntry[]; albums: AlbumEntry[] };
const startsWith = (path: string[], prefix: string[]) => prefix.every((seg, i) => path[i] === seg);
/**
* What one folder shows: its direct sub-folders and the albums titled exactly at its path plus a leaf.
* An album titled `x` and a folder `x/` coexist at the same level; neither hides the other. Newest first, both lists.
*/
export const listFolder = (albums: readonly Album[], at: string[]): FolderListing => {
const folders = new Map<string, FolderEntry>();
const direct: AlbumEntry[] = [];
albums.forEach((album) => {
const segs = splitPath(album.title);
if (segs.length === 0) {
// An untitled or all-slash title lives at the root under the title as written.
if (at.length === 0) direct.push({ album, leaf: album.title.trim() || UNTITLED });
return;
}
if (!startsWith(segs, at)) return;
// An album titled exactly at this path sits in the parent, not inside the folder it shares a name with.
if (segs.length <= at.length) return;
if (segs.length === at.length + 1) {
direct.push({ album, leaf: segs[segs.length - 1] });
return;
}
const name = segs[at.length];
const f = folders.get(name) ?? { name, path: [...at, name], albums: 0, ts: 0 };
f.albums += 1;
f.ts = Math.max(f.ts, album.ts);
folders.set(name, f);
});
return {
folders: [...folders.values()].sort((a, b) => b.ts - a.ts),
albums: direct.sort((a, b) => b.album.ts - a.album.ts),
};
};
/** The folder an album sits in: its title path without the leaf. */
export const folderOf = (album: Album): string[] => splitPath(album.title).slice(0, -1);
/**
* The title a share sends into `folder`: the folder path then what the sender typed. The typed title
* may itself contain `/`, which just goes deeper. An empty typed title falls back to `fallback`.
*/
export const titleIn = (folder: string[], typed: string, fallback: string): string => {
const leaf = splitPath(typed).join('/') || fallback;
return [...folder, leaf].join('/');
};
export const pathLabel = (path: string[]): string => path.join(' / ');