customizeable per-app lexicon names and links

+8
atproto-notifications/package-lock.json
··· 10 10 "dependencies": { 11 11 "@atcute/identity-resolver": "^1.1.3", 12 12 "@uidotdev/usehooks": "^2.4.1", 13 + "lexicons": "file:../lexicons", 13 14 "psl": "^1.15.0", 14 15 "react": "^19.1.0", 15 16 "react-dom": "^19.1.0" ··· 27 28 "typescript-eslint": "^8.34.1", 28 29 "vite": "^7.0.0" 29 30 } 31 + }, 32 + "../lexicons": { 33 + "version": "0.0.1" 30 34 }, 31 35 "node_modules/@ampproject/remapping": { 32 36 "version": "2.3.0", ··· 2666 2670 "engines": { 2667 2671 "node": ">= 0.8.0" 2668 2672 } 2673 + }, 2674 + "node_modules/lexicons": { 2675 + "resolved": "../lexicons", 2676 + "link": true 2669 2677 }, 2670 2678 "node_modules/locate-path": { 2671 2679 "version": "6.0.0",
+1
atproto-notifications/package.json
··· 12 12 "dependencies": { 13 13 "@atcute/identity-resolver": "^1.1.3", 14 14 "@uidotdev/usehooks": "^2.4.1", 15 + "lexicons": "file:../lexicons", 15 16 "psl": "^1.15.0", 16 17 "react": "^19.1.0", 17 18 "react-dom": "^19.1.0"
atproto-notifications/public/icons/app.popsky.png

This is a binary file and will not be displayed.

atproto-notifications/public/icons/com.shinolabs.jpg

This is a binary file and will not be displayed.

atproto-notifications/public/icons/events.smokesignal.png

This is a binary file and will not be displayed.

atproto-notifications/public/icons/place.stream.png

This is a binary file and will not be displayed.

atproto-notifications/public/icons/pub.leaflet.jpg

This is a binary file and will not be displayed.

atproto-notifications/public/icons/sh.tangled.jpg

This is a binary file and will not be displayed.

atproto-notifications/public/icons/so.sprk.png

This is a binary file and will not be displayed.

+2
atproto-notifications/src/App.tsx
··· 6 6 import { urlBase64ToUint8Array } from './utils'; 7 7 import './App.css' 8 8 9 + import lexicons from 'lexicons'; 10 + 9 11 const Problem = ({ children }) => ( 10 12 <div className="problem"> 11 13 <p>Sorry, {children}</p>
-1
atproto-notifications/src/db.ts
··· 76 76 count.unread += 1; 77 77 store.put(count, key); 78 78 }; 79 - const req = tx.objectStore(s).get(s === 'all' ? s : notif[s]); 80 79 } 81 80 82 81 return new Promise((resolve, reject) => {
+22 -25
atproto-notifications/src/service-worker.ts
··· 1 1 import psl from 'psl'; 2 + import lexicons from 'lexicons'; 2 3 import { resolveDid } from './atproto/resolve'; 3 4 import { insertNotification } from './db'; 4 5 ··· 7 8 8 9 async function handlePush(ev) { 9 10 const { subject, source, source_record } = ev.data.json(); 10 - 11 - let icon; 12 - if (source.startsWith('app.bsky')) icon = '/icons/app.bsky.png'; 13 - 14 - let title = { 15 - 'app.bsky.graph.follow:subject': 'New follow', 16 - 'app.bsky.feed.like:subject.uri': 'New like 💜', 17 - }[source] ?? source; 11 + let group; 12 + let app; 13 + let appPrefix; 14 + try { 15 + const [nsid, ...rp] = source.split(':'); 16 + const parts = nsid.split('.'); 17 + group = parts.slice(0, parts.length - 1).join('.') ?? 'unknown'; 18 + const unreversed = parts.toReversed().join('.'); 19 + app = psl.parse(unreversed)?.domain ?? 'unknown'; 20 + appPrefix = app.split('.').toReversed().join('.'); 21 + } catch (e) { 22 + console.error('getting top app failed', e); 23 + } 18 24 19 25 let handle = 'unknown'; 20 26 let source_did; ··· 27 33 } 28 34 } 29 35 36 + // TODO: user pref for alt client -> prefer that client's icon 37 + const lex = lexicons[appPrefix]; 38 + const icon = lex?.clients[0]?.icon; 39 + console.log('app', app, 'lex', lex, lexicons); 40 + const title = lex?.known_sources[source.slice(app.length + 1)] ?? source; 41 + const body = `from @${handle} on ${lex?.name ?? app}`; 42 + 30 43 // const tag = 'simple-push-demo-notification-tag'; 31 44 // TODO: resubscribe to notifs to try to stay alive 32 45 33 - let group; 34 - let app; 35 - try { 36 - const [nsid, ...rp] = source.split(':'); 37 - const parts = nsid.split('.'); 38 - group = parts.slice(0, parts.length - 1).join('.') ?? 'unknown'; 39 - const unreversed = parts.toReversed().join('.'); 40 - app = psl.parse(unreversed)?.domain ?? 'unknown'; 41 - } catch (e) { 42 - console.error('getting top app failed', e); 43 - } 44 - 45 46 try { 46 47 await insertNotification({ 47 48 subject, ··· 57 58 58 59 new BroadcastChannel('notif').postMessage('heyyy'); 59 60 60 - const notification = self.registration.showNotification(title, { 61 - icon, 62 - body: `from @${handle}`, 63 - }); 64 - 61 + const notification = self.registration.showNotification(title, { icon, body }); 65 62 ev.waitUntil(notification); 66 63 } 67 64
+165
lexicons/index.js
··· 1 + export default { 2 + 'app.bsky': { 3 + name: 'Bluesky', 4 + clients: [ 5 + { 6 + app_name: 'Bluesky Social', 7 + canonical: true, 8 + main: 'https://bsky.app', 9 + icon: '/icons/app.bsky.png', 10 + notifications: 'https://bsky.app/notifications', 11 + direct_links: { 12 + 'at_uri:feed.post': 'https://bsky.app/profile/{did}/post/{rkey}', 13 + 'did': 'https://bsky.app/profile/{did}', 14 + }, 15 + }, 16 + { 17 + app_name: 'Deer Social', 18 + main: 'https://deer.social', 19 + notifications: 'https://deer.social/notifications', 20 + direct_links: { 21 + 'at_uri:feed.post': 'https://deer.social/profile/{did}/post/{rkey}', 22 + 'did': 'https://deer.social/profile/{did}', 23 + }, 24 + }, 25 + ], 26 + known_sources: { 27 + 'graph.follow:subject': 'Follow', 28 + 'graph.verification:subject': 'Verification ✅', 29 + 'feed.like:subject.uri': 'Like 💜', 30 + 'feed.like:via.uri': 'Repost like', 31 + 'feed.post:reply.parent.uri': 'Reply', 32 + 'feed.post:reply.root.uri': 'Reply in thread', 33 + 'feed.post:embed.record.uri': 'Quote', 34 + 'feed.post:embed.record.record.uri': 'Quote', // with media 35 + 'feed.post:facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did': 'Mention', 36 + 'feed.repost:subject.uri': 'Repost', 37 + 'feed.repost:via.uri': 'Repost repost', 38 + }, 39 + torment_sources: { 40 + 'graph.block:subject': null, 41 + 'graph.listitem:subject': null, // we are never ever building listifications 42 + 'graph.listblock:subject': null, // "subscribed to your blocklist?" idk 43 + 'feed.threadgate:hiddenReplies[]': null, 44 + 'feed.postgate:detachedEmbeddingUris[]': null, 45 + }, 46 + }, 47 + 'pub.leaflet': { 48 + name: 'Leaflet', 49 + clients: [ 50 + { 51 + 'app_name': 'leaflet.pub', 52 + canonical: true, 53 + icon: '/icons/pub.leaflet.jpg', 54 + main: 'https://leaflet.pub/home', 55 + } 56 + ], 57 + known_sources: { 58 + 'graph.subscription:publication:': 'Subscription', 59 + }, 60 + }, 61 + 'sh.tangled': { 62 + name: 'Tangled', 63 + clients: [ 64 + { 65 + 'app_name': 'Tangled', 66 + canonical: true, 67 + icon: '/icons/sh.tangled.jpg', 68 + main: 'https://tangled.sh', 69 + } 70 + ], 71 + known_sources: { 72 + 'feed.star:subject': 'Star', 73 + 'feed.reaction:subject': 'Reaction', 74 + 'graph.follow:subject': 'Follow', 75 + 'actor.profile:pinnedRepositories[]': 'Pinned repo', 76 + 'repo.issue.comment:issue': 'Issue comment', 77 + 'repo.issue.comment:owner': 'Issue comment', 78 + 'repo.issue.comment:repo': 'Issue comment', 79 + 'repo.pull:targetRepo': 'Pull', 80 + 'repo.pull.comment:owner': 'Pull comment', 81 + 'repo.pull.comment:pull': 'Pull comment', 82 + 'repo.pull.comment:repo': 'Pull comment', 83 + 'knot.member:subject': 'Knot member', 84 + 'spindle.member:subject': 'Spindle member', 85 + }, 86 + }, 87 + 'com.shinolabs': { // TODO: this app isn't exactly tld+1 88 + name: 'Pinksea', 89 + clients: [ 90 + { 91 + 'app_name': 'Pinksea', 92 + canonical: true, 93 + icon: '/icons/com.shinolabs.jpg', 94 + main: 'https://pinksea.art', 95 + }, 96 + ], 97 + known_sources: { 98 + 'pinksea.oekaki:inResponseTo.uri': 'Response', 99 + }, 100 + }, 101 + 'place.stream': { 102 + name: 'Streamplace', 103 + clients: [ 104 + { 105 + app_name: 'Streamplace', 106 + canonical: true, 107 + icon: '/icons/place.stream.png', 108 + main: 'https://stream.place', 109 + }, 110 + ], 111 + known_sources: { 112 + 'chat.message:streamer': 'Message', 113 + 'key:signingKey': 'Signing key', 114 + }, 115 + }, 116 + 'so.sprk': { 117 + name: 'Spark', 118 + clients: [ 119 + { 120 + app_name: 'Spark', 121 + canonical: true, 122 + icon: '/icons/so.sprk.png', 123 + main: 'https://spark.so', 124 + }, 125 + ], 126 + known_sources: { 127 + 'feed.like:subject.uri': 'Like', 128 + // it's not actually clear to me if *all* the bsky sources were copied for sprk posts or not 129 + 'feed.post:reply.parent.uri': 'Reply', 130 + 'feed.post:reply.root.uri': 'Reply in thread', 131 + 'feed.post:embed.record.uri': 'Quote', 132 + 'feed.post:embed.record.record.uri': 'Quote', // with media 133 + 'feed.post:facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did': 'Mention', 134 + }, 135 + }, 136 + 'events.smokesignal': { 137 + name: 'Smoke Signal', 138 + clients: [ 139 + { 140 + app_name: 'Smoke Signal', 141 + canonical: true, 142 + icon: '/icons/events.smokesignal.png', 143 + main: 'https://smokesignal.events', 144 + }, 145 + ], 146 + known_sources: { 147 + 'calendar.rsvp:subject.uri': 'RSVP', 148 + }, 149 + }, 150 + 'app.popsky': { 151 + name: 'Popsky', 152 + clients: [ 153 + { 154 + app_name: 'Popsky', 155 + canonical: true, 156 + icon: '/icons/app.popsky.png', 157 + main: 'https://popsky.social', 158 + }, 159 + ], 160 + known_sources: { 161 + 'like:subjectUri': 'Like', 162 + 'comment:subjectUri': 'Comment', 163 + }, 164 + }, 165 + };
+8
lexicons/package.json
··· 1 + { 2 + "name": "lexicons", 3 + "version": "0.0.1", 4 + "description": "info about atproto apps", 5 + "main": "index.js", 6 + "scripts": {}, 7 + "author": "" 8 + }