demos for spacedust

more db in the indexeddb

+127 -97
+2
atproto-notifications/src/atproto/resolve.ts
··· 47 47 throw new Error('empty handle'); 48 48 } 49 49 50 + // TODO: do we need to resolve back the other way to verify? 51 + 50 52 return handle; 51 53 }
+2 -44
atproto-notifications/src/components/Feed.tsx
··· 1 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 - }; 2 + import { getNotifications } from '../db'; 45 3 46 4 export function Feed() { 47 5 ··· 58 16 // this could be combined with the broadcast thing above, but for now just chain deps 59 17 const [feed, setFeed] = useState([]); 60 18 useEffect(() => { 61 - (async () => setFeed((await getNotifs())))(); 19 + (async () => setFeed((await getNotifications())))(); 62 20 }, [inc]); 63 21 64 22 if (feed.length === 0) {
+107
atproto-notifications/src/db.ts
··· 1 + const NOTIFICATIONS = 'notifications'; 2 + const SECONDARIES = ['all', 'source', 'group', 'app']; 3 + 4 + export const getDB = ((upgrade, v) => { 5 + let instance; 6 + return () => { 7 + if (instance) return instance; 8 + const req = indexedDB.open('atproto-notifs', v); 9 + instance = new Promise((resolve, reject) => { 10 + req.onerror = () => reject(req.error); 11 + req.onupgradeneeded = () => upgrade(req.result); 12 + req.onsuccess = () => resolve(req.result); 13 + }); 14 + return instance; 15 + }; 16 + })(function dbUpgrade(db) { 17 + 18 + // primary store for notifications 19 + try { 20 + // upgrade is a reset: entirely remove the store (ignore errors if it didn't exist) 21 + db.deleteObjectStore('notifs'); 22 + } catch (e) {} 23 + const notifStore = db.createObjectStore(NOTIFICATIONS, { 24 + key: 'id', 25 + autoIncrement: true, 26 + }); 27 + // subject prob doesn't need an index, could just query constellation 28 + notifStore.createIndex('subject', 'subject', { unique: false }); 29 + // specific notification (not unique bc spacedust doens't emit deletes yet) 30 + notifStore.createIndex('source_record', 'source_record', { unique: false }); 31 + // filter by source user of notifications because why not 32 + notifStore.createIndex('source_did', 'source_did', { unique: false }); 33 + // notifications of an exact type 34 + notifStore.createIndex('source', 'source', { unique: false }); 35 + // by nsid group 36 + notifStore.createIndex('group', 'group', { unique: false }); 37 + // by nsid tld+1 38 + notifStore.createIndex('app', 'app', { unique: false }); 39 + 40 + // secondary indexes: notification counts 41 + for (const secondary of SECONDARIES) { 42 + try { 43 + // upgrade is hard reset 44 + db.deleteObjectStore(secondary); 45 + } catch (e) {} 46 + const store = db.createObjectStore(secondary, { 47 + key: 'k', 48 + }); 49 + store.createIndex('total', 'total', { unique: false }); 50 + store.createIndex('unread', 'unread', { unique: false }); 51 + } 52 + 53 + }, 3); 54 + 55 + export async function insertNotification(notif: { 56 + subject: String, 57 + source_record: String, 58 + source_did: String, 59 + source: String, 60 + group: String, 61 + app: String, 62 + }) { 63 + const db = await getDB(); 64 + const tx = db.transaction([NOTIFICATIONS, ...SECONDARIES], 'readwrite'); 65 + 66 + // 1. insert the actual notification 67 + tx.objectStore(NOTIFICATIONS).put(notif); 68 + 69 + // 2. update all secondary counts 70 + for (const secondary of SECONDARIES) { 71 + const store = tx.objectStore(secondary); 72 + const key = secondary === 'all' ? 'all' : notif[secondary]; 73 + store.get(key).onsuccess = ev => { 74 + let count = ev.target.result ?? { total: 0, unread: 0 }; 75 + count.total += 1; 76 + count.unread += 1; 77 + store.put(count, key); 78 + }; 79 + const req = tx.objectStore(s).get(s === 'all' ? s : notif[s]); 80 + } 81 + 82 + return new Promise((resolve, reject) => { 83 + tx.onerror = () => reject(tx.error); 84 + tx.oncomplete = resolve; 85 + }); 86 + } 87 + 88 + export async function getNotifications(limit = 30) { 89 + let res = []; 90 + const oc = (await getDB()) 91 + .transaction([NOTIFICATIONS]) 92 + .objectStore(NOTIFICATIONS) 93 + .openCursor(undefined, 'prev'); 94 + return new Promise((resolve, reject) => { 95 + oc.onerror = () => reject(oc.error); 96 + oc.onsuccess = ev => { 97 + const cursor = event.target.result; 98 + if (cursor) { 99 + res.push([cursor.key, cursor.value]); 100 + if (res.length < limit) cursor.continue(); 101 + else resolve(res); 102 + } else { 103 + resolve(res); 104 + } 105 + } 106 + }); 107 + }
+16 -53
atproto-notifications/src/service-worker.ts
··· 1 1 import psl from 'psl'; 2 2 import { resolveDid } from './atproto/resolve'; 3 + import { insertNotification } from './db'; 3 4 4 5 self.addEventListener('push', handlePush); 5 6 self.addEventListener('notificationclick', handleNotificationClick); 6 7 7 - const getDB = ((upgrade, v) => { 8 - let instance; 9 - return () => { 10 - if (instance) return instance; 11 - const req = indexedDB.open('atproto-notifs', v); 12 - instance = new Promise((resolve, reject) => { 13 - req.onerror = () => reject(req.error); 14 - req.onupgradeneeded = () => upgrade(req.result); 15 - req.onsuccess = () => resolve(req.result); 16 - }); 17 - return instance; 18 - }; 19 - })(function dbUpgrade(db) { 20 - try { 21 - db.deleteObjectStore('notifs'); 22 - } catch (e) {} 23 - db.createObjectStore('notifs', { 24 - key: 'id', 25 - autoIncrement: true, 26 - }); 27 - }, 2); 28 - 29 - const push = async notif => { 30 - const tx = (await getDB()).transaction('notifs', 'readwrite'); 31 - return new Promise((resolve, reject) => { 32 - tx.oncomplete = resolve; 33 - tx.onerror = () => reject(tx.error); 34 - tx.objectStore('notifs').put(notif); 35 - }); 36 - }; 37 - 38 8 async function handlePush(ev) { 39 9 const { subject, source, source_record } = ev.data.json(); 40 10 ··· 47 17 }[source] ?? source; 48 18 49 19 let handle = 'unknown'; 20 + let source_did; 50 21 if (source_record.startsWith('at://')) { 51 - const did = source_record.slice('at://'.length).split('/')[0]; 22 + source_did = source_record.slice('at://'.length).split('/')[0]; 52 23 try { 53 - handle = await resolveDid(did); 24 + handle = await resolveDid(source_did); 54 25 } catch (err) { 55 26 console.error('failed to get handle', err); 56 27 } ··· 60 31 // TODO: resubscribe to notifs to try to stay alive 61 32 62 33 let group; 63 - let domain; 34 + let app; 64 35 try { 65 36 const [nsid, ...rp] = source.split(':'); 66 37 const parts = nsid.split('.'); 67 38 group = parts.slice(0, parts.length - 1).join('.') ?? 'unknown'; 68 39 const unreversed = parts.toReversed().join('.'); 69 - domain = psl.parse(unreversed)?.domain ?? 'unknown'; 40 + app = psl.parse(unreversed)?.domain ?? 'unknown'; 70 41 } catch (e) { 71 - console.error('getting top app domain failed', e); 42 + console.error('getting top app failed', e); 72 43 } 73 44 74 - let db; 75 45 try { 76 - db = await getDB(); 46 + await insertNotification({ 47 + subject, 48 + source_record, 49 + source_did, 50 + source, 51 + group, 52 + app, 53 + }); 77 54 } catch (e) { 78 55 console.error('oh no', e); 79 - throw e; 80 - } 81 - db.onerror = e => { 82 - console.error('db errored', e); 83 - }; 84 - 85 - try { 86 - await push({ subject, source, source_record }); 87 - } catch (e) { 88 - console.error('uh oh', e); 89 56 } 90 57 91 58 new BroadcastChannel('notif').postMessage('heyyy'); 92 59 93 60 const notification = self.registration.showNotification(title, { 94 61 icon, 95 - body: `from ${handle} on ${domain} in ${group}`, 96 - // actions: [ 97 - // {'action': 'bsky', title: 'Bluesky'}, 98 - // {'action': 'spacedust', title: 'All notifications'}, 99 - // ], 62 + body: `from @${handle}`, 100 63 }); 101 64 102 65 ev.waitUntil(notification);