tangled
alpha
login
or
join now
evbogue.com
/
wiredove
1
fork
atom
a demonstration replicated social networking web app built with anproto
wiredove.net/
social
ed25519
protocols
1
fork
atom
overview
issues
pulls
pipelines
Send notifications locally on publish
Everett Bogue
2 months ago
53dde6ac
2f2c52af
+74
-163
3 changed files
expand all
collapse all
unified
split
composer.js
notifications_server.js
serve.js
+21
-2
composer.js
···
7
7
import { markdown } from './markdown.js'
8
8
import { imgUpload } from './upload.js'
9
9
10
10
+
async function pushLocalNotification({ hash, author, text }) {
11
11
+
try {
12
12
+
await fetch('/push-now', {
13
13
+
method: 'POST',
14
14
+
headers: { 'content-type': 'application/json' },
15
15
+
body: JSON.stringify({
16
16
+
hash,
17
17
+
author,
18
18
+
text,
19
19
+
url: `${window.location.origin}/#${hash}`,
20
20
+
}),
21
21
+
})
22
22
+
} catch {
23
23
+
// Notifications server might be unavailable; ignore.
24
24
+
}
25
25
+
}
26
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
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
103
-
const placeholder = render.hash(hash)
121
121
+
const opened = await apds.open(signed)
122
122
+
const ts = opened ? opened.substring(0, 13) : Date.now().toString()
123
123
+
const placeholder = render.insertByTimestamp(scroller, hash, ts)
104
124
if (placeholder) {
105
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
5
-
latestUrl: 'https://pub.wiredove.net/latest',
6
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
60
-
async function hashText(text) {
61
61
-
const data = new TextEncoder().encode(text)
62
62
-
const digest = await crypto.subtle.digest('SHA-256', data)
63
63
-
const bytes = new Uint8Array(digest)
64
64
-
let out = ''
65
65
-
for (const b of bytes) out += b.toString(16).padStart(2, '0')
66
66
-
return out
67
67
-
}
68
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
128
-
const targetUrl = hash ? `https://wiredove.net/#${hash}` : 'https://wiredove.net/'
117
117
+
const explicitUrl = record && typeof record.url === 'string' ? record.url : ''
118
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
145
-
function summarizeLatest(record) {
146
146
-
const text = typeof record.text === 'string' ? record.text : ''
147
147
-
const preview = text.length > 400 ? `${text.slice(0, 400)}…` : text
148
148
-
return {
149
149
-
hash: typeof record.hash === 'string' ? record.hash : undefined,
150
150
-
author: typeof record.author === 'string' ? record.author : undefined,
151
151
-
ts: typeof record.ts === 'string' ? record.ts : undefined,
152
152
-
textPreview: preview || undefined,
153
153
-
}
154
154
-
}
155
155
-
156
135
export async function createNotificationsService(options = {}) {
157
136
const settings = {
158
158
-
latestUrl: Deno.env.get('LATEST_URL') ?? DEFAULTS.latestUrl,
159
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
194
-
async function pollLatest(force = false) {
195
195
-
try {
196
196
-
const res = await fetch(settings.latestUrl, { cache: 'no-store' })
197
197
-
if (!res.ok) {
198
198
-
console.error(`Latest fetch failed: ${res.status}`)
199
199
-
return { changed: false, sent: false, reason: 'latest fetch failed' }
200
200
-
}
201
201
-
const bodyText = await res.text()
202
202
-
if (!bodyText.trim()) {
203
203
-
return { changed: false, sent: false, reason: 'empty response' }
204
204
-
}
171
171
+
async function sendPayloadToSubscriptions(payload) {
172
172
+
const subs = await loadSubscriptions()
173
173
+
if (subs.length === 0) {
174
174
+
return { sent: false, reason: 'no subscriptions' }
175
175
+
}
205
176
206
206
-
let latestId = ''
207
207
-
let latestJson = bodyText
208
208
-
let latestRecord = null
177
177
+
const now = new Date().toISOString()
178
178
+
const nextSubs = []
209
179
180
180
+
for (const sub of subs) {
210
181
try {
211
211
-
latestJson = JSON.parse(bodyText)
212
212
-
if (Array.isArray(latestJson)) {
213
213
-
if (latestJson.length === 0) {
214
214
-
return { changed: false, sent: false, reason: 'empty response' }
215
215
-
}
216
216
-
const sorted = [...latestJson]
217
217
-
.filter((item) => item && typeof item === 'object')
218
218
-
.sort((a, b) => {
219
219
-
const at = Number(a.ts ?? a.timestamp ?? 0)
220
220
-
const bt = Number(b.ts ?? b.timestamp ?? 0)
221
221
-
if (!Number.isNaN(bt - at)) return bt - at
222
222
-
return 0
223
223
-
})
224
224
-
latestRecord = sorted[0] ?? null
225
225
-
} else if (latestJson && typeof latestJson === 'object') {
226
226
-
latestRecord = latestJson
227
227
-
}
228
228
-
229
229
-
if (latestRecord) {
230
230
-
const candidate =
231
231
-
latestRecord.hash ??
232
232
-
latestRecord.sig ??
233
233
-
latestRecord.id ??
234
234
-
latestRecord.timestamp ??
235
235
-
latestRecord.ts
236
236
-
if (typeof candidate === 'string' || typeof candidate === 'number') {
237
237
-
latestId = String(candidate)
238
238
-
}
239
239
-
}
240
240
-
} catch {
241
241
-
// Non-JSON is allowed; fallback to hashing.
242
242
-
}
243
243
-
244
244
-
const state = await loadState()
245
245
-
const latestHash = latestId ? '' : await hashText(bodyText)
246
246
-
const latestSummary = latestRecord ? summarizeLatest(latestRecord) : undefined
247
247
-
248
248
-
const isNew = latestId
249
249
-
? latestId !== state.lastSeenId
250
250
-
: latestHash !== state.lastSeenHash
251
251
-
252
252
-
if (!isNew && !force) {
253
253
-
return {
254
254
-
changed: false,
255
255
-
sent: false,
256
256
-
reason: 'no new messages',
257
257
-
latest: latestSummary,
258
258
-
}
259
259
-
}
260
260
-
261
261
-
if (isNew) {
262
262
-
await saveState({
263
263
-
lastSeenId: latestId || undefined,
264
264
-
lastSeenHash: latestHash || undefined,
265
265
-
})
266
266
-
}
267
267
-
268
268
-
const subs = await loadSubscriptions()
269
269
-
if (subs.length === 0) {
270
270
-
return {
271
271
-
changed: true,
272
272
-
sent: false,
273
273
-
reason: 'no subscriptions',
274
274
-
latest: latestSummary,
275
275
-
}
276
276
-
}
277
277
-
278
278
-
const payload = await toPushPayload(latestRecord ?? latestJson, settings.pushIconUrl)
279
279
-
if (!payload) {
280
280
-
return {
281
281
-
changed: false,
282
282
-
sent: false,
283
283
-
reason: 'no content',
284
284
-
latest: latestSummary,
285
285
-
}
286
286
-
}
287
287
-
const now = new Date().toISOString()
288
288
-
const nextSubs = []
289
289
-
290
290
-
for (const sub of subs) {
291
291
-
try {
292
292
-
await webpush.sendNotification(
293
293
-
{
294
294
-
endpoint: sub.endpoint,
295
295
-
keys: sub.keys,
296
296
-
},
297
297
-
payload,
298
298
-
)
299
299
-
nextSubs.push({ ...sub, lastNotifiedAt: now })
300
300
-
} catch (err) {
301
301
-
const status = err && typeof err === 'object' ? err.statusCode : undefined
302
302
-
if (status === 404 || status === 410) {
303
303
-
console.warn(`Removing expired subscription: ${sub.id}`)
304
304
-
continue
305
305
-
}
306
306
-
console.error(`Push failed for ${sub.id}`, err)
307
307
-
nextSubs.push(sub)
182
182
+
await webpush.sendNotification(
183
183
+
{
184
184
+
endpoint: sub.endpoint,
185
185
+
keys: sub.keys,
186
186
+
},
187
187
+
payload,
188
188
+
)
189
189
+
nextSubs.push({ ...sub, lastNotifiedAt: now })
190
190
+
} catch (err) {
191
191
+
const status = err && typeof err === 'object' ? err.statusCode : undefined
192
192
+
if (status === 404 || status === 410) {
193
193
+
console.warn(`Removing expired subscription: ${sub.id}`)
194
194
+
continue
308
195
}
196
196
+
console.error(`Push failed for ${sub.id}`, err)
197
197
+
nextSubs.push(sub)
309
198
}
199
199
+
}
310
200
311
311
-
await saveSubscriptions(nextSubs)
312
312
-
return { changed: true, sent: true, latest: latestSummary }
313
313
-
} catch (err) {
314
314
-
console.error('Poll error', err)
315
315
-
return { changed: false, sent: false, reason: 'poll error' }
316
316
-
}
201
201
+
await saveSubscriptions(nextSubs)
202
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
368
-
if (req.method === 'POST' && url.pathname === '/poll-now') {
369
369
-
const result = await pollLatest()
370
370
-
return Response.json(result)
371
371
-
}
254
254
+
if (req.method === 'POST' && url.pathname === '/push-now') {
255
255
+
const body = await req.json().catch(() => null)
256
256
+
if (!body || typeof body !== 'object') {
257
257
+
return Response.json({ error: 'invalid payload' }, { status: 400 })
258
258
+
}
259
259
+
const record = {
260
260
+
hash: typeof body.hash === 'string' ? body.hash : undefined,
261
261
+
author: typeof body.author === 'string' ? body.author : undefined,
262
262
+
text: typeof body.text === 'string' ? body.text : undefined,
263
263
+
url: typeof body.url === 'string' ? body.url : undefined,
264
264
+
}
372
265
373
373
-
if (req.method === 'POST' && url.pathname === '/push-latest') {
374
374
-
const result = await pollLatest(true)
375
375
-
return Response.json(result)
266
266
+
const payload = await toPushPayload(record, settings.pushIconUrl)
267
267
+
if (!payload) {
268
268
+
return Response.json({ sent: false, reason: 'no content' })
269
269
+
}
270
270
+
271
271
+
if (record.hash) {
272
272
+
await saveState({ lastSeenId: record.hash })
273
273
+
}
274
274
+
275
275
+
const sendResult = await sendPayloadToSubscriptions(payload)
276
276
+
return Response.json({
277
277
+
sent: sendResult.sent,
278
278
+
reason: sendResult.reason,
279
279
+
})
376
280
}
377
281
378
282
return null
379
283
}
380
284
381
381
-
function startPolling() {
382
382
-
console.log(`Polling ${settings.latestUrl} every ${settings.pollMs}ms`)
383
383
-
pollLatest()
384
384
-
setInterval(() => {
385
385
-
pollLatest()
386
386
-
}, settings.pollMs)
387
387
-
}
388
388
-
389
285
return {
390
286
config,
391
287
handleRequest,
392
392
-
pollLatest,
393
393
-
startPolling,
394
288
}
395
289
}
-2
serve.js
···
3
3
4
4
const notifications = await createNotificationsService()
5
5
6
6
-
notifications.startPolling()
7
7
-
8
6
Deno.serve(async (r) => {
9
7
const handled = await notifications.handleRequest(r)
10
8
if (handled) return handled