feat(files): download from anywhere — a link box, a pasted link, the palette; the running downloads in a tray

- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one
  app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses
  the box on its history entry.
- The download tray, bottom right on every page: what waits, runs, is paused or failed (reason +
  Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until
  closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the
  work changes or something finishes. Back to top steps up over it, the page end keeps clear.
- A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts
  and notices may carry a second action).
- The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id.
- GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with
  each row's source key.
This commit is contained in:
2026-10-11 16:56:13 +02:00
parent d22c64f4dd
commit 1c4f0042d2
42 changed files with 1509 additions and 162 deletions
+50 -34
View File
@@ -8,9 +8,9 @@ import {
Bell,
Check,
CheckCheck,
Download,
ExternalLink,
Eye,
FolderOpen,
RotateCcw,
Search,
Trash2,
@@ -45,6 +45,7 @@ import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./navigationContext";
import { useFilesActions } from "./filesContext";
import { useChannelsActions } from "./channelsContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { SURFACE } from "./ui/surfaces";
@@ -71,6 +72,7 @@ const byId = (n: { id: number }) => n.id;
export default function NotificationsPanel() {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
const { openFile } = useFilesActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in
@@ -184,7 +186,12 @@ export default function NotificationsPanel() {
const serverMenu = useContextMenu<AppNotification>({ keyOf: byId });
const clientMenu = useContextMenu<ClientNotif>({ keyOf: byId });
const openScheduler = () => setPage("scheduler");
const openDownloads = () => setPage("downloads");
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
const openDone = (n: AppNotification) => {
const { job_id: id, folder_id: folder } = n.data ?? {};
if (typeof id === "number") openFile(id, typeof folder === "number" ? folder : null);
else setPage("files");
};
const reviewAccess = () => {
focusAccessRequestsTab();
setPage("users");
@@ -213,9 +220,9 @@ export default function NotificationsPanel() {
? [
{
id: "open",
label: t("inbox.openDownloads"),
icon: icon(Download),
onSelect: openDownloads,
label: t("inbox.openFile"),
icon: icon(FolderOpen),
onSelect: () => openDone(n),
},
]
: []),
@@ -280,17 +287,15 @@ export default function NotificationsPanel() {
onSelect: reviewAccess,
},
]
: n.action && !n.dismissed
? [
{
id: "action",
label: n.action.label,
onSelect: () => {
n.action!.onClick();
dismissClient(n.id);
},
: !n.dismissed
? liveActions(n).map((action, i) => ({
id: `action${i}`,
label: action.label,
onSelect: () => {
action.onClick();
dismissClient(n.id);
},
]
}))
: [];
return menuGroups(own, [dismissRow(() => removeClient(n.id))]);
}
@@ -391,7 +396,7 @@ export default function NotificationsPanel() {
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenDownloads={openDownloads}
onOpenFile={() => openDone(n)}
/>
);
})}
@@ -438,6 +443,11 @@ export default function NotificationsPanel() {
);
}
/** A client notice's live actions (they do not survive a reload): its action, then the lesser one. */
function liveActions(n: ClientNotif): { label: string; onClick: () => void }[] {
return [n.action, n.secondaryAction].filter((a) => a !== undefined);
}
/** A server notification's link, from its type and typed payload (the row's button and its menu). */
// The download-lifecycle notification types → their `inbox.<key>` copy.
const DOWNLOAD_LIFECYCLE: Record<string, string | undefined> = {
@@ -500,7 +510,7 @@ function NotificationRow({
onRead,
onDismiss,
onOpenScheduler,
onOpenDownloads,
onOpenFile,
}: {
n: AppNotification;
t: TFunction;
@@ -510,7 +520,7 @@ function NotificationRow({
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenDownloads: () => void;
onOpenFile: () => void;
}) {
const lang = useTranslation().i18n.language;
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
@@ -609,10 +619,10 @@ function NotificationRow({
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenDownloads}
icon={<Download className="w-3.5 h-3.5" />}
onClick={onOpenFile}
icon={<FolderOpen className="w-3.5 h-3.5" />}
>
{t("inbox.openDownloads")}
{t("inbox.openFile")}
</Button>
)}
</div>
@@ -706,19 +716,25 @@ function ClientActivityRow({
{t("common.accessRequestsReview")}
</Button>
) : (
n.action &&
!n.dismissed && (
<Button
variant="link"
className="mt-1 text-sm"
data-nav-open=""
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
>
{n.action.label}
</Button>
!n.dismissed &&
liveActions(n).length > 0 && (
<div className="mt-1 flex flex-wrap gap-x-4">
{liveActions(n).map((action, i) => (
<Button
key={action.label}
variant="link"
className="text-sm"
// The first is the row's own: Enter on the row runs it.
data-nav-open={i === 0 ? "" : undefined}
onClick={() => {
action.onClick();
dismissClient(n.id);
}}
>
{action.label}
</Button>
))}
</div>
)
)}
</div>