Reach shared folders from Files, not the profile menu

A folder somebody shared was reachable only by switching the whole app
to their account from the profile menu -- which nobody would think to
look in for files, and which pointed mail, calendar and contacts at them
as well. The server refused all three, so nothing leaked; it was simply
the app claiming to be somewhere it could not go.

Files now lists shared accounts itself, under "Shared with me", and opens
them in place. Only Files moves: `accountId` in its store is the account
being browsed, `ownAccountId` is the reader's, and nothing else in the
app notices.

Which accounts hold shared files cannot be worked out from capabilities.
Stalwart advertises the whole set on a shared account -- mail, calendars,
contacts, sieve, the lot, identical to a personal one, whatever was
actually shared (checked live on 0.16.19, 2026-08-27). That is why
routing alone could never have fixed this, and why the list offers every
account that is not the reader's own and lets its folders answer for
themselves. The mock's shared account now advertises the same full set,
because a mock that quietly advertised only what it shared would agree
with a fix that cannot work.

Shares also went unseen until the next sign-in. They arrive in the JMAP
session, which is fetched once and refreshed only when a session-state
change is pushed to that tab -- so a share granted while the tab was open
stayed invisible, and one removed stayed on offer. That is the two
browsers disagreeing about whether an account still existed. Opening
Files now re-reads the session, throttled, and the section header carries
a refresh for when someone is waiting on a share they have just been
promised.

The sidebar's button on Files was Compose, which wrote mail from the file
manager. It uploads.

