diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..7edd011 --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,65 @@ +// Service worker for Clicked push notifications. +// Scope: / (entire origin). +// Registered by src/hooks/usePushSubscription.ts after the user grants permission. + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +// Push handler — shows a content-free notification. +// The push payload may carry { conversationId } for routing on click, +// but no message text is ever displayed. +self.addEventListener('push', (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + // malformed payload — show a generic notification + } + + const conversationId = data.conversationId ?? null; + + event.waitUntil( + self.registration.showNotification('Clicked', { + body: 'You have a new message', + icon: '/icons/icon-192.png', + badge: '/icons/badge-96.png', + tag: conversationId ? `conv-${conversationId}` : 'new-message', + renotify: true, + data: { conversationId }, + }), + ); +}); + +// Notification click — focus an open window or open a new one, +// then tell the client to sync the relevant conversation. +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const conversationId = event.notification.data?.conversationId ?? null; + const target = conversationId + ? `/app/conversations/${conversationId}` + : '/app/messages'; + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((windowClients) => { + for (const client of windowClients) { + if (new URL(client.url).origin === self.location.origin) { + // postMessage triggers navigation in the React router; focus brings + // the tab to the front. navigate() is available only on controlled + // clients, so we guard before calling it. + client.postMessage({ type: 'sw:sync', conversationId }); + client.focus(); + return; + } + } + return self.clients.openWindow(target); + }), + ); +}); diff --git a/apps/web/src/app/app/layout.tsx b/apps/web/src/app/app/layout.tsx index 175275a..09b35ce 100644 --- a/apps/web/src/app/app/layout.tsx +++ b/apps/web/src/app/app/layout.tsx @@ -1,9 +1,10 @@ 'use client'; -import React, { useState } from 'react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useWallet } from '@/contexts/WalletContext'; +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useWallet } from "@/contexts/WalletContext"; +import { PushPermissionPrompt } from "@/components/PushPermissionPrompt"; // Custom premium SVG Icons to avoid dependency weight const LogoIcon = () => ( @@ -151,9 +152,29 @@ const NavItem: React.FC = ({ href, label, icon, active }) => { export default function AppLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const router = useRouter(); const { publicKey, connect, disconnect } = useWallet(); const [isConnecting, setIsConnecting] = useState(false); + // Listen for sw:sync messages from the service worker (notification click). + // Navigate to the conversation so the page re-fetches fresh data. + useEffect(() => { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return; + + function onSwMessage(event: MessageEvent<{ type: string; conversationId?: string | null }>) { + if (event.data?.type !== "sw:sync") return; + const { conversationId } = event.data; + if (conversationId) { + router.push(`/app/conversations/${conversationId}`); + } else { + router.push("/app/messages"); + } + } + + navigator.serviceWorker.addEventListener("message", onSwMessage); + return () => navigator.serviceWorker.removeEventListener("message", onSwMessage); + }, [router]); + const handleWalletAction = async () => { if (publicKey) { disconnect(); @@ -248,6 +269,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
{children}
+ + {/* Contextual push permission prompt — shown 5 s after the user enters + the app, suppressed on denial or dismissal. */} + ); } diff --git a/apps/web/src/components/PushPermissionPrompt.tsx b/apps/web/src/components/PushPermissionPrompt.tsx new file mode 100644 index 0000000..87e1b8d --- /dev/null +++ b/apps/web/src/components/PushPermissionPrompt.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "@/components/auth/useAuth"; +import { usePushSubscription } from "@/hooks/usePushSubscription"; + +const DISMISSED_KEY = "clicked.push.dismissed"; + +// Shown contextually inside the authenticated app shell, not on first page load. +// Appears 5 seconds after the component mounts (i.e. after the user navigates +// into the app), and is suppressed once the user dismisses or grants permission. +export function PushPermissionPrompt() { + const { token } = useAuth(); + const { permission, subscribed, requestSubscription } = usePushSubscription(token); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!token) return; + if (permission !== "default") return; + if (typeof sessionStorage !== "undefined" && sessionStorage.getItem(DISMISSED_KEY)) return; + + const timer = setTimeout(() => setVisible(true), 5000); + return () => clearTimeout(timer); + }, [token, permission]); + + function dismiss() { + setVisible(false); + sessionStorage.setItem(DISMISSED_KEY, "1"); + } + + async function enable() { + await requestSubscription(); + setVisible(false); + } + + // Hide when not visible, permission already decided, or already subscribed. + if (!visible || permission !== "default" || subscribed) return null; + + return ( +
+
+

Enable notifications

+

+ Get alerted when a new message arrives, even when the tab is in the background. +

+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/hooks/usePushSubscription.ts b/apps/web/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..69cfbb7 --- /dev/null +++ b/apps/web/src/hooks/usePushSubscription.ts @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { API_BASE_URL } from "@/lib/api"; + +// Loaded at build time — must be set in the environment. +const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? ""; + +// Web Push requires the VAPID key as a Uint8Array in base64url encoding. +function vapidKeyToUint8Array(base64url: string): Uint8Array { + const padding = "=".repeat((4 - (base64url.length % 4)) % 4); + const base64 = (base64url + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const bytes = new Uint8Array(new ArrayBuffer(raw.length)); + for (let i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + return bytes; +} + +async function postSubscription(sub: PushSubscription, token: string): Promise { + const json = sub.toJSON(); + await fetch(`${API_BASE_URL}/push/subscriptions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + endpoint: json.endpoint, + keys: json.keys, + }), + }); +} + +export type PushSubscriptionState = { + // The current Notification.permission value — 'default' | 'granted' | 'denied'. + permission: NotificationPermission; + // True once the subscription has been posted to the server. + subscribed: boolean; + // Call this to request permission and subscribe. Safe to call multiple times. + requestSubscription: () => Promise; +}; + +export function usePushSubscription(token: string | null): PushSubscriptionState { + const [registration, setRegistration] = useState(null); + const [permission, setPermission] = useState(() => { + if (typeof window !== "undefined" && "Notification" in window) { + return Notification.permission; + } + return "default"; + }); + const [subscribed, setSubscribed] = useState(false); + + // Register the service worker once on mount. + useEffect(() => { + if ( + typeof window === "undefined" || + !("serviceWorker" in navigator) || + !("PushManager" in window) + ) { + return; + } + + let active = true; + navigator.serviceWorker.register("/sw.js").then((reg) => { + if (active) setRegistration(reg); + }); + + return () => { + active = false; + }; + }, []); + + // Re-use an existing subscription if one already exists. + useEffect(() => { + if (!registration || !token || !VAPID_PUBLIC_KEY) return; + if (Notification.permission !== "granted") return; + + let active = true; + registration.pushManager.getSubscription().then((existing) => { + if (!active || !existing) return; + setSubscribed(true); + // Ensure server has this subscription (idempotent POST). + postSubscription(existing, token).catch(() => {}); + }); + + return () => { + active = false; + }; + }, [registration, token]); + + const requestSubscription = useCallback(async () => { + if (!registration || !token || !VAPID_PUBLIC_KEY) return; + + const result = await Notification.requestPermission(); + setPermission(result); + if (result !== "granted") return; + + // Reuse an existing subscription to avoid double-posting. + let sub = await registration.pushManager.getSubscription(); + if (!sub) { + sub = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidKeyToUint8Array(VAPID_PUBLIC_KEY), + }); + } + + await postSubscription(sub, token); + setSubscribed(true); + }, [registration, token]); + + return { permission, subscribed, requestSubscription }; +}