demos for spacedust

put notifications in indexeddb to show on page

+142 -10
+66 -4
atproto-notifications/public/service-worker.js
··· 1 1 self.addEventListener('push', handlePush); 2 2 3 - function handlePush(event) { 4 - const { title, body } = event.data.json(); 5 - // const icon = '/images/icon.png'; 3 + const getDB = ((upgrade, v) => { 4 + let instance; 5 + return () => { 6 + if (instance) return instance; 7 + const req = indexedDB.open('atproto-notifs', v); 8 + instance = new Promise((resolve, reject) => { 9 + req.onerror = () => reject(req.error); 10 + req.onupgradeneeded = () => upgrade(req.result); 11 + req.onsuccess = () => resolve(req.result); 12 + }); 13 + return instance; 14 + }; 15 + })(function dbUpgrade(db) { 16 + try { 17 + db.deleteObjectStore('notifs'); 18 + } catch (e) {} 19 + db.createObjectStore('notifs', { 20 + key: 'id', 21 + autoIncrement: true, 22 + }); 23 + }, 2); 24 + 25 + const push = async notif => { 26 + const tx = (await getDB()).transaction('notifs', 'readwrite'); 27 + return new Promise((resolve, reject) => { 28 + tx.oncomplete = resolve; 29 + tx.onerror = () => reject(tx.error); 30 + tx.objectStore('notifs').put(notif); 31 + }); 32 + }; 33 + 34 + async function handlePush(event) { 35 + const { subject, source, source_record } = event.data.json(); 36 + 37 + let icon; 38 + if (source.startsWith('app.bsky')) icon = '/icons/app.bsky.png'; 39 + 40 + let title = { 41 + 'app.bsky.graph.follow:subject': 'New follow', 42 + 'app.bsky.feed.like:subject.uri': 'New like 💜', 43 + }[source] ?? source; 44 + 6 45 // const tag = 'simple-push-demo-notification-tag'; 7 - event.waitUntil(self.registration.showNotification(title, { body })); 8 46 // TODO: resubscribe to notifs to try to stay alive 47 + 48 + let db; 49 + try { 50 + db = await getDB(); 51 + } catch (e) { 52 + console.error('oh no', e); 53 + throw e; 54 + } 55 + db.onerror = e => { 56 + console.error('db errored', e); 57 + }; 58 + 59 + try { 60 + await push({ subject, source, source_record }); 61 + } catch (e) { 62 + console.error('uh oh', e); 63 + } 64 + 65 + new BroadcastChannel('notif').postMessage('heyyy'); 66 + 67 + event.waitUntil(self.registration.showNotification(title, { 68 + icon, 69 + body: source_record, 70 + })); 9 71 }
+2
atproto-notifications/src/App.tsx
··· 2 2 import { useLocalStorage } from "@uidotdev/usehooks"; 3 3 import { HostContext } from './context' 4 4 import { WhoAmI } from './components/WhoAmI'; 5 + import { Feed } from './components/Feed'; 5 6 import { urlBase64ToUint8Array } from './utils'; 6 7 import './App.css' 7 8 ··· 120 121 @{user.handle} 121 122 <button onClick={() => setUser(null)}>&times;</button> 122 123 </p> 124 + <Feed /> 123 125 </> 124 126 ); 125 127 }
+71
atproto-notifications/src/components/Feed.tsx
··· 1 + import { useEffect, useState } from 'react'; 2 + 3 + const getDB = ((upgrade, v) => { 4 + let instance; 5 + return () => { 6 + if (instance) return instance; 7 + const req = indexedDB.open('atproto-notifs', v); 8 + instance = new Promise((resolve, reject) => { 9 + req.onerror = () => reject(req.error); 10 + req.onupgradeneeded = () => upgrade(req.result); 11 + req.onsuccess = () => resolve(req.result); 12 + }); 13 + return instance; 14 + }; 15 + })(function dbUpgrade(db) { 16 + try { 17 + db.deleteObjectStore('notifs'); 18 + } catch (e) {} 19 + db.createObjectStore('notifs', { 20 + key: 'id', 21 + autoIncrement: true, 22 + }); 23 + }, 2); 24 + 25 + const getNotifs = async (limit = 30) => { 26 + let res = []; 27 + const oc = (await getDB()) 28 + .transaction(['notifs']) 29 + .objectStore('notifs') 30 + .openCursor(undefined, 'prev'); 31 + return new Promise((resolve, reject) => { 32 + oc.onerror = () => reject(oc.error); 33 + oc.onsuccess = ev => { 34 + const cursor = event.target.result; 35 + if (cursor) { 36 + res.push([cursor.key, cursor.value]); 37 + if (res.length < limit) cursor.continue(); 38 + else resolve(res); 39 + } else { 40 + resolve(res); 41 + } 42 + } 43 + }); 44 + }; 45 + 46 + export function Feed() { 47 + 48 + // for now, we just increment a counter when a new notif comes in, which forces a re-render 49 + const [inc, setInc] = useState(0); 50 + useEffect(() => { 51 + const handleMessage = () => setInc(n => n + 1); 52 + const chan = new BroadcastChannel('notif'); 53 + chan.addEventListener('message', handleMessage); 54 + return () => chan.removeEventListener('message', handleMessage); 55 + }); 56 + 57 + // semi-gross way to just pull out all the events so we can see them 58 + // this could be combined with the broadcast thing above, but for now just chain deps 59 + const [feed, setFeed] = useState([]); 60 + useEffect(() => { 61 + (async () => setFeed((await getNotifs())))(); 62 + }, [inc]); 63 + 64 + if (feed.length === 0) { 65 + return 'no notifications loaded'; 66 + } 67 + return feed.map(([k, n]) => ( 68 + <p key={k}>{k}: {n.source} ({n.source_record}) <code>{JSON.stringify(n)}</code></p> 69 + )); 70 + 71 + }
+3 -6
server/index.js
··· 70 70 } 71 71 72 72 const expiredSubs = []; 73 - for (const sub of subs.get(did) ?? []) { 74 - const title = `new ${source}`; 75 - const body = `from ${source_record}`; 76 - try { 77 - await webpush.sendNotification(sub, JSON.stringify({ title, body })); 73 + for (const sub of subs.get(did) ?? []) { try { 74 + await webpush.sendNotification(sub, JSON.stringify({ subject, source, source_record })); 78 75 } catch (err) { 79 76 if (400 <= err.statusCode && err.statusCode < 500) { 80 77 expiredSubs.push(sub); ··· 206 203 207 204 const body = await getRequesBody(req); 208 205 const { sub } = JSON.parse(body); 209 - // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) 206 + addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) 210 207 addSub(did, sub); 211 208 res.setHeader('Content-Type', 'application/json'); 212 209 res.writeHead(201);