a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
1function urlBase64ToUint8Array(base64String) {
2 const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
3 const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
4 const raw = atob(base64);
5 const output = new Uint8Array(raw.length);
6 for (let i = 0; i < raw.length; i += 1) {
7 output[i] = raw.charCodeAt(i);
8 }
9 return output;
10}
11
12async function ensureServiceWorker(serviceWorkerUrl) {
13 if (!("serviceWorker" in navigator)) {
14 throw new Error("Service Worker not supported in this browser");
15 }
16 const registration = await navigator.serviceWorker.register(serviceWorkerUrl);
17 return registration;
18}
19
20async function getPublicKey(vapidKeyUrl) {
21 const res = await fetch(vapidKeyUrl);
22 if (!res.ok) throw new Error("Failed to load VAPID public key");
23 const data = await res.json();
24 return data.key;
25}
26
27async function showLocalNotification(title, body, iconUrl) {
28 if (!("Notification" in window) || Notification.permission !== "granted") {
29 return;
30 }
31 const registration = await navigator.serviceWorker.getRegistration();
32 if (!registration) return;
33 await registration.showNotification(title, { body, icon: iconUrl });
34}
35
36export function notificationsButton(options = {}) {
37 const {
38 className = "notifications-link",
39 iconOn = "notifications_active",
40 iconOff = "notifications",
41 titleOn = "Turn off notifications",
42 titleOff = "Turn on notifications",
43 serviceWorkerUrl = "/sw.js",
44 vapidKeyUrl = "/vapid-public-key",
45 subscribeUrl = "/subscribe",
46 unsubscribeUrl = "/unsubscribe",
47 iconUrl = "/favicon.ico",
48 welcomeTitle = "Welcome to Wiredove",
49 welcomeBody = "Your notifications are on.",
50 goodbyeTitle = "Goodbye from Wiredove!",
51 goodbyeBody = "Your notifications are off.",
52 onStatus,
53 onToggle,
54 } = options;
55
56 const button = document.createElement("a");
57 button.href = "#";
58 button.className = className;
59 button.title = titleOff;
60 button.setAttribute("aria-label", titleOff);
61
62 const icon = document.createElement("span");
63 icon.className = "material-symbols-outlined";
64 icon.setAttribute("aria-hidden", "true");
65 icon.textContent = iconOff;
66 button.appendChild(icon);
67
68 function setStatus(text) {
69 if (onStatus) onStatus(text);
70 }
71
72 function setState(enabled) {
73 button.dataset.enabled = enabled ? "true" : "false";
74 icon.textContent = enabled ? iconOn : iconOff;
75 const title = enabled ? titleOn : titleOff;
76 button.title = title;
77 button.setAttribute("aria-label", title);
78 if (onToggle) onToggle(enabled);
79 }
80
81 async function subscribe() {
82 setStatus("requesting permission");
83 const permission = await Notification.requestPermission();
84 if (permission !== "granted") {
85 setStatus("permission denied");
86 return;
87 }
88
89 const registration = await ensureServiceWorker(serviceWorkerUrl);
90 const key = await getPublicKey(vapidKeyUrl);
91 const subscription = await registration.pushManager.subscribe({
92 userVisibleOnly: true,
93 applicationServerKey: urlBase64ToUint8Array(key),
94 });
95
96 const res = await fetch(subscribeUrl, {
97 method: "POST",
98 headers: { "content-type": "application/json" },
99 body: JSON.stringify(subscription),
100 });
101
102 if (!res.ok) throw new Error("Subscribe failed");
103 setStatus("subscribed");
104 setState(true);
105 await showLocalNotification(welcomeTitle, welcomeBody, iconUrl);
106 }
107
108 async function unsubscribe() {
109 const registration = await navigator.serviceWorker.getRegistration();
110 if (!registration) {
111 setStatus("no service worker");
112 return;
113 }
114
115 const subscription = await registration.pushManager.getSubscription();
116 if (!subscription) {
117 setStatus("not subscribed");
118 return;
119 }
120
121 await subscription.unsubscribe();
122 await fetch(unsubscribeUrl, {
123 method: "POST",
124 headers: { "content-type": "application/json" },
125 body: JSON.stringify({ endpoint: subscription.endpoint }),
126 });
127
128 setStatus("unsubscribed");
129 setState(false);
130 await showLocalNotification(goodbyeTitle, goodbyeBody, iconUrl);
131 }
132
133 async function refresh() {
134 if (!("serviceWorker" in navigator)) {
135 setState(false);
136 return;
137 }
138
139 const registration = await navigator.serviceWorker.getRegistration();
140 const subscription = registration
141 ? await registration.pushManager.getSubscription()
142 : null;
143 setState(!!subscription);
144 }
145
146 button.addEventListener("click", (event) => {
147 event.preventDefault();
148 const enabled = button.dataset.enabled === "true";
149 const action = enabled ? unsubscribe : subscribe;
150 action().catch((err) => {
151 console.error(err);
152 setStatus(enabled ? "unsubscribe failed" : "subscribe failed");
153 });
154 });
155
156 button.refresh = refresh;
157 refresh().catch(() => setState(false));
158 return button;
159}