mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: reach directly shared files and keep others' files on a failed copy
Fixes from the review of the Files work (1cf9944b..a9a25b94).
- A file shared on its own, or one below a folder its owner took back,
could not be reached. Once Stalwart 1.0's placeholder folders were
left out it became a root of "Shared with me", which listed folders
only, and search opened it on the drive root. 0.16 lists such files
as roots too. Each owner's files shared on their own are now listed
on one page under "Shared with me" ("Files from <name>"), where they
open, preview and download like any other file and nothing can be
added; two of the same name get a number. Search opens a shared hit
where it is listed, and links into a shared drive name the node by id
(`/files?shared=<owner>:<id>`), so a shared folder or page also
survives a reload. A Files page that is already open takes the link
from search directly, since Next keeps it mounted when only the query
changes.
- A copy that fails part-way destroys only the nodes it made, the
deepest first and without `onDestroyRemoveChildren`, one call per
level because 0.16 refuses a folder destroyed in the same call as
its children. It used to destroy the new folder with everything
below it, so whatever someone put there during a long copy was
deleted for good. A folder holding such nodes now stays, and the
error says part of the copy was left in place.
- The document editor opens read-only on 0.16 for a sharee without the
rename right. 0.16.6 to 0.16.23 refuse any change a sharee makes
without Modify, so every save of such a document failed with a 500.
1.0, told apart by its `maxFileNodeDepth`, still asks for
`mayModifyContent` alone.
- Ctrl+C with only symlinks selected empties the clipboard instead of
keeping an earlier cut for the next paste.
- A paste reads each account's file tree once, not once per pasted
item for the listing and 1.0's depth check.
- The Stalwart bundle's README says that on older 0.16 releases and
the 1.0 pre-releases a failed update also drops the cached copy, and
the next restart or reload of the Applications downloads it again,
not only a later `UpdateApps`. The static README says a restart
applies the CORS setting on the current 1.0 pre-releases, where
`ReloadSettings` fails with the stock spam-filter rules.
This commit is contained in:
@@ -10,8 +10,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, resourceServerRef, type UploadReport } from "@/stores/file-store";
|
||||
import { fileErrorKind } from "@/lib/file-errors";
|
||||
import { useFileStore, isSharedLocation, resourceServerRef, type UploadReport } from "@/stores/file-store";
|
||||
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
@@ -34,7 +34,7 @@ import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { AlertTriangle, Loader2 } from "@/components/icons";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
|
||||
import { appPath, buildFilesPath, buildSharedFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
@@ -108,6 +108,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
undoLastAction,
|
||||
lastAction,
|
||||
shareResource,
|
||||
openSharedNode,
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -223,23 +224,40 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
return link;
|
||||
}, []);
|
||||
|
||||
// Pro shell only: this surface stays mounted for the whole session, so links
|
||||
// arriving after mount are delivered live instead of being parked forever.
|
||||
// The bootstrap has already run by then - walk the drive directly and let
|
||||
// the preview effect below claim `pendingPreviewRef` once the listing lands.
|
||||
// Embedded-only: during a cold-load redirect the standard instance renders
|
||||
// briefly and must not steal the link parked for the Pro one.
|
||||
// A node of a drive shared with the user has no path under the user's own
|
||||
// root, so its link names it by id and the store opens where it is listed.
|
||||
// False when it is gone or no longer shared.
|
||||
const openSharedLink = useCallback(async (id: string): Promise<boolean> => {
|
||||
if (await openSharedNode(id, (name) => t("shared_files_from", { name }))) return true;
|
||||
toast.error(tDeepLink(pendingPreviewRef.current !== null ? "file_not_found" : "folder_not_found"));
|
||||
// Not a file of the same name in whatever folder is open instead.
|
||||
pendingPreviewRef.current = null;
|
||||
return false;
|
||||
}, [openSharedNode, t, tDeepLink]);
|
||||
const openSharedLinkRef = useRef(openSharedLink);
|
||||
openSharedLinkRef.current = openSharedLink;
|
||||
|
||||
// Links arriving after mount are delivered live. The Pro shell keeps this
|
||||
// surface mounted for the whole session, and so does Next for the standard
|
||||
// page when a link changes only the query (`?shared=`), while the route is
|
||||
// read on mount only. The bootstrap has already run by then - walk the
|
||||
// drive directly and let the preview effect below claim `pendingPreviewRef`
|
||||
// once the listing lands. While the Pro interface is on, the standard
|
||||
// instance renders only during a cold-load redirect and must not steal the
|
||||
// link parked for the Pro one.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const navigateByPathRef = useRef(navigateByPath);
|
||||
navigateByPathRef.current = navigateByPath;
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
if (!isEmbedded && proInterfaceActive) return;
|
||||
return subscribePendingDeepLink('files', (segments, search) => {
|
||||
const link = parseFilesPath(segments, new URLSearchParams(search ?? window.location.search));
|
||||
if (!link) return;
|
||||
if (link.preview) pendingPreviewRef.current = link.preview;
|
||||
void navigateByPathRef.current(link.path);
|
||||
if (link.shared) void openSharedLinkRef.current(link.shared);
|
||||
else void navigateByPathRef.current(link.path);
|
||||
});
|
||||
}, [isEmbedded]);
|
||||
}, [isEmbedded, proInterfaceActive]);
|
||||
|
||||
// Check support and load the first listing after the client is initialized.
|
||||
// One effect, run once: `checkSupport` publishes `supportsFiles` before it
|
||||
@@ -267,7 +285,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
// listing the root first would strand the user there and rewrite the
|
||||
// URL they arrived on.
|
||||
const link = takeFilesLink();
|
||||
if (link && link.path !== '/') await navigateByPath(link.path);
|
||||
if (link?.shared && await openSharedLinkRef.current(link.shared)) return;
|
||||
if (link && !link.shared && link.path !== '/') await navigateByPath(link.path);
|
||||
else if (firstCheck) await navigate(null);
|
||||
})();
|
||||
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate, navigateByPath, takeFilesLink]);
|
||||
@@ -310,7 +329,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
// What the server said went wrong, as the second line under an action's
|
||||
// own error message; undefined when it said nothing specific.
|
||||
const failureReason = useCallback((err: unknown): string | undefined => {
|
||||
const serverReason = useCallback((err: unknown): string | undefined => {
|
||||
switch (fileErrorKind(err)) {
|
||||
case "overQuota": return t("error_over_quota");
|
||||
case "tooDeep": return t("error_too_deep");
|
||||
@@ -320,6 +339,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
default: return undefined;
|
||||
}
|
||||
}, [t]);
|
||||
// A copy that stopped part-way and could not all be taken back says so too.
|
||||
const failureReason = useCallback((err: unknown): string | undefined => {
|
||||
const reason = serverReason(err);
|
||||
if (!isPartialCopy(err)) return reason;
|
||||
return reason ? `${reason} ${t("error_partial_copy")}` : t("error_partial_copy");
|
||||
}, [serverReason, t]);
|
||||
|
||||
// An upload goes on past a file the server refuses; the refused ones are
|
||||
// reported once it is done.
|
||||
@@ -536,9 +561,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
// The permalink for the folder on screen. In the Pro shell only the focused
|
||||
// tab writes the address bar; the standard instance that renders while Pro
|
||||
// takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile));
|
||||
// A shared drive's folders and pages are linked by id (see openSharedLink).
|
||||
const currentParentId = useFileStore((s) => s.currentParentId);
|
||||
const filesLinkPath = appPath(isSharedLocation(currentParentId)
|
||||
? buildSharedFilesPath(currentParentId, previewFile)
|
||||
: buildFilesPath(currentPath, previewFile));
|
||||
useDeepLinkUrl(
|
||||
isEmbedded
|
||||
? (isFocusedProTab ? filesLinkPath : null)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
Share2,
|
||||
} from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore, type FileResource } from "@/stores/file-store";
|
||||
import { useFileStore, sharedFilesLocation, type FileResource } from "@/stores/file-store";
|
||||
|
||||
interface FolderNode {
|
||||
id: string;
|
||||
@@ -132,6 +133,21 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
onNavigate(path, id);
|
||||
}, [onNavigate]);
|
||||
|
||||
const sharedFolders = sharedRoots.filter(r => r.isDirectory);
|
||||
// Files shared on their own sit in no shared folder; each owner's are
|
||||
// listed on one page.
|
||||
const fileOwners = new Map<string, string>();
|
||||
for (const r of sharedRoots) {
|
||||
if (!r.isDirectory && r.ownerAccountId) fileOwners.set(r.ownerAccountId, r.ownerName || r.ownerAccountId);
|
||||
}
|
||||
const sharedFilePages = [...fileOwners]
|
||||
.sort(([, a], [, b]) => a.localeCompare(b))
|
||||
.map(([accountId, ownerName]) => ({
|
||||
id: sharedFilesLocation(accountId),
|
||||
name: t("shared_files_from", { name: ownerName }),
|
||||
ownerName,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -186,14 +202,14 @@ 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 && (
|
||||
{/* Shared with me: what another principal has shared with the user */}
|
||||
{(sharedFolders.length > 0 || sharedFilePages.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 => {
|
||||
{sharedFolders.map(r => {
|
||||
const path = `/${r.name}`;
|
||||
const isSelected = currentPath === path;
|
||||
return (
|
||||
@@ -217,6 +233,30 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sharedFilePages.map(page => {
|
||||
const path = `/${page.name}`;
|
||||
const isSelected = currentPath === path;
|
||||
return (
|
||||
<div
|
||||
key={page.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, page.id)}
|
||||
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
|
||||
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
|
||||
title={t("shared_by", { name: page.ownerName })}
|
||||
>
|
||||
<File className="w-4 h-4 flex-shrink-0 me-2 text-primary" />
|
||||
<span className="truncate">{page.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user