feat: push notifications + iOS share sheet integration
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 37s
- Web Push API for offline device notifications (VAPID) - Custom service worker with push + share target handlers - iOS/Android share sheet support via Web Share Target API - Dedicated /share page with one-tap send to nearby peer - Background tab notifications for incoming transfers - Persistent deviceId per device
This commit is contained in:
parent
fd249abbf1
commit
0034e91672
@ -1,11 +1,13 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import JoinRoom from "./pages/JoinRoom";
|
||||
import Share from "./pages/Share";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/share" element={<Share />} />
|
||||
<Route path="/:code" element={<JoinRoom />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
157
web/src/pages/Share.tsx
Normal file
157
web/src/pages/Share.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useSignaling } from "../hooks/useSignaling";
|
||||
import { useStore } from "../stores/useStore";
|
||||
import { useProfileStore } from "../stores/useProfileStore";
|
||||
import PeerList from "../components/PeerList";
|
||||
import ProfileSetup from "../components/ProfileSetup";
|
||||
|
||||
interface SharedData {
|
||||
files: File[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Read shared files/text from the cache stashed by the service worker */
|
||||
async function readSharedData(): Promise<SharedData> {
|
||||
const result: SharedData = { files: [], text: "" };
|
||||
|
||||
try {
|
||||
const cache = await caches.open("share-target");
|
||||
|
||||
// Read metadata
|
||||
const metaResponse = await cache.match("/share-target-meta");
|
||||
if (!metaResponse) return result;
|
||||
|
||||
const meta = await metaResponse.json();
|
||||
result.text = meta.text || "";
|
||||
|
||||
// Read files
|
||||
for (let i = 0; i < (meta.count || 0); i++) {
|
||||
const fileResponse = await cache.match(`/share-target-file/${i}`);
|
||||
if (!fileResponse) continue;
|
||||
|
||||
const blob = await fileResponse.blob();
|
||||
const fileName = decodeURIComponent(
|
||||
fileResponse.headers.get("X-File-Name") || `fichier-${i}`,
|
||||
);
|
||||
result.files.push(new File([blob], fileName, { type: blob.type }));
|
||||
}
|
||||
|
||||
// Clean up cache
|
||||
await caches.delete("share-target");
|
||||
} catch (err) {
|
||||
console.error("[share] Failed to read shared data:", err);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function Share() {
|
||||
const isSetUp = useProfileStore((s) => s.isSetUp);
|
||||
|
||||
if (!isSetUp) {
|
||||
return <ProfileSetup onDone={() => {}} />;
|
||||
}
|
||||
|
||||
return <ShareConnected />;
|
||||
}
|
||||
|
||||
function ShareConnected() {
|
||||
const { sendFiles, sendText } = useSignaling();
|
||||
const peers = useStore((s) => s.peers);
|
||||
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
||||
|
||||
const [shared, setShared] = useState<SharedData | null>(null);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
readSharedData().then(setShared);
|
||||
}, []);
|
||||
|
||||
const handlePeerSelect = useCallback(
|
||||
(peerId: string) => {
|
||||
if (!shared || sent) return;
|
||||
|
||||
setSelectedPeerId(peerId);
|
||||
|
||||
if (shared.files.length > 0) {
|
||||
sendFiles(peerId, shared.files);
|
||||
}
|
||||
if (shared.text && shared.files.length === 0) {
|
||||
sendText(peerId, shared.text);
|
||||
}
|
||||
|
||||
setSent(true);
|
||||
},
|
||||
[shared, sent, sendFiles, sendText, setSelectedPeerId],
|
||||
);
|
||||
|
||||
const fileCount = shared?.files.length || 0;
|
||||
const hasText = !!shared?.text;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
||||
<div className="max-w-lg mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<header className="text-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-white mb-1">
|
||||
Any<span className="text-brand-400">Drop</span>
|
||||
</h1>
|
||||
|
||||
{!shared ? (
|
||||
<p className="text-slate-500 text-sm mt-4">Chargement...</p>
|
||||
) : sent ? (
|
||||
<div className="mt-6">
|
||||
<div className="text-4xl mb-3">✓</div>
|
||||
<p className="text-brand-400 font-medium">Envoi en cours</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block mt-4 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-slate-400 text-sm mt-4">Envoyer à quel appareil ?</p>
|
||||
|
||||
{/* What's being shared */}
|
||||
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 text-xs text-slate-400">
|
||||
{fileCount > 0 && (
|
||||
<span>
|
||||
📎 {fileCount} {fileCount > 1 ? "fichiers" : "fichier"}
|
||||
</span>
|
||||
)}
|
||||
{hasText && !fileCount && <span>💬 Texte</span>}
|
||||
{hasText && fileCount > 0 && <span>+ texte</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Peer list — tap to send immediately */}
|
||||
{!sent && shared && (
|
||||
<section>
|
||||
{peers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<div className="text-5xl mb-4">📡</div>
|
||||
<p className="text-lg font-medium">En attente d'appareils...</p>
|
||||
<p className="text-sm mt-2 text-center max-w-xs">
|
||||
Ouvrez AnyDrop sur l'appareil destinataire.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-center gap-6 py-8">
|
||||
<PeerList onPeerSelect={handlePeerSelect} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="text-center text-xs text-slate-600 mt-12">
|
||||
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,59 @@ self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// ── Share Target handler ──
|
||||
// iOS/Android share sheet sends a POST to /share with files as multipart/form-data.
|
||||
// We intercept it, stash the files in a temporary cache, and redirect to the share page.
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (url.pathname === "/share" && event.request.method === "POST") {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const formData = await event.request.formData();
|
||||
const files = formData.getAll("files") as File[];
|
||||
const text = formData.get("text") as string | null;
|
||||
const sharedUrl = formData.get("url") as string | null;
|
||||
|
||||
// Build the shared text (combine text + url if both present)
|
||||
let sharedText = "";
|
||||
if (text) sharedText += text;
|
||||
if (sharedUrl) sharedText += (sharedText ? "\n" : "") + sharedUrl;
|
||||
|
||||
// Stash files in Cache Storage so the page can retrieve them
|
||||
if (files.length > 0) {
|
||||
const cache = await caches.open("share-target");
|
||||
// Store each file as a cache entry keyed by index
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const response = new Response(files[i], {
|
||||
headers: {
|
||||
"Content-Type": files[i].type || "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent(files[i].name),
|
||||
},
|
||||
});
|
||||
await cache.put(`/share-target-file/${i}`, response);
|
||||
}
|
||||
// Store the count
|
||||
await cache.put(
|
||||
"/share-target-meta",
|
||||
new Response(JSON.stringify({ count: files.length, text: sharedText })),
|
||||
);
|
||||
} else if (sharedText) {
|
||||
const cache = await caches.open("share-target");
|
||||
await cache.put(
|
||||
"/share-target-meta",
|
||||
new Response(JSON.stringify({ count: 0, text: sharedText })),
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to the share page (GET) — the page will read from cache
|
||||
return Response.redirect("/share", 303);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Push notifications ──
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
@ -56,13 +109,11 @@ self.addEventListener("notificationclick", (event) => {
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||
// Focus existing tab if open
|
||||
for (const client of clients) {
|
||||
if (new URL(client.url).pathname === url && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise open new tab
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
|
||||
@ -41,6 +41,22 @@ export default defineConfig({
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: "/share",
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
title: "title",
|
||||
text: "text",
|
||||
url: "url",
|
||||
files: [
|
||||
{
|
||||
name: "files",
|
||||
accept: ["*/*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user