Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
29 changes: 27 additions & 2 deletions apps/web/src/app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"use client";

import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
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 = () => (
Expand Down Expand Up @@ -156,9 +157,29 @@ const NavItem: React.FC<NavItemProps> = ({ 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();
Expand Down Expand Up @@ -255,6 +276,10 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
{children}
</main>
</div>

{/* Contextual push permission prompt — shown 5 s after the user enters
the app, suppressed on denial or dismissal. */}
<PushPermissionPrompt />
</div>
);
}
66 changes: 66 additions & 0 deletions apps/web/src/components/PushPermissionPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="alert"
className="fixed bottom-6 left-1/2 z-50 flex w-full max-w-sm -translate-x-1/2 items-start gap-4 rounded-2xl border border-border bg-card p-4 shadow-xl"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground">Enable notifications</p>
<p className="mt-0.5 text-xs text-foreground/60">
Get alerted when a new message arrives, even when the tab is in the background.
</p>
</div>
<div className="flex shrink-0 flex-col gap-2">
<button
onClick={enable}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-light"
>
Enable
</button>
<button
onClick={dismiss}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-foreground/50 transition-colors hover:text-foreground"
>
Not now
</button>
</div>
</div>
);
}
110 changes: 110 additions & 0 deletions apps/web/src/hooks/usePushSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"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);
return new Uint8Array([...raw].map((c) => c.charCodeAt(0)));
}

async function postSubscription(sub: PushSubscription, token: string): Promise<void> {
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<void>;
};

export function usePushSubscription(token: string | null): PushSubscriptionState {
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
const [permission, setPermission] = useState<NotificationPermission>(() => {
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 };
}
Loading