link templating via data from record lookup

Changed files
+296 -8
atproto-notifications
lexicons
+15 -2
atproto-notifications/package-lock.json
··· 8 8 "name": "atproto-notifications", 9 9 "version": "0.0.0", 10 10 "dependencies": { 11 + "@atcute/client": "^4.0.3", 11 12 "@atcute/identity-resolver": "^1.1.3", 12 13 "@uidotdev/usehooks": "^2.4.1", 13 14 "lexicons": "file:../lexicons", ··· 33 34 } 34 35 }, 35 36 "../lexicons": { 36 - "version": "0.0.1" 37 + "version": "0.0.1", 38 + "dependencies": { 39 + "psl": "^1.15.0" 40 + } 37 41 }, 38 42 "node_modules/@ampproject/remapping": { 39 43 "version": "2.3.0", ··· 49 53 "node": ">=6.0.0" 50 54 } 51 55 }, 56 + "node_modules/@atcute/client": { 57 + "version": "4.0.3", 58 + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.3.tgz", 59 + "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", 60 + "license": "MIT", 61 + "dependencies": { 62 + "@atcute/identity": "^1.0.2", 63 + "@atcute/lexicons": "^1.0.3" 64 + } 65 + }, 52 66 "node_modules/@atcute/identity": { 53 67 "version": "1.0.3", 54 68 "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.0.3.tgz", 55 69 "integrity": "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==", 56 70 "license": "0BSD", 57 - "peer": true, 58 71 "dependencies": { 59 72 "@atcute/lexicons": "^1.0.4", 60 73 "@badrap/valita": "^0.4.5"
+1
atproto-notifications/package.json
··· 11 11 "preview": "vite preview" 12 12 }, 13 13 "dependencies": { 14 + "@atcute/client": "^4.0.3", 14 15 "@atcute/identity-resolver": "^1.1.3", 15 16 "@uidotdev/usehooks": "^2.4.1", 16 17 "lexicons": "file:../lexicons",
+12 -3
atproto-notifications/src/components/Notification.tsx
··· 1 + import { useState, useEffect } from 'react'; 1 2 import ReactTimeAgo from 'react-time-ago'; 2 3 import psl from 'psl'; 3 - import lexicons from 'lexicons'; 4 + import { default as lexicons, getLink } from 'lexicons'; 4 5 import { resolveDid } from '../atproto/resolve'; 5 6 import { Fetch } from './Fetch'; 6 7 7 8 import './Notification.css'; 8 9 9 10 export function Notification({ app, group, source, source_record, source_did, subject, timestamp }) { 11 + const [resolvedLink, setResolvedLink] = useState(null); 12 + 13 + useEffect(() => { 14 + (async () => { 15 + const link = await getLink(source, source_record, subject); 16 + if (link) setResolvedLink(link); 17 + })(); 18 + }, [source, source_record, subject]); 10 19 11 20 // TODO: clean up / move this to lexicons package? 12 21 let title = source; ··· 48 57 49 58 directLink = lex 50 59 ?.clients[0] 51 - ?.direct_links[`at_uri:${sourceRemainder}`] 60 + ?.direct_links?.[`at_uri:${sourceRemainder}`] 52 61 ?.replace('{subject.did}', did) 53 62 ?.replace('{subject.collection}', collection) 54 63 ?.replace('{subject.rkey}', rest.join('/') || null) ··· 56 65 ?.replace('{source_record.collection}', sCollection) 57 66 ?.replace('{source_record.rkey}', sRest.join('/') || null); 58 67 } 59 - link = directLink ?? link; 68 + link = resolvedLink ?? directLink ?? link; 60 69 61 70 const contents = ( 62 71 <>
+67
lexicons/atproto.js
··· 1 + import { Client, CredentialManager, ok, simpleFetchHandler } from '@atcute/client'; 2 + import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from '@atcute/identity-resolver'; 3 + 4 + // cleanup needed 5 + 6 + const docResolver = new CompositeDidDocumentResolver({ 7 + methods: { 8 + plc: new PlcDidDocumentResolver(), 9 + web: new WebDidDocumentResolver(), 10 + }, 11 + }); 12 + 13 + async function resolve_did(did) { 14 + return await docResolver.resolve(did); 15 + } 16 + 17 + function pds({ service }) { 18 + if (!service) { 19 + throw new Error('missing service from identity doc'); 20 + } 21 + const { serviceEndpoint } = service[0]; 22 + if (!serviceEndpoint) { 23 + throw new Error('missing serviceEndpoint from identity service array'); 24 + } 25 + return serviceEndpoint; 26 + } 27 + 28 + 29 + async function get_pds_record(endpoint, did, collection, rkey) { 30 + const handler = simpleFetchHandler({ service: endpoint }); 31 + const rpc = new Client({ handler }); 32 + const { ok, data } = await rpc.get('com.atproto.repo.getRecord', { 33 + params: { repo: did, collection, rkey }, 34 + }); 35 + if (!ok) throw new Error('fetching pds record failed'); 36 + return data; 37 + } 38 + 39 + function parse_at_uri(uri) { 40 + let collection, rkey; 41 + if (!uri.startsWith('at://')) { 42 + throw new Error('invalid at-uri: did not start with "at://"'); 43 + } 44 + let remaining = uri.slice('at://'.length); // remove the at:// prefix 45 + remaining = remaining.split('#')[0]; // hash is valid in at-uri but we don't handle them 46 + remaining = remaining.split('?')[0]; // query is valid in at-uri but we don't handle it 47 + const segments = remaining.split('/'); 48 + if (segments.length === 0) { 49 + throw new Error('invalid at-uri: could not find did after "at://"'); 50 + } 51 + const did = segments[0]; 52 + if (segments.length > 1) { 53 + collection = segments[1]; 54 + } 55 + if (segments.length > 2) { 56 + rkey = segments.slice(2).join('/'); // hmm are slashes actually valid in rkey? 57 + } 58 + return { did, collection, rkey }; 59 + } 60 + 61 + export async function getAtUri(atUri) { 62 + const { did, collection, rkey } = parse_at_uri(atUri); 63 + const doc = await resolve_did(did); 64 + const endpoint = pds(doc); 65 + const { value } = await get_pds_record(endpoint, did, collection, rkey); 66 + return value; 67 + }
+73 -2
lexicons/bits.js
··· 1 1 import psl from 'psl'; 2 + import { JSONPath } from 'jsonpath-plus'; 2 3 import defs from './defs.js'; 4 + import { getAtUri } from './atproto.js'; 3 5 4 6 export function getBits(source) { 5 7 const [nsid, ...rp] = source.split(':'); 6 8 const segments = nsid.split('.'); 7 9 const group = segments.slice(0, segments.length - 1).join('.') ?? null; 8 - const unreversed = segments.toReversed().join('.'); 9 - const app = psl.parse(unreversed)?.domain ?? null; 10 + segments.reverse(); 11 + const app = psl.parse(segments.join('.'))?.domain ?? null; 10 12 return { app, group }; 11 13 } 14 + 15 + function getAppDefs(source) { 16 + const { app } = getBits(source); 17 + const appPrefix = source.slice(0, app.length); 18 + const appSource = source.slice(app.length + 1); 19 + return [appSource, defs[appPrefix]]; 20 + } 21 + 22 + export async function getContext(source, source_record, subject) { 23 + } 24 + 25 + const uriBits = async uri => { 26 + const bits = uri.slice('at://'.length).split('/'); 27 + // TODO: identifier might be a handle 28 + // TODO: rest might contain stuff after the rkey 29 + const [did, nsid, rkey] = [bits[0], bits[1], bits.slice(2)]; 30 + return [did, nsid, rkey.join('/') || null]; 31 + }; 32 + 33 + export async function getLink(source, source_record, subject) { 34 + // TODO: pass in preferred client 35 + const [appSource, appDefs] = getAppDefs(source); 36 + const appLinks = appDefs?.clients?.[0]?.direct_links; 37 + const linkType = subject.startsWith('did:') ? 'did' : 'at_uri'; 38 + const linkTemplate = appLinks?.[`${linkType}:${appSource}`]; 39 + if (!linkTemplate) return null; 40 + 41 + let link = linkTemplate; 42 + 43 + // 1. sync subs 44 + const [sourceDid, sourceNsid, sourceRkey] = await uriBits(source_record); 45 + link = link 46 + .replaceAll('{source_record.did}', sourceDid) 47 + .replaceAll('{source_record.collection}', sourceNsid) 48 + .replaceAll('{source_record.rkey}', sourceRkey); 49 + if (linkTemplate === 'did') { 50 + link = link.replaceAll('{subject.did}', subject); 51 + } else { 52 + const [subjectDid, subjectNsid, subjectRkey] = await uriBits(subject); 53 + link = link 54 + .replaceAll('{subject.did}', subjectDid) 55 + .replaceAll('{subject.collection}', subjectNsid) 56 + .replaceAll('{subject.rkey}', subjectRkey); 57 + } 58 + 59 + // 2. async lookups 60 + 61 + // do we need to fetch anything from the link subject record? 62 + if (linkType === 'at_uri') { 63 + const subjectMatches = [...link.matchAll(/(\{@subject:(?<path>[^\}]+)\})/g)]; 64 + if (subjectMatches.length > 0) { 65 + const subjectRecord = await getAtUri(subject); 66 + 67 + // do the actual replacements 68 + for (const match of subjectMatches) { 69 + // TODO: JSONPath won't actually cut it once we get $type in 70 + const sub = JSONPath({ 71 + path: `$.${match.groups.path}`, 72 + json: subjectRecord, 73 + })[0]; 74 + 75 + link = link.replaceAll(match[0], sub); 76 + } 77 + } 78 + } 79 + 80 + // 2.b TODO: source record lookups if needed 81 + return link; 82 + }
+3
lexicons/defs.js
··· 92 92 canonical: true, 93 93 icon: '/icons/sh.tangled.jpg', 94 94 main: 'https://tangled.sh', 95 + direct_links: { 96 + 'at_uri:feed.star:subject': 'https://tangled.sh/{subject.did}/{@subject:name}', 97 + }, 95 98 } 96 99 ], 97 100 known_sources: {
+1 -1
lexicons/index.js
··· 1 - export { getBits } from './bits.js'; 1 + export { getBits, getLink, getContext } from './bits.js'; 2 2 export { default } from './defs.js';
+121
lexicons/package-lock.json
··· 8 8 "name": "lexicons", 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 + "@atcute/client": "^4.0.3", 12 + "@atcute/identity-resolver": "^1.1.3", 13 + "jsonpath-plus": "^10.3.0", 11 14 "psl": "^1.15.0" 15 + } 16 + }, 17 + "node_modules/@atcute/client": { 18 + "version": "4.0.3", 19 + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.3.tgz", 20 + "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", 21 + "license": "MIT", 22 + "dependencies": { 23 + "@atcute/identity": "^1.0.2", 24 + "@atcute/lexicons": "^1.0.3" 25 + } 26 + }, 27 + "node_modules/@atcute/identity": { 28 + "version": "1.0.3", 29 + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.0.3.tgz", 30 + "integrity": "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==", 31 + "license": "0BSD", 32 + "dependencies": { 33 + "@atcute/lexicons": "^1.0.4", 34 + "@badrap/valita": "^0.4.5" 35 + } 36 + }, 37 + "node_modules/@atcute/identity-resolver": { 38 + "version": "1.1.3", 39 + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.1.3.tgz", 40 + "integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==", 41 + "license": "MIT", 42 + "dependencies": { 43 + "@atcute/lexicons": "^1.0.4", 44 + "@atcute/util-fetch": "^1.0.1", 45 + "@badrap/valita": "^0.4.4" 46 + }, 47 + "peerDependencies": { 48 + "@atcute/identity": "^1.0.0" 49 + } 50 + }, 51 + "node_modules/@atcute/lexicons": { 52 + "version": "1.1.0", 53 + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.1.0.tgz", 54 + "integrity": "sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q==", 55 + "license": "0BSD", 56 + "dependencies": { 57 + "esm-env": "^1.2.2" 58 + } 59 + }, 60 + "node_modules/@atcute/util-fetch": { 61 + "version": "1.0.1", 62 + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.1.tgz", 63 + "integrity": "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==", 64 + "license": "MIT", 65 + "dependencies": { 66 + "@badrap/valita": "^0.4.2" 67 + } 68 + }, 69 + "node_modules/@badrap/valita": { 70 + "version": "0.4.5", 71 + "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.5.tgz", 72 + "integrity": "sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ==", 73 + "license": "MIT", 74 + "engines": { 75 + "node": ">= 18" 76 + } 77 + }, 78 + "node_modules/@jsep-plugin/assignment": { 79 + "version": "1.3.0", 80 + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", 81 + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", 82 + "license": "MIT", 83 + "engines": { 84 + "node": ">= 10.16.0" 85 + }, 86 + "peerDependencies": { 87 + "jsep": "^0.4.0||^1.0.0" 88 + } 89 + }, 90 + "node_modules/@jsep-plugin/regex": { 91 + "version": "1.0.4", 92 + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", 93 + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", 94 + "license": "MIT", 95 + "engines": { 96 + "node": ">= 10.16.0" 97 + }, 98 + "peerDependencies": { 99 + "jsep": "^0.4.0||^1.0.0" 100 + } 101 + }, 102 + "node_modules/esm-env": { 103 + "version": "1.2.2", 104 + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", 105 + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", 106 + "license": "MIT" 107 + }, 108 + "node_modules/jsep": { 109 + "version": "1.4.0", 110 + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", 111 + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", 112 + "license": "MIT", 113 + "engines": { 114 + "node": ">= 10.16.0" 115 + } 116 + }, 117 + "node_modules/jsonpath-plus": { 118 + "version": "10.3.0", 119 + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", 120 + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", 121 + "license": "MIT", 122 + "dependencies": { 123 + "@jsep-plugin/assignment": "^1.3.0", 124 + "@jsep-plugin/regex": "^1.0.4", 125 + "jsep": "^1.4.0" 126 + }, 127 + "bin": { 128 + "jsonpath": "bin/jsonpath-cli.js", 129 + "jsonpath-plus": "bin/jsonpath-cli.js" 130 + }, 131 + "engines": { 132 + "node": ">=18.0.0" 12 133 } 13 134 }, 14 135 "node_modules/psl": {
+3
lexicons/package.json
··· 7 7 "author": "", 8 8 "type": "module", 9 9 "dependencies": { 10 + "@atcute/client": "^4.0.3", 11 + "@atcute/identity-resolver": "^1.1.3", 12 + "jsonpath-plus": "^10.3.0", 10 13 "psl": "^1.15.0" 11 14 } 12 15 }