diff --git a/atproto-notifications/src/atproto/resolve.ts b/atproto-notifications/src/atproto/resolve.ts index 13aef2a..2c41f51 100644 --- a/atproto-notifications/src/atproto/resolve.ts +++ b/atproto-notifications/src/atproto/resolve.ts @@ -47,5 +47,7 @@ export async function resolveDid(did) { throw new Error('empty handle'); } + // TODO: do we need to resolve back the other way to verify? + return handle; } diff --git a/atproto-notifications/src/components/Feed.tsx b/atproto-notifications/src/components/Feed.tsx index 53ff2d6..bc04ac8 100644 --- a/atproto-notifications/src/components/Feed.tsx +++ b/atproto-notifications/src/components/Feed.tsx @@ -1,47 +1,5 @@ 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); - } - } - }); -}; +import { getNotifications } from '../db'; export function Feed() { @@ -58,7 +16,7 @@ export function Feed() { // this could be combined with the broadcast thing above, but for now just chain deps const [feed, setFeed] = useState([]); useEffect(() => { - (async () => setFeed((await getNotifs())))(); + (async () => setFeed((await getNotifications())))(); }, [inc]); if (feed.length === 0) { diff --git a/atproto-notifications/src/db.ts b/atproto-notifications/src/db.ts new file mode 100644 index 0000000..35f4089 --- /dev/null +++ b/atproto-notifications/src/db.ts @@ -0,0 +1,107 @@ +const NOTIFICATIONS = 'notifications'; +const SECONDARIES = ['all', 'source', 'group', 'app']; + +export 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) { + + // primary store for notifications + try { + // upgrade is a reset: entirely remove the store (ignore errors if it didn't exist) + db.deleteObjectStore('notifs'); + } catch (e) {} + const notifStore = db.createObjectStore(NOTIFICATIONS, { + key: 'id', + autoIncrement: true, + }); + // subject prob doesn't need an index, could just query constellation + notifStore.createIndex('subject', 'subject', { unique: false }); + // specific notification (not unique bc spacedust doens't emit deletes yet) + notifStore.createIndex('source_record', 'source_record', { unique: false }); + // filter by source user of notifications because why not + notifStore.createIndex('source_did', 'source_did', { unique: false }); + // notifications of an exact type + notifStore.createIndex('source', 'source', { unique: false }); + // by nsid group + notifStore.createIndex('group', 'group', { unique: false }); + // by nsid tld+1 + notifStore.createIndex('app', 'app', { unique: false }); + + // secondary indexes: notification counts + for (const secondary of SECONDARIES) { + try { + // upgrade is hard reset + db.deleteObjectStore(secondary); + } catch (e) {} + const store = db.createObjectStore(secondary, { + key: 'k', + }); + store.createIndex('total', 'total', { unique: false }); + store.createIndex('unread', 'unread', { unique: false }); + } + +}, 3); + +export async function insertNotification(notif: { + subject: String, + source_record: String, + source_did: String, + source: String, + group: String, + app: String, +}) { + const db = await getDB(); + const tx = db.transaction([NOTIFICATIONS, ...SECONDARIES], 'readwrite'); + + // 1. insert the actual notification + tx.objectStore(NOTIFICATIONS).put(notif); + + // 2. update all secondary counts + for (const secondary of SECONDARIES) { + const store = tx.objectStore(secondary); + const key = secondary === 'all' ? 'all' : notif[secondary]; + store.get(key).onsuccess = ev => { + let count = ev.target.result ?? { total: 0, unread: 0 }; + count.total += 1; + count.unread += 1; + store.put(count, key); + }; + const req = tx.objectStore(s).get(s === 'all' ? s : notif[s]); + } + + return new Promise((resolve, reject) => { + tx.onerror = () => reject(tx.error); + tx.oncomplete = resolve; + }); +} + +export async function getNotifications(limit = 30) { + let res = []; + const oc = (await getDB()) + .transaction([NOTIFICATIONS]) + .objectStore(NOTIFICATIONS) + .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); + } + } + }); +} diff --git a/atproto-notifications/src/service-worker.ts b/atproto-notifications/src/service-worker.ts index f24095f..c211df0 100644 --- a/atproto-notifications/src/service-worker.ts +++ b/atproto-notifications/src/service-worker.ts @@ -1,40 +1,10 @@ import psl from 'psl'; import { resolveDid } from './atproto/resolve'; +import { insertNotification } from './db'; self.addEventListener('push', handlePush); self.addEventListener('notificationclick', handleNotificationClick); -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(ev) { const { subject, source, source_record } = ev.data.json(); @@ -47,10 +17,11 @@ async function handlePush(ev) { }[source] ?? source; let handle = 'unknown'; + let source_did; if (source_record.startsWith('at://')) { - const did = source_record.slice('at://'.length).split('/')[0]; + source_did = source_record.slice('at://'.length).split('/')[0]; try { - handle = await resolveDid(did); + handle = await resolveDid(source_did); } catch (err) { console.error('failed to get handle', err); } @@ -60,43 +31,35 @@ async function handlePush(ev) { // TODO: resubscribe to notifs to try to stay alive let group; - let domain; + let app; try { const [nsid, ...rp] = source.split(':'); const parts = nsid.split('.'); group = parts.slice(0, parts.length - 1).join('.') ?? 'unknown'; const unreversed = parts.toReversed().join('.'); - domain = psl.parse(unreversed)?.domain ?? 'unknown'; + app = psl.parse(unreversed)?.domain ?? 'unknown'; } catch (e) { - console.error('getting top app domain failed', e); + console.error('getting top app failed', e); } - let db; try { - db = await getDB(); + await insertNotification({ + subject, + source_record, + source_did, + source, + group, + app, + }); } 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'); const notification = self.registration.showNotification(title, { icon, - body: `from ${handle} on ${domain} in ${group}`, - // actions: [ - // {'action': 'bsky', title: 'Bluesky'}, - // {'action': 'spacedust', title: 'All notifications'}, - // ], + body: `from @${handle}`, }); ev.waitUntil(notification);