AUDIT / src /components /DownloadQueue.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
9.07 kB
// ─── DownloadQueue.tsx ────────────────────────────────────────────────────────
// S725-XLSX1: Intercetta l'evento agent:download-ready da triggerDownload().
// Su Safari iOS, a.click() in contesto async è bloccato silenziosamente.
// Questa coda mostra un bottone <a download> nel user-gesture context garantito.
// Auto-dismiss dopo 30s. Dedup: non aggiunge se url già in coda.
// FILE-6: Web Share API — bottone "Condividi" nativo iOS (AirDrop, Note, Messaggi).
// FILE-5: ascolta agent:share-file per condivisione file VFS da tool agente.
// AUT-3: ascolta agent:open-url per chip azione (Working Copy pull, ecc.).
// ─────────────────────────────────────────────────────────────────────────────
import { useEffect, useState, useCallback } from "react";
import { Z_INDEX } from "@/lib/zindex";
interface QueuedDownload { id: number; url: string; filename: string; }
interface QueuedUrl { id: number; url: string; label: string; }
const _hasShare = typeof navigator !== "undefined" && "share" in navigator;
async function _tryWebShare(url: string, filename: string): Promise<boolean> {
if (!_hasShare) return false;
try {
const res = await fetch(url);
const blob = await res.blob();
const file = new File([blob], filename, { type: blob.type || "application/octet-stream" });
const nav = navigator as Navigator & { canShare?: (d: unknown) => boolean };
if (nav.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], title: filename });
return true;
}
await navigator.share({ title: filename, text: filename });
return true;
} catch (e) {
if ((e as Error).name === "AbortError") return true;
return false;
}
}
export default function DownloadQueue() {
const [queue, setQueue] = useState<QueuedDownload[]>([]);
const [urlQueue, setUrlQueue] = useState<QueuedUrl[]>([]);
// agent:download-ready — da fileConverter.downloadBlob, export.ts, write_file, ecc.
useEffect(() => {
const handler = (e: Event) => {
const { url, filename } = (e as CustomEvent<{ url: string; filename: string }>).detail ?? {};
if (!url || !filename) return;
setQueue(prev => prev.some(q => q.url === url) ? prev : [...prev, { id: Date.now() + Math.random(), url, filename }]);
};
window.addEventListener("agent:download-ready", handler);
return () => window.removeEventListener("agent:download-ready", handler);
}, []);
// AUT-3: agent:open-url — deep link Working Copy, GitHub, ecc.
useEffect(() => {
const handler = (e: Event) => {
const { url, label } = (e as CustomEvent<{ url: string; label?: string }>).detail ?? {};
if (!url) return;
setUrlQueue(prev => prev.some(q => q.url === url) ? prev : [...prev, { id: Date.now() + Math.random(), url, label: label || "Apri" }]);
};
window.addEventListener("agent:open-url", handler);
return () => window.removeEventListener("agent:open-url", handler);
}, []);
// FILE-5: agent:share-file — dall'agente via tool share_file
useEffect(() => {
const handler = async (e: Event) => {
const { path, title } = (e as CustomEvent<{ path: string; title?: string }>).detail ?? {};
if (!path) return;
try {
const { vfsAsync } = await import("@/lib/vfsDb");
const content = await vfsAsync.read(path);
if (!content) { console.warn("share_file: file non trovato", path); return; }
const filename = title || path.split("/").pop() || "file";
const mimeMap: Record<string, string> = {
".md": "text/markdown", ".txt": "text/plain", ".json": "application/json",
".html": "text/html", ".css": "text/css", ".ts": "text/typescript",
".js": "text/javascript", ".zip": "application/zip",
};
const ext = filename.includes(".") ? "." + filename.split(".").pop()! : "";
const mime = mimeMap[ext] ?? "text/plain";
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
if (_hasShare) {
const ok = await _tryWebShare(url, filename);
if (ok) { URL.revokeObjectURL(url); return; }
}
setQueue(prev => [...prev, { id: Date.now() + Math.random(), url, filename }]);
} catch (err) { console.error("agent:share-file handler:", err); }
};
window.addEventListener("agent:share-file", handler as EventListener);
return () => window.removeEventListener("agent:share-file", handler as EventListener);
}, []);
const dismiss = useCallback((id: number) => setQueue(prev => prev.filter(q => q.id !== id)), []);
const dismissUrl = useCallback((id: number) => setUrlQueue(prev => prev.filter(q => q.id !== id)), []);
// Auto-dismiss
useEffect(() => {
if (!queue.length) return;
const t = setTimeout(() => setQueue([]), 30_000);
return () => clearTimeout(t);
}, [queue.length]);
useEffect(() => {
if (!urlQueue.length) return;
const t = setTimeout(() => setUrlQueue([]), 60_000);
return () => clearTimeout(t);
}, [urlQueue.length]);
if (!queue.length && !urlQueue.length) return null;
return (
<div style={{
position: "fixed", bottom: 72, right: 12, zIndex: Z_INDEX.CRITICAL_OVERLAY,
display: "flex", flexDirection: "column-reverse", gap: 8,
maxWidth: 320, pointerEvents: "none",
}}>
{/* AUT-3: URL action chips (Working Copy pull, ecc.) */}
{urlQueue.map(item => (
<div key={item.id} style={{
background: "rgba(17,24,39,0.97)", backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)", // B4: Safari iOS
border: "1px solid rgba(52,211,153,0.35)", borderRadius: 10,
padding: "0.5rem 0.7rem",
display: "flex", alignItems: "center", gap: 6,
boxShadow: "0 4px 24px rgba(0,0,0,0.6)",
pointerEvents: "auto",
}}>
<span style={{ fontSize: "0.75rem" }}>📂</span>
<button
onClick={() => { window.location.href = item.url; dismissUrl(item.id); }}
style={{
all: "unset" as never, cursor: "pointer", flex: 1,
fontSize: "0.72rem", color: "#6ee7b7", fontWeight: 700,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}
title={item.url}
>{item.label}</button>
<button
onClick={() => dismissUrl(item.id)}
style={{ all: "unset" as never, cursor: "pointer", color: "#5a5a72", fontSize: "0.8rem", padding: "0 2px", lineHeight: 1 }}
aria-label="Chiudi"
>✕</button>
</div>
))}
{/* Download / Share chips */}
{queue.map(item => (
<div key={item.id} style={{
background: "rgba(17,17,30,0.97)", backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)", // B4: Safari iOS
border: "1px solid rgba(124,77,255,0.4)", borderRadius: 10,
padding: "0.5rem 0.7rem",
display: "flex", alignItems: "center", gap: 6,
boxShadow: "0 4px 24px rgba(0,0,0,0.6)",
pointerEvents: "auto",
}}>
<span style={{ fontSize: "0.72rem", color: "#b8adde", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
📥 {item.filename}
</span>
{_hasShare && (
<button
onClick={async () => {
const ok = await _tryWebShare(item.url, item.filename);
if (ok) dismiss(item.id);
}}
style={{
all: "unset" as never, cursor: "pointer",
background: "rgba(52,211,153,0.15)",
border: "1px solid rgba(52,211,153,0.4)",
color: "#6ee7b7", fontSize: "0.72rem", fontWeight: 700,
padding: "3px 8px", borderRadius: 6, whiteSpace: "nowrap",
}}
title="Condividi via iOS share sheet"
>↗ Share</button>
)}
<a
href={item.url}
download={item.filename}
onClick={() => dismiss(item.id)}
style={{
all: "unset" as never, cursor: "pointer",
background: "rgba(124,77,255,0.25)",
border: "1px solid rgba(124,77,255,0.5)",
color: "#c5b8f7", fontSize: "0.72rem", fontWeight: 700,
padding: "3px 10px", borderRadius: 6, whiteSpace: "nowrap",
}}
>⬇ Salva</a>
<button
onClick={() => dismiss(item.id)}
style={{ all: "unset" as never, cursor: "pointer", color: "#5a5a72", fontSize: "0.8rem", padding: "0 2px", lineHeight: 1 }}
aria-label="Chiudi"
>✕</button>
</div>
))}
</div>
);
}