feat: JMAP file/folder sharing in Files app #408
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
AlertCircle, Star, Clock, FolderUp,
|
||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||
Menu,
|
||||
Menu, Users, Share2,
|
||||
} from "lucide-react";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,6 +26,9 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
|
||||
type SortKey = "name" | "size" | "modified";
|
||||
type SortDir = "asc" | "desc";
|
||||
@@ -95,6 +98,14 @@ interface FileBrowserProps {
|
||||
accountPickerMode?: boolean;
|
||||
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
||||
accountLabel?: string | null;
|
||||
/** JMAP client for the browsing account, used by the share dialog to list principals. */
|
||||
client?: IJMAPClient | null;
|
||||
/** Files account id of the browsing account; used to exclude self from the share picker. */
|
||||
ownAccountId?: string | null;
|
||||
/** True when the server supports JMAP Sharing (principals); gates the Share action. */
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
||||
@@ -230,6 +241,33 @@ function getGridIcon(resource: FileResource) {
|
||||
return getFileIconByName(resource.name, "lg");
|
||||
}
|
||||
|
||||
// Small inline indicator: a node shared out by the user (shareWith has entries)
|
||||
// or a node shared *with* the user by another principal (isShared).
|
||||
function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) {
|
||||
const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0;
|
||||
if (resource.isShared) {
|
||||
return (
|
||||
<Share2
|
||||
className="w-3.5 h-3.5 text-primary shrink-0"
|
||||
aria-label={t("shared_with_me")}
|
||||
>
|
||||
<title>{t("shared_with_me")}</title>
|
||||
</Share2>
|
||||
);
|
||||
}
|
||||
if (sharedOut) {
|
||||
return (
|
||||
<Users
|
||||
className="w-3.5 h-3.5 text-primary shrink-0"
|
||||
aria-label={t("shared")}
|
||||
>
|
||||
<title>{t("shared")}</title>
|
||||
</Users>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
|
||||
name: string;
|
||||
getImageUrl: (n: string) => Promise<string>;
|
||||
@@ -342,11 +380,25 @@ export function FileBrowser({
|
||||
onSelectAccount,
|
||||
accountPickerMode,
|
||||
accountLabel,
|
||||
client,
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
// The share dialog is bound to a node id (not name) so its shareWith stays
|
||||
// live after a share refresh re-derives the resource list.
|
||||
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
|
||||
// A node is shareable when the server supports JMAP Sharing, the viewer owns
|
||||
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
|
||||
// report full rights; treat missing myRights as allowed).
|
||||
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
|
||||
!!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)),
|
||||
[sharingEnabled, onShare, client]);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
||||
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
||||
@@ -1440,8 +1492,9 @@ export function FileBrowser({
|
||||
{showThumbnails && isImageFile(resource.name)
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
||||
: getGridIcon(resource)}
|
||||
<span className="text-xs truncate w-full text-center" title={resource.name}>
|
||||
{resource.name}
|
||||
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
|
||||
<span className="truncate">{resource.name}</span>
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -1590,6 +1643,7 @@ export function FileBrowser({
|
||||
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
||||
: getFileIcon(resource)}
|
||||
<span className="truncate">{resource.name}</span>
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
||||
@@ -1697,6 +1751,19 @@ export function FileBrowser({
|
||||
{t("duplicate")}
|
||||
</button>
|
||||
)}
|
||||
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
onClick={() => {
|
||||
const r = resources.find(res => res.name === contextMenu.name);
|
||||
if (r) setShareTargetId(r.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
{t("share")}
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
|
||||
@@ -1936,6 +2003,20 @@ export function FileBrowser({
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share dialog */}
|
||||
{shareTarget && client && onShare && (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="file"
|
||||
collectionName={shareTarget.name}
|
||||
shareWith={shareTarget.shareWith}
|
||||
ownAccountId={ownAccountId || ""}
|
||||
onShare={(principalId, rights) =>
|
||||
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
|
||||
onClose={() => setShareTargetId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Home,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
@@ -29,6 +30,8 @@ interface FolderTreeSidebarProps {
|
||||
export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, width = 256, isResizing }: FolderTreeSidebarProps) {
|
||||
const t = useTranslations("files");
|
||||
const client = useFileStore(s => s.client);
|
||||
const sharedRoots = useFileStore(s => s.sharedRoots);
|
||||
const loadSharedRoots = useFileStore(s => s.loadSharedRoots);
|
||||
const [rootChildren, setRootChildren] = useState<FolderNode[] | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(["root"]));
|
||||
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
||||
@@ -81,6 +84,8 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
loadChildren(null, "/");
|
||||
// Discover folders shared with the user by other principals.
|
||||
loadSharedRoots();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client]);
|
||||
@@ -180,6 +185,40 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Shared with me: folders another principal has shared with the user */}
|
||||
{sharedRoots.filter(r => r.isDirectory).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
<div className="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
|
||||
<Share2 className="w-3 h-3" />
|
||||
<span className="truncate">{t("shared_with_me")}</span>
|
||||
</div>
|
||||
{sharedRoots.filter(r => r.isDirectory).map(r => {
|
||||
const path = `/${r.name}`;
|
||||
const isSelected = currentPath === path;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)" }}
|
||||
className={cn(
|
||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 px-2",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick(path, r.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user