diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index dbf7543cf..0592e4bfd 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -220,6 +220,7 @@ [data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"], [data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"], [data-boot-settings-tab="files"] [data-sk-settings-tab="files"], + [data-boot-settings-tab="files_sync"] [data-sk-settings-tab="files_sync"], [data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"], [data-boot-settings-tab="offline"] [data-sk-settings-tab="offline"], [data-boot-settings-tab="privacy"] [data-sk-settings-tab="privacy"], @@ -239,6 +240,7 @@ @custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *)); @custom-variant boot-no-settings-offline (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="offline"]) *)); @custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *)); +@custom-variant boot-settings-files-sync (&:where([data-boot-settings-tabs~="files_sync"] *)); /* Settings pages with one section have no section jump row (SectionNav). */ @custom-variant boot-settings-one-section (&:where([data-boot-settings-tab="security"] *, [data-boot-settings-tab="filters"] *, [data-boot-settings-tab="vacation"] *, [data-boot-settings-tab="recovery"] *, [data-boot-settings-tab="addons"] *)); diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx index c49515063..82477c6bb 100644 --- a/components/boot-skeleton/settings-skeleton.tsx +++ b/components/boot-skeleton/settings-skeleton.tsx @@ -27,6 +27,8 @@ const OPTIONAL: Partial> = { calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden", contacts: "boot-no-settings-contacts:hidden", files: "boot-no-files:hidden boot-no-settings-files:hidden", + // Desktop app only, while the files sync runs. + files_sync: "hidden boot-settings-files-sync:flex", addons: "boot-no-settings-addons:hidden", // Not in the static Lite build. offline: "boot-no-settings-offline:hidden", diff --git a/components/files-sync/files-sync-prompts.tsx b/components/files-sync/files-sync-prompts.tsx new file mode 100644 index 000000000..fdf938506 --- /dev/null +++ b/components/files-sync/files-sync-prompts.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useTranslations } from "next-intl"; +import { AlertTriangle } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { useFilesSyncStore } from "@/stores/files-sync-store"; +import type { FilesRoot } from "@/lib/desktop/types"; +import type { FilesSyncRootStatus } from "@/lib/files-sync/types"; +import { rootName } from "./files-sync-text"; + +/* + * The mass-delete question (FILES-SYNC-BRIEF: more than 50 items or more than half a + * pair at once pauses the pair and asks). The settings page asks it in the folder's + * row; everywhere else FilesSyncPrompts asks it in a dialog, so the user sees it + * wherever they are. + */ + +let inlineHosts = 0; +const inlineListeners = new Set<() => void>(); +const subscribeInline = (listener: () => void) => { + inlineListeners.add(listener); + return () => { inlineListeners.delete(listener); }; +}; +const inlineShown = () => inlineHosts > 0; + +/** The settings page asks in its rows while it's open: the dialog stays away meanwhile. */ +export function useInlineMassDeleteQuestions(): void { + useEffect(() => { + inlineHosts += 1; + inlineListeners.forEach((listener) => listener()); + return () => { + inlineHosts -= 1; + inlineListeners.forEach((listener) => listener()); + }; + }, []); +} + +/** The question's text, for a root in `confirm-delete`. */ +export function useMassDeleteText(root: FilesRoot, massDelete: NonNullable): string { + const t = useTranslations("files_sync"); + // The side the items went from: this computer's folder, or the Files folder. + return massDelete.side === "local" + ? t("mass_delete.local", { count: massDelete.count, folder: rootName(root, t("drive.root_name")) }) + : t("mass_delete.remote", { count: massDelete.count, folder: root.remoteLabel || t("drive.root_name") }); +} + +interface AnswerButtonsProps { + rootId: string; + count: number; + /** After an answer went out. */ + onAnswered?: () => void; +} + +/** "Delete them" and "Bring them back", straight to the engine. */ +export function MassDeleteAnswers({ rootId, count, onAnswered }: AnswerButtonsProps) { + const t = useTranslations("files_sync"); + const controller = useFilesSyncStore((s) => s.controller); + const [busy, setBusy] = useState(false); + const answer = (allow: boolean) => { + if (!controller) return; + setBusy(true); + void controller.confirmMassDelete(rootId, allow).catch(() => {}).finally(() => { + setBusy(false); + onAnswered?.(); + }); + }; + return ( + <> + + + + ); +} + +/** The question inside a folder's row on the settings page. */ +export function MassDeleteQuestion({ root, status }: { root: FilesRoot; status: FilesSyncRootStatus }) { + const massDelete = status.massDelete ?? { count: 0, side: "local" as const }; + const text = useMassDeleteText(root, massDelete); + return ( +
+

+

+
+ +
+
+ ); +} + +function PromptDialog({ root, status, onLater }: { root: FilesRoot; status: FilesSyncRootStatus; onLater: () => void }) { + const t = useTranslations("files_sync"); + const massDelete = status.massDelete!; + const text = useMassDeleteText(root, massDelete); + return ( + +