Merge pull request #95 from LINUXexpert-org/shared-with-me
Reach shared folders from Files, not the profile menu
This commit is contained in:
@@ -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. */
|
||||
const MAX_DELAYED_SEND = 86400 * 30;
|
||||
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]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
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: "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() {
|
||||
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: [] }; },
|
||||
"FileNode/query": (a) => {
|
||||
const f = (a.filter as Obj) ?? {};
|
||||
const fileNodes = nodesFor(a.accountId);
|
||||
// `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
|
||||
// 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 };
|
||||
},
|
||||
"FileNode/get": genericGet(fileNodes),
|
||||
"FileNode/get": (a) => genericGet(nodesFor(a.accountId))(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 });
|
||||
// Without nodeType, a node is a directory precisely when it carries no
|
||||
// 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" },
|
||||
"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": {} },
|
||||
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 }) },
|
||||
username: USER,
|
||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||
|
||||
+47
-4
@@ -6,8 +6,26 @@ import { isAppFolder } from "@/lib/appFolder";
|
||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "./session";
|
||||
|
||||
interface SharedAccount {
|
||||
id: Id;
|
||||
name: string;
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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;
|
||||
nodes: Record<Id, FileNode>;
|
||||
children: Record<string, Id[]>; // parentId ("root" for null) → ids
|
||||
@@ -32,6 +50,8 @@ interface FilesState {
|
||||
draggingId: Id | null;
|
||||
|
||||
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>;
|
||||
mkdir(parentId: Id | null, name: string): Promise<Id>;
|
||||
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) => ({
|
||||
accountId: null,
|
||||
ownAccountId: null,
|
||||
sharedAccounts: [],
|
||||
available: false,
|
||||
nodes: {},
|
||||
children: {},
|
||||
@@ -104,10 +126,31 @@ export const useFiles = create<FilesState>((set, get) => ({
|
||||
draggingId: null,
|
||||
|
||||
async init() {
|
||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
||||
const available = Boolean(accountId && client.hasCapability(CAP.filenode));
|
||||
if (accountId !== get().accountId) set(emptyForAccount(accountId));
|
||||
set({ available });
|
||||
const session = useSession.getState();
|
||||
const ownAccountId = session.ownAccountFor(CAP.filenode);
|
||||
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
|
||||
/*
|
||||
* 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));
|
||||
},
|
||||
|
||||
/*
|
||||
|
||||
@@ -1024,3 +1024,9 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
||||
.files-table tbody tr[draggable="true"]:active { cursor: grabbing; }
|
||||
.sidebar .nav-item[draggable="true"] { cursor: pointer; }
|
||||
.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; } }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
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 { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
@@ -114,16 +114,19 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div className={`app-body ${collapsed && !isMobile ? "collapsed" : ""}`}>
|
||||
<div className={`drawer-backdrop ${drawer ? "open" : ""}`} onClick={() => setDrawer(false)} />
|
||||
<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
|
||||
className="compose-btn"
|
||||
onClick={() => {
|
||||
if (section === "calendar") window.dispatchEvent(new CustomEvent("ihm:new-event"));
|
||||
else if (section === "contacts") window.dispatchEvent(new CustomEvent("ihm:new-contact"));
|
||||
else if (section === "files") window.dispatchEvent(new CustomEvent("ihm:files-upload"));
|
||||
else openCompose();
|
||||
}}
|
||||
>
|
||||
{section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : "Compose"}</span>
|
||||
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
|
||||
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
|
||||
</button>
|
||||
<div className="sidebar-scroll">
|
||||
{(section === "mail" || section === "search") && <MailboxTree />}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { useSession } from "@/store/session";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
@@ -11,6 +12,27 @@ import { toast } from "@/ui/toast";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
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. */
|
||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||
|
||||
@@ -28,6 +50,11 @@ export function FilesTree() {
|
||||
const treeLoaded = useFiles((s) => s.treeLoaded);
|
||||
const available = useFiles((s) => s.available);
|
||||
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.
|
||||
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; });
|
||||
@@ -45,6 +72,21 @@ export function FilesTree() {
|
||||
if (available && !treeLoaded) void 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;
|
||||
|
||||
// Open the branch the reader is looking at, so the current folder is visible
|
||||
@@ -139,7 +181,7 @@ export function FilesTree() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="nav-section"><span>Files</span></div>
|
||||
<div className="nav-section"><span>{viewingShare ? "Shared folder" : "Files"}</span></div>
|
||||
<div
|
||||
className={`nav-item ${currentId === null ? "active" : ""} ${rootDrop ? "drop-target" : ""}`}
|
||||
onClick={() => navigate("/files")}
|
||||
@@ -150,10 +192,49 @@ export function FilesTree() {
|
||||
>
|
||||
<span className="nav-twisty" aria-hidden="true" />
|
||||
<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>
|
||||
{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}>
|
||||
<MenuItem
|
||||
|
||||
@@ -32,8 +32,19 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user