a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols

Send notifications locally on publish

+74 -163
+21 -2
composer.js
··· 7 7 import { markdown } from './markdown.js' 8 8 import { imgUpload } from './upload.js' 9 9 10 + async function pushLocalNotification({ hash, author, text }) { 11 + try { 12 + await fetch('/push-now', { 13 + method: 'POST', 14 + headers: { 'content-type': 'application/json' }, 15 + body: JSON.stringify({ 16 + hash, 17 + author, 18 + text, 19 + url: `${window.location.origin}/#${hash}`, 20 + }), 21 + }) 22 + } catch { 23 + // Notifications server might be unavailable; ignore. 24 + } 25 + } 26 + 10 27 export const composer = async (sig, options = {}) => { 11 28 const obj = {} 12 29 const isEdit = !!options.editHash && !sig ··· 78 95 await send(signed) 79 96 await send(blob) 80 97 const hash = await apds.hash(signed) 98 + pushLocalNotification({ hash, author: signed.substring(0, 44), text: blob }) 81 99 82 100 const images = blob.match(/!\[.*?\]\((.*?)\)/g) 83 101 if (images) { ··· 100 118 await render.blob(signed) 101 119 } else { 102 120 const scroller = document.getElementById('scroller') 103 - const placeholder = render.hash(hash) 121 + const opened = await apds.open(signed) 122 + const ts = opened ? opened.substring(0, 13) : Date.now().toString() 123 + const placeholder = render.insertByTimestamp(scroller, hash, ts) 104 124 if (placeholder) { 105 - scroller.insertBefore(placeholder, scroller.firstChild) 106 125 await render.blob(signed) 107 126 } 108 127 overlay.remove()
+53 -159
notifications_server.js
··· 2 2 import { apds } from 'https://esm.sh/gh/evbogue/apds@d9326cb/apds.js' 3 3 4 4 const DEFAULTS = { 5 - latestUrl: 'https://pub.wiredove.net/latest', 6 - pollMs: 15000, 7 5 dataDir: './data', 8 6 subsFile: './data/subscriptions.json', 9 7 stateFile: './data/state.json', ··· 57 55 return btoa(endpoint).replaceAll('=', '') 58 56 } 59 57 60 - async function hashText(text) { 61 - const data = new TextEncoder().encode(text) 62 - const digest = await crypto.subtle.digest('SHA-256', data) 63 - const bytes = new Uint8Array(digest) 64 - let out = '' 65 - for (const b of bytes) out += b.toString(16).padStart(2, '0') 66 - return out 67 - } 68 - 69 58 async function parsePostText(text) { 70 59 if (!text || typeof text !== 'string') return {} 71 60 ··· 125 114 async function toPushPayload(latest, pushIconUrl) { 126 115 const record = latest && typeof latest === 'object' ? latest : null 127 116 const hash = record && typeof record.hash === 'string' ? record.hash : '' 128 - const targetUrl = hash ? `https://wiredove.net/#${hash}` : 'https://wiredove.net/' 117 + const explicitUrl = record && typeof record.url === 'string' ? record.url : '' 118 + const targetUrl = explicitUrl || (hash ? `https://wiredove.net/#${hash}` : 'https://wiredove.net/') 129 119 const rawText = record && typeof record.text === 'string' ? record.text : '' 130 120 const parsed = rawText ? await parsePostText(rawText) : {} 131 121 const bodyText = parsed.body || '' ··· 142 132 }) 143 133 } 144 134 145 - function summarizeLatest(record) { 146 - const text = typeof record.text === 'string' ? record.text : '' 147 - const preview = text.length > 400 ? `${text.slice(0, 400)}…` : text 148 - return { 149 - hash: typeof record.hash === 'string' ? record.hash : undefined, 150 - author: typeof record.author === 'string' ? record.author : undefined, 151 - ts: typeof record.ts === 'string' ? record.ts : undefined, 152 - textPreview: preview || undefined, 153 - } 154 - } 155 - 156 135 export async function createNotificationsService(options = {}) { 157 136 const settings = { 158 - latestUrl: Deno.env.get('LATEST_URL') ?? DEFAULTS.latestUrl, 159 - pollMs: Number(Deno.env.get('POLL_MS') ?? DEFAULTS.pollMs), 160 137 dataDir: DEFAULTS.dataDir, 161 138 subsFile: DEFAULTS.subsFile, 162 139 stateFile: DEFAULTS.stateFile, ··· 191 168 await writeJsonFile(settings.stateFile, state) 192 169 } 193 170 194 - async function pollLatest(force = false) { 195 - try { 196 - const res = await fetch(settings.latestUrl, { cache: 'no-store' }) 197 - if (!res.ok) { 198 - console.error(`Latest fetch failed: ${res.status}`) 199 - return { changed: false, sent: false, reason: 'latest fetch failed' } 200 - } 201 - const bodyText = await res.text() 202 - if (!bodyText.trim()) { 203 - return { changed: false, sent: false, reason: 'empty response' } 204 - } 171 + async function sendPayloadToSubscriptions(payload) { 172 + const subs = await loadSubscriptions() 173 + if (subs.length === 0) { 174 + return { sent: false, reason: 'no subscriptions' } 175 + } 205 176 206 - let latestId = '' 207 - let latestJson = bodyText 208 - let latestRecord = null 177 + const now = new Date().toISOString() 178 + const nextSubs = [] 209 179 180 + for (const sub of subs) { 210 181 try { 211 - latestJson = JSON.parse(bodyText) 212 - if (Array.isArray(latestJson)) { 213 - if (latestJson.length === 0) { 214 - return { changed: false, sent: false, reason: 'empty response' } 215 - } 216 - const sorted = [...latestJson] 217 - .filter((item) => item && typeof item === 'object') 218 - .sort((a, b) => { 219 - const at = Number(a.ts ?? a.timestamp ?? 0) 220 - const bt = Number(b.ts ?? b.timestamp ?? 0) 221 - if (!Number.isNaN(bt - at)) return bt - at 222 - return 0 223 - }) 224 - latestRecord = sorted[0] ?? null 225 - } else if (latestJson && typeof latestJson === 'object') { 226 - latestRecord = latestJson 227 - } 228 - 229 - if (latestRecord) { 230 - const candidate = 231 - latestRecord.hash ?? 232 - latestRecord.sig ?? 233 - latestRecord.id ?? 234 - latestRecord.timestamp ?? 235 - latestRecord.ts 236 - if (typeof candidate === 'string' || typeof candidate === 'number') { 237 - latestId = String(candidate) 238 - } 239 - } 240 - } catch { 241 - // Non-JSON is allowed; fallback to hashing. 242 - } 243 - 244 - const state = await loadState() 245 - const latestHash = latestId ? '' : await hashText(bodyText) 246 - const latestSummary = latestRecord ? summarizeLatest(latestRecord) : undefined 247 - 248 - const isNew = latestId 249 - ? latestId !== state.lastSeenId 250 - : latestHash !== state.lastSeenHash 251 - 252 - if (!isNew && !force) { 253 - return { 254 - changed: false, 255 - sent: false, 256 - reason: 'no new messages', 257 - latest: latestSummary, 258 - } 259 - } 260 - 261 - if (isNew) { 262 - await saveState({ 263 - lastSeenId: latestId || undefined, 264 - lastSeenHash: latestHash || undefined, 265 - }) 266 - } 267 - 268 - const subs = await loadSubscriptions() 269 - if (subs.length === 0) { 270 - return { 271 - changed: true, 272 - sent: false, 273 - reason: 'no subscriptions', 274 - latest: latestSummary, 275 - } 276 - } 277 - 278 - const payload = await toPushPayload(latestRecord ?? latestJson, settings.pushIconUrl) 279 - if (!payload) { 280 - return { 281 - changed: false, 282 - sent: false, 283 - reason: 'no content', 284 - latest: latestSummary, 285 - } 286 - } 287 - const now = new Date().toISOString() 288 - const nextSubs = [] 289 - 290 - for (const sub of subs) { 291 - try { 292 - await webpush.sendNotification( 293 - { 294 - endpoint: sub.endpoint, 295 - keys: sub.keys, 296 - }, 297 - payload, 298 - ) 299 - nextSubs.push({ ...sub, lastNotifiedAt: now }) 300 - } catch (err) { 301 - const status = err && typeof err === 'object' ? err.statusCode : undefined 302 - if (status === 404 || status === 410) { 303 - console.warn(`Removing expired subscription: ${sub.id}`) 304 - continue 305 - } 306 - console.error(`Push failed for ${sub.id}`, err) 307 - nextSubs.push(sub) 182 + await webpush.sendNotification( 183 + { 184 + endpoint: sub.endpoint, 185 + keys: sub.keys, 186 + }, 187 + payload, 188 + ) 189 + nextSubs.push({ ...sub, lastNotifiedAt: now }) 190 + } catch (err) { 191 + const status = err && typeof err === 'object' ? err.statusCode : undefined 192 + if (status === 404 || status === 410) { 193 + console.warn(`Removing expired subscription: ${sub.id}`) 194 + continue 308 195 } 196 + console.error(`Push failed for ${sub.id}`, err) 197 + nextSubs.push(sub) 309 198 } 199 + } 310 200 311 - await saveSubscriptions(nextSubs) 312 - return { changed: true, sent: true, latest: latestSummary } 313 - } catch (err) { 314 - console.error('Poll error', err) 315 - return { changed: false, sent: false, reason: 'poll error' } 316 - } 201 + await saveSubscriptions(nextSubs) 202 + return { sent: true } 317 203 } 318 204 319 205 async function handleRequest(req) { ··· 365 251 return new Response('ok', { status: 200 }) 366 252 } 367 253 368 - if (req.method === 'POST' && url.pathname === '/poll-now') { 369 - const result = await pollLatest() 370 - return Response.json(result) 371 - } 254 + if (req.method === 'POST' && url.pathname === '/push-now') { 255 + const body = await req.json().catch(() => null) 256 + if (!body || typeof body !== 'object') { 257 + return Response.json({ error: 'invalid payload' }, { status: 400 }) 258 + } 259 + const record = { 260 + hash: typeof body.hash === 'string' ? body.hash : undefined, 261 + author: typeof body.author === 'string' ? body.author : undefined, 262 + text: typeof body.text === 'string' ? body.text : undefined, 263 + url: typeof body.url === 'string' ? body.url : undefined, 264 + } 372 265 373 - if (req.method === 'POST' && url.pathname === '/push-latest') { 374 - const result = await pollLatest(true) 375 - return Response.json(result) 266 + const payload = await toPushPayload(record, settings.pushIconUrl) 267 + if (!payload) { 268 + return Response.json({ sent: false, reason: 'no content' }) 269 + } 270 + 271 + if (record.hash) { 272 + await saveState({ lastSeenId: record.hash }) 273 + } 274 + 275 + const sendResult = await sendPayloadToSubscriptions(payload) 276 + return Response.json({ 277 + sent: sendResult.sent, 278 + reason: sendResult.reason, 279 + }) 376 280 } 377 281 378 282 return null 379 283 } 380 284 381 - function startPolling() { 382 - console.log(`Polling ${settings.latestUrl} every ${settings.pollMs}ms`) 383 - pollLatest() 384 - setInterval(() => { 385 - pollLatest() 386 - }, settings.pollMs) 387 - } 388 - 389 285 return { 390 286 config, 391 287 handleRequest, 392 - pollLatest, 393 - startPolling, 394 288 } 395 289 }
-2
serve.js
··· 3 3 4 4 const notifications = await createNotificationsService() 5 5 6 - notifications.startPolling() 7 - 8 6 Deno.serve(async (r) => { 9 7 const handled = await notifications.handleRequest(r) 10 8 if (handled) return handled