Verified against the mock, which grew a second account to make any of
this testable: "Shared with me" lists it, opening it shows its folders
and not the reader's, the header says whose they are, "Back to my files"
returns, and the profile menu is not involved at any point.
This commit is contained in:
2026-08-27 10:13:58 -07:00
parent 9f4c0c3351
commit ad94efb65b
6 changed files with 189 additions and 15 deletions
+33 -3
View File
@@ -26,6 +26,13 @@ const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1";
/** What the session advertises, matching Stalwart's own 30 days. */ /** What the session advertises, matching Stalwart's own 30 days. */
const MAX_DELAYED_SEND = 86400 * 30; const MAX_DELAYED_SEND = 86400 * 30;
const ACCOUNT = "a1"; const ACCOUNT = "a1";
/** An account somebody has shared with the demo user. See the session below. */
const SHARED_ACCOUNT = "a2";
const SHARED_CAPS: Obj = {
"urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {},
"urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {},
"urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {},
};
const USER = process.env.MOCK_USER ?? "[email protected]"; const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ /** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
@@ -197,6 +204,17 @@ const fileNodes: Obj[] = [
{ id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
]; ];
/* What the shared account holds. Its own nodes, so opening the share in Files
shows something different from the reader's own folders rather than the same
list under another name. */
const sharedFileNodes: Obj[] = [
{ id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
{ id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} },
];
/** The node list an account owns. */
const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes);
function fr() { function fr() {
return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
} }
@@ -721,6 +739,7 @@ const handlers: Record<string, Handler> = {
"ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"FileNode/query": (a) => { "FileNode/query": (a) => {
const f = (a.filter as Obj) ?? {}; const f = (a.filter as Obj) ?? {};
const fileNodes = nodesFor(a.accountId);
// `nodeType` is a filter 0.16.19 really applies -- checked live on // `nodeType` is a filter 0.16.19 really applies -- checked live on
// 2026-08-27, where it returned the two directories out of seven nodes. The // 2026-08-27, where it returned the two directories out of seven nodes. The
// mock ignoring it was worse than not having it: the sidebar tree asks for // mock ignoring it was worse than not having it: the sidebar tree asks for
@@ -732,9 +751,9 @@ const handlers: Record<string, Handler> = {
}); });
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
}, },
"FileNode/get": genericGet(fileNodes), "FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a),
"FileNode/set": (a) => { "FileNode/set": (a) => {
return genericSet(fileNodes, "f", (o) => { return genericSet(nodesFor(a.accountId), "f", (o) => {
Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o });
// Without nodeType, a node is a directory precisely when it carries no // Without nodeType, a node is a directory precisely when it carries no
// file properties. Keep it internally so query and get stay consistent. // file properties. Keep it internally so query and get stay consistent.
@@ -779,7 +798,18 @@ const session = () => ({
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" }, capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:webpush-vapid": { applicationServerKey: "BBvig2GPmqohMJJHMzp6bTKviHibYiVCyAY8gdq2fPhS-9YfO9_0TnhMyZ0a0JxTsbCqd3zm1rEiXsXsL3jveJY" },
"urn:ietf:params:jmap:emailpush": {}, "urn:ietf:params:jmap:emailpush": {},
"urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} }, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } }, /*
* Two accounts: the demo user's own, and one somebody has shared.
*
* The shared one carries the *same* capability list, because that is what
* Stalwart does -- checked on 0.16.19 (2026-08-27), where a shared account
* advertised mail, calendars, contacts and the rest, identical to a personal
* one, whatever had actually been shared. Giving the mock a truthful shared
* account is the only way to exercise the Files "Shared with me" list, and
* the only way this stays honest about what can be inferred from a
* capability, which is nothing.
*/
accounts: { [SHARED_ACCOUNT]: { name: "[email protected]", isPersonal: false, isReadOnly: false, accountCapabilities: SHARED_CAPS }, [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": {} }) } } },
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(NO_REGISTRY ? {} : { "urn:stalwart:jmap": ACCOUNT }) },
username: USER, username: USER,
apiUrl: `http://127.0.0.1:${PORT}/jmap/`, apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
+47 -4
View File
@@ -6,8 +6,26 @@ import { isAppFolder } from "@/lib/appFolder";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session"; import { useSession } from "./session";
interface SharedAccount {
id: Id;
name: string;
}
interface FilesState { interface FilesState {
/**
* The account being browsed, which is not always the reader's own.
*
* Files is the one module that opens somebody else's account in place: a
* folder shared with you is reached from "Shared with me" in the tree, not by
* switching the whole app over. So this moves and `ownAccountId` does not,
* and anything belonging to the reader -- their settings, their signatures --
* goes through `ownAccountFor` rather than either of them.
*/
accountId: Id | null; accountId: Id | null;
/** The reader's own file account, wherever they happen to be looking. */
ownAccountId: Id | null;
/** Accounts someone else has shared, from the session. */
sharedAccounts: SharedAccount[];
available: boolean; available: boolean;
nodes: Record<Id, FileNode>; nodes: Record<Id, FileNode>;
children: Record<string, Id[]>; // parentId ("root" for null) → ids children: Record<string, Id[]>; // parentId ("root" for null) → ids
@@ -32,6 +50,8 @@ interface FilesState {
draggingId: Id | null; draggingId: Id | null;
init(): Promise<void>; init(): Promise<void>;
/** Browse an account: the reader's own, or one shared with them. */
openAccount(accountId: Id | null): void;
loadChildren(parentId: Id | null): Promise<void>; loadChildren(parentId: Id | null): Promise<void>;
mkdir(parentId: Id | null, name: string): Promise<Id>; mkdir(parentId: Id | null, name: string): Promise<Id>;
upload(parentId: Id | null, files: File[]): Promise<void>; upload(parentId: Id | null, files: File[]): Promise<void>;
@@ -93,6 +113,8 @@ export function emptyForAccount(accountId: Id | null) {
export const useFiles = create<FilesState>((set, get) => ({ export const useFiles = create<FilesState>((set, get) => ({
accountId: null, accountId: null,
ownAccountId: null,
sharedAccounts: [],
available: false, available: false,
nodes: {}, nodes: {},
children: {}, children: {},
@@ -104,10 +126,31 @@ export const useFiles = create<FilesState>((set, get) => ({
draggingId: null, draggingId: null,
async init() { async init() {
const accountId = useSession.getState().accountFor(CAP.filenode); const session = useSession.getState();
const available = Boolean(accountId && client.hasCapability(CAP.filenode)); const ownAccountId = session.ownAccountFor(CAP.filenode);
if (accountId !== get().accountId) set(emptyForAccount(accountId)); const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
set({ available }); /*
* Which accounts hold shared files cannot be worked out from capabilities:
* Stalwart advertises the whole set on a shared account -- mail, calendars,
* contacts and the rest -- identical to a personal one, whatever was
* actually shared (checked on 0.16.19, 2026-08-27). So every account that
* is not the reader's own is offered, and what it really holds is settled
* by asking it for its folders and showing what comes back.
*/
const s = session.session;
const sharedAccounts = Object.entries(s?.accounts ?? {})
.filter(([, a]) => a.isPersonal === false)
.map(([id, a]) => ({ id, name: a.name }));
// Stay where the reader is if they are reading a share that still exists.
const browsing = get().accountId;
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
if (!keep) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId, sharedAccounts });
},
openAccount(accountId) {
if (accountId === get().accountId) return;
set(emptyForAccount(accountId));
}, },
/* /*
+6
View File
@@ -1024,3 +1024,9 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
.files-table tbody tr[draggable="true"]:active { cursor: grabbing; } .files-table tbody tr[draggable="true"]:active { cursor: grabbing; }
.sidebar .nav-item[draggable="true"] { cursor: pointer; } .sidebar .nav-item[draggable="true"] { cursor: pointer; }
.f-name .faint { flex: none; } .f-name .faint { flex: none; }
/* The "Shared with me" header carries a refresh control, so it is a row rather
than the plain label the other sections use. */
.sidebar .nav-section { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.spin { animation: spin 1s linear infinite; }
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
+6 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react"; import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, Sun, Upload, Users } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
@@ -114,16 +114,19 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}> <div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} /> <div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`sidebar ${drawer ? "open" : ""}`}> <aside className={`sidebar ${drawer ? "open" : ""}`}>
{/* Whatever this pane is for. Files offered Compose, which wrote mail
from the file manager and was the one thing nobody wanted there. */}
<button <button
className="compose-btn" className="compose-btn"
onClick={() => { onClick={() => {
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event")); if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact")); else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
else if (section === "files") window.dispatchEvent(new CustomEvent("ihm:files-upload"));
else openCompose(); else openCompose();
}} }}
> >
{section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />} {section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}</span> <span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
</button> </button>
<div className="sidebar-scroll"> <div className="sidebar-scroll">
{(section === "mail" || section === "search") && <MailboxTree />} {(section === "mail" || section === "search") && <MailboxTree />}
+85 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, Pencil, Share2, Trash2 } from "lucide-react"; import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, Pencil, RefreshCw, Share2, Trash2, Users } from "lucide-react";
import { useFiles } from "@/store/files"; import { useFiles } from "@/store/files";
import { useSession } from "@/store/session";
import type { FileNode, Id } from "@/jmap/types"; import type { FileNode, Id } from "@/jmap/types";
import { canDropFileNode, isShared } from "@/lib/filenode"; import { canDropFileNode, isShared } from "@/lib/filenode";
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload"; import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
@@ -11,6 +12,27 @@ import { toast } from "@/ui/toast";
import { loadRaw, saveJson } from "@/lib/storage"; import { loadRaw, saveJson } from "@/lib/storage";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
/**
* Re-read the session, so the shared accounts on offer are current.
*
* Throttled because Files is navigated to often and this is a round trip that
* tells the reader nothing new most times it runs.
*/
let lastShareRefresh = 0;
async function refreshShares(force = false): Promise<void> {
const now = Date.now();
if (!force && now - lastShareRefresh < 30_000) return;
lastShareRefresh = now;
try {
await useSession.getState().refresh();
} catch {
// The tree still lists whatever the last session said; a failed refresh is
// not worth an error over something the reader did not ask for.
return;
}
await useFiles.getState().init();
}
/** The MIME a dragged node is offered under, so a target can recognise it. */ /** The MIME a dragged node is offered under, so a target can recognise it. */
export const NODE_MIME = "application/x-ihasmail-filenode"; export const NODE_MIME = "application/x-ihasmail-filenode";
@@ -28,6 +50,11 @@ export function FilesTree() {
const treeLoaded = useFiles((s) => s.treeLoaded); const treeLoaded = useFiles((s) => s.treeLoaded);
const available = useFiles((s) => s.available); const available = useFiles((s) => s.available);
const loadTree = useFiles((s) => s.loadTree); const loadTree = useFiles((s) => s.loadTree);
const accountId = useFiles((s) => s.accountId);
const ownAccountId = useFiles((s) => s.ownAccountId);
const sharedAccounts = useFiles((s) => s.sharedAccounts);
const [refreshing, setRefreshing] = useState(false);
const viewingShare = Boolean(accountId && accountId !== ownAccountId);
// Kept across sessions, the way the mailbox tree keeps its own. // Kept across sessions, the way the mailbox tree keeps its own.
const [expanded, setExpandedState] = useState<Record<Id, boolean>>(() => loadRaw("files-expanded", {})); const [expanded, setExpandedState] = useState<Record<Id, boolean>>(() => loadRaw("files-expanded", {}));
const setExpanded = (fn: (x: Record<Id, boolean>) => Record<Id, boolean>) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; }); const setExpanded = (fn: (x: Record<Id, boolean>) => Record<Id, boolean>) => setExpandedState((x) => { const next = fn(x); saveJson("files-expanded", next); return next; });
@@ -45,6 +72,21 @@ export function FilesTree() {
if (available && !treeLoaded) void loadTree(); if (available && !treeLoaded) void loadTree();
}, [available, treeLoaded, loadTree]); }, [available, treeLoaded, loadTree]);
/*
* Ask the server what is shared, on the way in.
*
* Shared accounts arrive in the JMAP session, which is fetched at sign-in and
* refreshed only when a session-state change is pushed to this tab. A share
* granted while the tab was open therefore stayed invisible until the next
* sign-in -- and a share removed stayed on offer, which is why two browsers
* disagreed about whether an account still existed. Opening Files is the
* moment the answer matters, so that is when it is asked for.
*/
useEffect(() => {
void refreshShares();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const currentId = location.startsWith("/files/") ? location.slice("/files/".length) : null; const currentId = location.startsWith("/files/") ? location.slice("/files/".length) : null;
// Open the branch the reader is looking at, so the current folder is visible // Open the branch the reader is looking at, so the current folder is visible
@@ -139,7 +181,7 @@ export function FilesTree() {
return ( return (
<> <>
<div className="nav-section"><span>Files</span></div> <div className="nav-section"><span>{viewingShare ? "Shared folder" : "Files"}</span></div>
<div <div
className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`} className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`}
onClick={() => navigate("/files")} onClick={() => navigate("/files")}
@@ -150,10 +192,49 @@ export function FilesTree() {
> >
<span className="nav-twisty" aria-hidden="true" /> <span className="nav-twisty" aria-hidden="true" />
<HardDrive size={17} /> <HardDrive size={17} />
<span className="grow truncate">All files</span> <span className="grow truncate">{viewingShare ? sharedAccounts.find((a) => a.id === accountId)?.name ?? "Shared files" : "All files"}</span>
</div> </div>
{childrenOf(null).map((d) => row(d, 1))} {childrenOf(null).map((d) => row(d, 1))}
{treeLoaded && !dirs.length && <p className="hint" style={{ padding: "4px 12px" }}>No folders yet.</p>} {treeLoaded && !dirs.length && <p className="hint" style={{ padding: "4px 12px" }}>{viewingShare ? "Nothing shared here." : "No folders yet."}</p>}
{/* Reaching a share used to mean switching the whole app to the other
account from the profile menu, which pointed mail, calendar and
contacts at them as well. Shared folders belong here, beside your
own. */}
{(viewingShare || sharedAccounts.length > 0) && (
<>
<div className="nav-section">
<span>Shared with me</span>
<button
className="icon-btn sm"
title="Check for new shares"
aria-label="Check for new shares"
onClick={async () => { setRefreshing(true); await refreshShares(true); setRefreshing(false); }}
>
<RefreshCw size={14} className={refreshing ? "spin" : ""} />
</button>
</div>
{viewingShare && (
<div className="nav-item" onClick={() => { useFiles.getState().openAccount(ownAccountId); navigate("/files"); }}>
<span className="nav-twisty" aria-hidden="true" />
<HardDrive size={17} />
<span className="grow truncate">Back to my files</span>
</div>
)}
{sharedAccounts.map((a) => (
<div
key={a.id}
className={`nav-item ${accountId === a.id ? "active" : ""}`}
onClick={() => { useFiles.getState().openAccount(a.id); navigate("/files"); }}
>
<span className="nav-twisty" aria-hidden="true" />
<Users size={17} />
<span className="grow truncate">{a.name}</span>
</div>
))}
{!sharedAccounts.length && <p className="hint" style={{ padding: "4px 12px" }}>Nothing is shared with you.</p>}
</>
)}
<Popover anchor={menu.anchor} onClose={menu.close} width={210}> <Popover anchor={menu.anchor} onClose={menu.close} width={210}>
<MenuItem <MenuItem
+12 -1
View File
@@ -32,8 +32,19 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
useEffect(() => { useEffect(() => {
if (files.available) void files.loadChildren(parentId); if (files.available) void files.loadChildren(parentId);
// `accountId` is in here because opening a share changes which account the
// same route means: at /files the parent is null before and after, so
// without it the listing would keep showing the previous account's folder.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [files.available, parentId]); }, [files.available, files.accountId, parentId]);
// The sidebar's primary button asks for an upload here, the way it asks the
// calendar for a new event.
useEffect(() => {
const open = () => inputRef.current?.click();
window.addEventListener("ihm:files-upload", open);
return () => window.removeEventListener("ihm:files-upload", open);
}, []);
// Ensure ancestors are loaded for breadcrumbs // Ensure ancestors are loaded for breadcrumbs
useEffect(() => { useEffect(() => {