Files desk cut 1: folders by album title path, share into the current folder #147
4 changed files with 262 additions and 16 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
),
|
||||
})
|
||||
|
|
|
|||
133
src/app/features/albums/albumFolders.test.ts
Normal file
133
src/app/features/albums/albumFolders.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/app/features/albums/albumFolders.ts
Normal file
73
src/app/features/albums/albumFolders.ts
Normal 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(' / ');
|
||||
Loading…
Reference in a new issue