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