diff --git a/live-embed/package-lock.json b/live-embed/package-lock.json index 2d42f9d..df005df 100644 --- a/live-embed/package-lock.json +++ b/live-embed/package-lock.json @@ -8,6 +8,8 @@ "name": "live-embed", "version": "0.0.0", "dependencies": { + "@atcute/client": "^4.0.3", + "@atcute/identity-resolver": "^1.1.3", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -39,6 +41,58 @@ "node": ">=6.0.0" } }, + "node_modules/@atcute/client": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.3.tgz", + "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", + "license": "MIT", + "dependencies": { + "@atcute/identity": "^1.0.2", + "@atcute/lexicons": "^1.0.3" + } + }, + "node_modules/@atcute/identity": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.0.3.tgz", + "integrity": "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^1.0.4", + "@badrap/valita": "^0.4.5" + } + }, + "node_modules/@atcute/identity-resolver": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.1.3.tgz", + "integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==", + "license": "MIT", + "dependencies": { + "@atcute/lexicons": "^1.0.4", + "@atcute/util-fetch": "^1.0.1", + "@badrap/valita": "^0.4.4" + }, + "peerDependencies": { + "@atcute/identity": "^1.0.0" + } + }, + "node_modules/@atcute/lexicons": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.1.0.tgz", + "integrity": "sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q==", + "license": "0BSD", + "dependencies": { + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/util-fetch": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.1.tgz", + "integrity": "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==", + "license": "MIT", + "dependencies": { + "@badrap/valita": "^0.4.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -321,6 +375,15 @@ "node": ">=6.9.0" } }, + "node_modules/@badrap/valita": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.5.tgz", + "integrity": "sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", @@ -2160,6 +2223,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", diff --git a/live-embed/package.json b/live-embed/package.json index 27c7647..8c15157 100644 --- a/live-embed/package.json +++ b/live-embed/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@atcute/client": "^4.0.3", + "@atcute/identity-resolver": "^1.1.3", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/live-embed/src/App.css b/live-embed/src/App.css index b9d355d..da7d57c 100644 --- a/live-embed/src/App.css +++ b/live-embed/src/App.css @@ -5,38 +5,13 @@ text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; +.with { + margin: 0.5em 0 1.5em; + font-size: 1.2em; } -.read-the-docs { - color: #888; +.posts { + display: flex; + flex-wrap: wrap; + gap: 1rem; } diff --git a/live-embed/src/App.tsx b/live-embed/src/App.tsx index 57b0845..58f303b 100644 --- a/live-embed/src/App.tsx +++ b/live-embed/src/App.tsx @@ -44,10 +44,13 @@ function App() { return ( <> -

live post interactions

- {currentPair.map(p => ( - - ))} +

zero bluesky infra post rendering

+

with live-updating interaction counts

+
+ {currentPair.map(p => ( + + ))} +
) } diff --git a/live-embed/src/Post.css b/live-embed/src/Post.css new file mode 100644 index 0000000..47565bf --- /dev/null +++ b/live-embed/src/Post.css @@ -0,0 +1,33 @@ +.post { + background-color: rgb(32, 41, 53); + border: 1px solid rgb(46, 64, 82); + display: flex; + flex-direction: column; + max-width: 24em; +} + +.record-contents { + flex-grow: 1; + padding: 1em; + text-align: left; + align-content: center; +} + +.stats { + border-top: 0.5px solid rgb(46, 64, 82); + display: flex; + justify-content: space-around; + margin: 0; + gap: 1em; + padding: 0 1em; + opacity: 0.667; +} +.stat.reply:before { + content: "🫧 "; +} +.stat.repost:before { + content: "♻ "; +} +.stat.like:before { + content: "♡ "; +} diff --git a/live-embed/src/Post.tsx b/live-embed/src/Post.tsx index c69ff18..b1e18be 100644 --- a/live-embed/src/Post.tsx +++ b/live-embed/src/Post.tsx @@ -1,8 +1,12 @@ import { useEffect, useState } from 'react'; import { getPostStats } from './constellation'; +import { getAtUri } from './getPost'; import linkSources from './linkSources'; +import './Post.css'; + export function Post({ atUri, updatedLinks }) { + const [record, setRecord] = useState({ state: 'loading' }); const [baseStats, setBaseStats] = useState({}); const liveStats = { ...baseStats }; @@ -14,18 +18,51 @@ export function Post({ atUri, updatedLinks }) { useEffect(() => { let alive = true; + + getAtUri(atUri).then( + record => alive && setRecord({ state: 'loaded', record }), + error => alive && setRecord({ state: 'failed', error })); + getPostStats(atUri).then( stats => alive && setBaseStats(stats), e => console.warn('fetching base stats failed', e)); + return () => alive = false; }, [atUri]); return ( -
-

- {atUri}
- {JSON.stringify(liveStats)} +

+ {record.state === 'loading' + ?

loading post…

+ : record.state === 'failed' + ?

failed to load post :/ {`${record.error}`}

+ : + } +

+ {liveStats.reply && ( + + {liveStats.reply.toLocaleString()} + + )} + {liveStats.repost && ( + + {liveStats.repost.toLocaleString()} + + )} + {liveStats.like && ( + + {liveStats.like.toLocaleString()} + + )}

); } + +function RecordContents({ data }) { + return ( +
+ {data.text} +
+ ); +} diff --git a/live-embed/src/getPost.ts b/live-embed/src/getPost.ts new file mode 100644 index 0000000..28e1399 --- /dev/null +++ b/live-embed/src/getPost.ts @@ -0,0 +1,73 @@ +// heyyyyyy if you're reading this: +// +// it's all copy-pasta from the atproto notifications demo, pasted over from +// previous experimental stuff. +// +// all to say it probably needs some attention and cleanup, in particular with +// handling failures. i'll get back to it hopefully soon. + +import { Client, CredentialManager, ok, simpleFetchHandler } from '@atcute/client'; +import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver } from '@atcute/identity-resolver'; + +const docResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, +}); + +async function resolve_did(did) { + return await docResolver.resolve(did); +} + +function pds({ service }) { + if (!service) { + throw new Error('missing service from identity doc'); + } + const { serviceEndpoint } = service[0]; + if (!serviceEndpoint) { + throw new Error('missing serviceEndpoint from identity service array'); + } + return serviceEndpoint; +} + + +async function get_pds_record(endpoint, did, collection, rkey) { + const handler = simpleFetchHandler({ service: endpoint }); + const rpc = new Client({ handler }); + const { ok, data } = await rpc.get('com.atproto.repo.getRecord', { + params: { repo: did, collection, rkey }, + }); + if (!ok) throw new Error('fetching pds record failed'); + return data; +} + +function parse_at_uri(uri) { + let collection, rkey; + if (!uri.startsWith('at://')) { + throw new Error('invalid at-uri: did not start with "at://"'); + } + let remaining = uri.slice('at://'.length); // remove the at:// prefix + remaining = remaining.split('#')[0]; // hash is valid in at-uri but we don't handle them + remaining = remaining.split('?')[0]; // query is valid in at-uri but we don't handle it + const segments = remaining.split('/'); + if (segments.length === 0) { + throw new Error('invalid at-uri: could not find did after "at://"'); + } + const did = segments[0]; + if (segments.length > 1) { + collection = segments[1]; + } + if (segments.length > 2) { + rkey = segments.slice(2).join('/'); // hmm are slashes actually valid in rkey? + } + return { did, collection, rkey }; +} + +export async function getAtUri(atUri) { + const { did, collection, rkey } = parse_at_uri(atUri); + const doc = await resolve_did(did); + const endpoint = pds(doc); + const { value } = await get_pds_record(endpoint, did, collection, rkey); + return value; +} diff --git a/live-embed/src/index.css b/live-embed/src/index.css index 08a3ac9..cdd0c59 100644 --- a/live-embed/src/index.css +++ b/live-embed/src/index.css @@ -5,7 +5,7 @@ color-scheme: light dark; color: rgba(255, 255, 255, 0.87); - background-color: #242424; + background-color: rgb(24, 30, 38); font-synthesis: none; text-rendering: optimizeLegibility; @@ -31,8 +31,9 @@ body { } h1 { - font-size: 3.2em; + font-size: 2em; line-height: 1.1; + margin: 0; } button {