From e916a0fffee14e3735c3ab4bba36d76e7773f54b Mon Sep 17 00:00:00 2001 From: phil Date: Sun, 6 Jul 2025 16:14:47 -0400 Subject: [PATCH] put notifications in indexeddb to show on page --- .../public/service-worker.js | 70 ++++++++++++++++-- atproto-notifications/src/App.tsx | 2 + atproto-notifications/src/components/Feed.tsx | 71 +++++++++++++++++++ server/index.js | 9 +-- 4 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 atproto-notifications/src/components/Feed.tsx diff --git a/atproto-notifications/public/service-worker.js b/atproto-notifications/public/service-worker.js index e3a2842..a82f72f 100644 --- a/atproto-notifications/public/service-worker.js +++ b/atproto-notifications/public/service-worker.js @@ -1,9 +1,71 @@ self.addEventListener('push', handlePush); -function handlePush(event) { - const { title, body } = event.data.json(); - // const icon = '/images/icon.png'; +const getDB = ((upgrade, v) => { + let instance; + return () => { + if (instance) return instance; + const req = indexedDB.open('atproto-notifs', v); + instance = new Promise((resolve, reject) => { + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => upgrade(req.result); + req.onsuccess = () => resolve(req.result); + }); + return instance; + }; +})(function dbUpgrade(db) { + try { + db.deleteObjectStore('notifs'); + } catch (e) {} + db.createObjectStore('notifs', { + key: 'id', + autoIncrement: true, + }); +}, 2); + +const push = async notif => { + const tx = (await getDB()).transaction('notifs', 'readwrite'); + return new Promise((resolve, reject) => { + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + tx.objectStore('notifs').put(notif); + }); +}; + +async function handlePush(event) { + const { subject, source, source_record } = event.data.json(); + + let icon; + if (source.startsWith('app.bsky')) icon = '/icons/app.bsky.png'; + + let title = { + 'app.bsky.graph.follow:subject': 'New follow', + 'app.bsky.feed.like:subject.uri': 'New like 💜', + }[source] ?? source; + // const tag = 'simple-push-demo-notification-tag'; - event.waitUntil(self.registration.showNotification(title, { body })); // TODO: resubscribe to notifs to try to stay alive + + let db; + try { + db = await getDB(); + } catch (e) { + console.error('oh no', e); + throw e; + } + db.onerror = e => { + console.error('db errored', e); + }; + + try { + await push({ subject, source, source_record }); + } catch (e) { + console.error('uh oh', e); + } + + new BroadcastChannel('notif').postMessage('heyyy'); + + event.waitUntil(self.registration.showNotification(title, { + icon, + body: source_record, + })); } diff --git a/atproto-notifications/src/App.tsx b/atproto-notifications/src/App.tsx index ef97989..9439f74 100644 --- a/atproto-notifications/src/App.tsx +++ b/atproto-notifications/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from 'react'; import { useLocalStorage } from "@uidotdev/usehooks"; import { HostContext } from './context' import { WhoAmI } from './components/WhoAmI'; +import { Feed } from './components/Feed'; import { urlBase64ToUint8Array } from './utils'; import './App.css' @@ -120,6 +121,7 @@ function App() { @{user.handle}

+ ); } diff --git a/atproto-notifications/src/components/Feed.tsx b/atproto-notifications/src/components/Feed.tsx new file mode 100644 index 0000000..53ff2d6 --- /dev/null +++ b/atproto-notifications/src/components/Feed.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; + +const getDB = ((upgrade, v) => { + let instance; + return () => { + if (instance) return instance; + const req = indexedDB.open('atproto-notifs', v); + instance = new Promise((resolve, reject) => { + req.onerror = () => reject(req.error); + req.onupgradeneeded = () => upgrade(req.result); + req.onsuccess = () => resolve(req.result); + }); + return instance; + }; +})(function dbUpgrade(db) { + try { + db.deleteObjectStore('notifs'); + } catch (e) {} + db.createObjectStore('notifs', { + key: 'id', + autoIncrement: true, + }); +}, 2); + +const getNotifs = async (limit = 30) => { + let res = []; + const oc = (await getDB()) + .transaction(['notifs']) + .objectStore('notifs') + .openCursor(undefined, 'prev'); + return new Promise((resolve, reject) => { + oc.onerror = () => reject(oc.error); + oc.onsuccess = ev => { + const cursor = event.target.result; + if (cursor) { + res.push([cursor.key, cursor.value]); + if (res.length < limit) cursor.continue(); + else resolve(res); + } else { + resolve(res); + } + } + }); +}; + +export function Feed() { + + // for now, we just increment a counter when a new notif comes in, which forces a re-render + const [inc, setInc] = useState(0); + useEffect(() => { + const handleMessage = () => setInc(n => n + 1); + const chan = new BroadcastChannel('notif'); + chan.addEventListener('message', handleMessage); + return () => chan.removeEventListener('message', handleMessage); + }); + + // semi-gross way to just pull out all the events so we can see them + // this could be combined with the broadcast thing above, but for now just chain deps + const [feed, setFeed] = useState([]); + useEffect(() => { + (async () => setFeed((await getNotifs())))(); + }, [inc]); + + if (feed.length === 0) { + return 'no notifications loaded'; + } + return feed.map(([k, n]) => ( +

{k}: {n.source} ({n.source_record}) {JSON.stringify(n)}

+ )); + +} diff --git a/server/index.js b/server/index.js index 2127038..0e5f72a 100755 --- a/server/index.js +++ b/server/index.js @@ -70,11 +70,8 @@ const handleDust = async event => { } const expiredSubs = []; - for (const sub of subs.get(did) ?? []) { - const title = `new ${source}`; - const body = `from ${source_record}`; - try { - await webpush.sendNotification(sub, JSON.stringify({ title, body })); + for (const sub of subs.get(did) ?? []) { try { + await webpush.sendNotification(sub, JSON.stringify({ subject, source, source_record })); } catch (err) { if (400 <= err.statusCode && err.statusCode < 500) { expiredSubs.push(sub); @@ -206,7 +203,7 @@ const handleSubscribe = async (req, res, appSecret) => { const body = await getRequesBody(req); const { sub } = JSON.parse(body); - // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) + addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) addSub(did, sub); res.setHeader('Content-Type', 'application/json'); res.writeHead(201); -- 2.51.2