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 { Routes, Route } from "react-router-dom";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
import JoinRoom from "./pages/JoinRoom";
|
import JoinRoom from "./pages/JoinRoom";
|
||||||
|
import Share from "./pages/Share";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/share" element={<Share />} />
|
||||||
<Route path="/:code" element={<JoinRoom />} />
|
<Route path="/:code" element={<JoinRoom />} />
|
||||||
</Routes>
|
</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());
|
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 ──
|
// ── Push notifications ──
|
||||||
|
|
||||||
self.addEventListener("push", (event) => {
|
self.addEventListener("push", (event) => {
|
||||||
@ -56,13 +109,11 @@ self.addEventListener("notificationclick", (event) => {
|
|||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||||
// Focus existing tab if open
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (new URL(client.url).pathname === url && "focus" in client) {
|
if (new URL(client.url).pathname === url && "focus" in client) {
|
||||||
return client.focus();
|
return client.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise open new tab
|
|
||||||
return self.clients.openWindow(url);
|
return self.clients.openWindow(url);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -41,6 +41,22 @@ export default defineConfig({
|
|||||||
type: "image/png",
|
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: {
|
devOptions: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user