Your Bluesky timeline in a side pane: the posts of the accounts you follow, a field to write and send a post, and a like button on every post. /bsky opens it…

Your Bluesky timeline in a side pane: the posts of the accounts you follow, a field to write and send a post, and a like button on every post.
🦋 BLUESKY FOLLOWING @you.bsky.social · updated now
──────────────────────────────────────────────────────────────────
✎ post: What's up?
300 left · Enter posts · links and #tags are clickable
Mara Quinn @mara.dev · 5m
Shipped a Claude Code mod that turns every failing test into a toast.
🔗 aitmpl.com: Claude Mods catalog
♡ 128 ↩ 9 ↻ 14 open ↗
──────────────────────────────────────────────────────────────────
↻ reposted by Sam Ortiz
Infra Notes @infra-notes.bsky.social · 27m
Our p99 dropped 40% after moving the cache in front of the auth check.
▣ 2 images
♥ 55 ↩ 3 ↻ 6 open ↗
──────────────────────────────────────────────────────────────────
[ more ] [ refresh ] [ close ]
/bsky opens it (/bsky stop closes it).
app.bsky.feed.getTimeline), newest first. Reposts and replies are marked. Images, videos, link cards and quoted posts are described in one line, not drawn. more loads the next page, up to 120 posts. While the pane is open it refreshes every pollSeconds.#hashtags are sent as richtext facets, so they are clickable on bsky.app. @mentions are sent as plain text, because linking one would take one more request per name to resolve the handle.On Claude Code's mobile surface the pane is read-only: the mobile surface draws no input field.
Bluesky's API is free and public. You don't need a developer account or credits: an app password is enough. X was considered first and ruled out. Since April 2026 X's self-serve API no longer allows likes, and reading the home timeline is billed per post (X API changelog, pricing).
Bluesky's documented limits are 3,000 requests per 5 minutes per IP and 5,000 write points per hour, where a post or like costs 3 points. Logging in is limited to 30 times per 5 minutes. The mod logs in once, refreshes the session when the access token expires, and reads at most once per pollSeconds.
The mod uses an app password, never your account password. Create one in Bluesky under Settings, Privacy and security, App passwords. You can revoke it there at any time.
The app password and the session tokens are kept in memory and sent only to service, which must be https://. Plain http:// is accepted only for a PDS on your own machine (localhost, 127.0.0.1). Any other http:// value falls back to https://bsky.social. They are never logged, drawn or written to disk. The app password option is stored as sensitive. Posts are held in memory while the pane is open and are not stored either.
The mod reads your timeline, creates posts, creates likes and deletes likes. It does nothing else with your account.
handle: string your handle, e.g. you.bsky.social (required)
appPassword: string a Bluesky app password (required, sensitive)
service: string your PDS; https://bsky.social unless you self-host
limit: number posts per page, 10-50 (default 30)
pollSeconds: number refresh interval while open, 30-3600 (default 120)
openOnStart: boolean open the pane at session start (default false)
pollSeconds is honoured to the nearest 5 seconds.
npx claude-code-templates@latest --mod integrations/bluesky-feed
claude
The mod is written to .claude/skills/bluesky-feed/. Claude Code auto-loads it from there as bluesky-feed@skills-dir once you accept the workspace trust prompt. To load it for one session with hot reload, run claude --plugin-dir .claude/skills/bluesky-feed. claude plugin validate .claude/skills/bluesky-feed prints every event the mod hooks and every $ call it makes. claude plugin test .claude/skills/bluesky-feed runs its tests.
Options are read from user settings (~/.claude/settings.json, never project settings), from --settings <file> or from managed settings. They go under the plugin's full id:
{ "pluginConfigs": { "bluesky-feed@skills-dir": { "options": { "handle": "you.bsky.social", "appPassword": "xxxx-xxxx-xxxx-xxxx" } } } }
Requirements. Claude Code 2.1.287 or later, where mods are on by default. The mod is typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/bluesky-feed.tsx 329 lines1/**
2 * bluesky-feed — Claude Mod
3 *
4 * Your Bluesky "Following" timeline in a side pane:
5 *
6 * - the posts of the accounts you follow, newest first, with reposts and
7 * replies marked, images, videos, link cards and quotes described in a line
8 * - a field to write and send a post (links and #hashtags become clickable)
9 * - a like button on every post (press again to unlike) and a link that
10 * opens the post on bsky.app
11 *
12 * `/bsky` opens it, `/bsky stop` closes it. `$.clock.every(5000)` decides when
13 * to read again, every `pollSeconds`, only while the pane is open.
14 *
15 * It logs in with an app password (Bluesky, Settings, Privacy and security,
16 * App passwords), never your account password. The app password and the
17 * session tokens stay in memory, are sent only to `service`, and are never
18 * logged, drawn or stored. Never hardcode them in this file.
19 *
20 * Needs Claude Code >= 2.1.287.
21 *
22 * Options (pluginConfigs["bluesky-feed@skills-dir"].options):
23 * handle: string your handle, e.g. dan.bsky.social (required)
24 * appPassword: string an app password (required)
25 * service: string your PDS (default https://bsky.social)
26 * limit: number posts per page, 10-50 (default 30)
27 * pollSeconds: number refresh interval, 30-3600 (default 120)
28 * openOnStart: boolean open the pane at session start (default false)
29 */
30import type { Register } from 'claude-code'
31import { BskyError, Client, MAX_GRAPHEMES, ago, clampInt, compact, graphemes, handleOf, postUrl, serviceUrl } from './bsky-api.ts'
32import type { Post } from './bsky-api.ts'
33
34const PANE = 'bsky'
35const COMMAND = 'bsky'
36const TICK_MS = 5000
37const MAX_POSTS = 120
38
39const SKY = '#1185fe'
40const PINK = '#ec4899'
41const INK = '#0b1220'
42const GREY = '#8a8a8a'
43const RULE = '#3a3a3a'
44const GREEN = '#22c55e'
45
46let posts: Post[] = []
47let cursor: string | undefined
48let error: string | undefined
49let notice: { text: string; tone: 'success' | 'error' } | undefined
50let isOpen = false
51let isBusy = false
52let isPosting = false
53let lastReadAt = 0
54let draftLength = 0
55let composeGeneration = 0
56let timer: { cancel: () => void } | undefined
57
58const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
59const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
60// Desktop draws text as HTML and collapses runs of spaces; keep short gaps
61const nb = (s: string) => s.replace(/ /g, ' ')
62
63export const register: Register = (on, options) => {
64 const handle = handleOf(options.handle)
65 const password = text(options.appPassword)
66 const service = serviceUrl(options.service)
67 const limit = clampInt(options.limit, 30, 10, 50)
68 const pollMs = clampInt(options.pollSeconds, 120, 30, 3600) * 1000
69 const openOnStart = options.openOnStart === true
70 const isConfigured = handle !== '' && password !== ''
71
72 let client: Client | undefined
73 const api = (f: ConstructorParameters<typeof Client>[0]) => (client ??= new Client(f, service, handle, password))
74
75 async function read(c: Client, more = false): Promise<void> {
76 isBusy = true
77 try {
78 const page = await c.timeline(limit, more ? cursor : undefined)
79 if (more) {
80 const seen = new Set(posts.map(p => p.uri))
81 posts = [...posts, ...page.posts.filter(p => !seen.has(p.uri))].slice(0, MAX_POSTS)
82 } else {
83 posts = page.posts
84 }
85 cursor = page.cursor
86 error = undefined
87 } catch (err) {
88 error = message(err)
89 } finally {
90 lastReadAt = Date.now()
91 isBusy = false
92 }
93 }
94
95 on('session.start', async ($, e, next) => {
96 const r = await next(e)
97 await $.command
98 .register({
99 name: COMMAND,
100 description: 'Your Bluesky timeline: read, post and like (stop closes)',
101 argumentHint: '[stop]',
102 immediate: true,
103 })
104 .catch(err => $.ui.log(`bluesky-feed: /${COMMAND} not registered: ${err}`))
105 if (!isConfigured) {
106 $.ui.log('bluesky-feed: set handle and appPassword in the plugin options', { to: 'debug' })
107 return r
108 }
109
110 timer?.cancel()
111 timer = $.clock.every(TICK_MS, () => {
112 if (isBusy || !isOpen) return
113 if (Date.now() - lastReadAt < pollMs - TICK_MS / 2) return
114 void read(api((url, init) => $.http.fetch(url, init))).then(() => $.ui.invalidate('ui.render'))
115 })
116
117 if (openOnStart) {
118 // open first and read in the background: a slow PDS never holds up the session
119 isOpen = true
120 await $.ui.open({ id: PANE, title: 'bluesky' }).catch(() => {
121 isOpen = false
122 })
123 if (isOpen) void read(api((url, init) => $.http.fetch(url, init))).then(() => $.ui.invalidate('ui.render'))
124 }
125 return r
126 })
127
128 on('session.end', async ($, e, next) => {
129 timer?.cancel()
130 timer = undefined
131 return next(e)
132 })
133
134 on('command.run', { command: COMMAND }, async ($, e) => {
135 if (!isConfigured) return { text: 'bsky: set handle and appPassword in the plugin options' }
136 if (['stop', 'close'].includes(e.args.trim().toLowerCase())) {
137 await $.ui.close({ id: PANE }).catch(() => undefined)
138 isOpen = false
139 return { text: 'bluesky closed' }
140 }
141 await read(api((url, init) => $.http.fetch(url, init)))
142 isOpen = true
143 const opened = await $.ui.open({ id: PANE, title: 'bluesky', focus: true }).then(
144 () => true,
145 () => false,
146 )
147 if (!opened) {
148 isOpen = false
149 return { text: 'bsky: the pane could not open here' }
150 }
151 $.ui.invalidate('ui.render')
152 if (error) return { text: `bsky: ${error}` }
153 return { text: `bluesky: ${posts.length} posts from the accounts you follow · /${COMMAND} stop closes` }
154 })
155
156 on('ui.close', async ($, e, next) => {
157 if (e.id !== PANE) return next(e)
158 isOpen = false
159 return next(e)
160 })
161
162 on('ui.input', async ($, e, next) => {
163 if (e.plugin !== $.plugin.name || e.requestId !== PANE || !e.element.startsWith('compose')) return next(e)
164 const r = await next(e)
165 if (e.kind === 'change') {
166 const n = graphemes(e.value)
167 // redraw only when the counter's look changes, not on every key
168 const band = (x: number) => (x === 0 ? 0 : x > MAX_GRAPHEMES ? 3 : x > MAX_GRAPHEMES - 20 ? 2 : 1)
169 const changed = band(n) !== band(draftLength) || band(n) >= 2
170 draftLength = n
171 if (changed) $.ui.invalidate('ui.render')
172 return r
173 }
174 const body = e.value.trim()
175 if (!body || isPosting) return r
176 if (graphemes(body) > MAX_GRAPHEMES) {
177 notice = { text: `Too long: ${graphemes(body)}/${MAX_GRAPHEMES} characters`, tone: 'error' }
178 $.ui.invalidate('ui.render')
179 return r
180 }
181 isPosting = true
182 $.ui.invalidate('ui.render')
183 try {
184 const c = api((url, init) => $.http.fetch(url, init))
185 await c.post(body)
186 notice = { text: 'Posted', tone: 'success' }
187 composeGeneration++
188 draftLength = 0
189 $.ui.toast('🦋 posted to Bluesky')
190 await read(c)
191 } catch (err) {
192 notice = { text: `Not posted: ${message(err)}`, tone: 'error' }
193 } finally {
194 isPosting = false
195 }
196 $.ui.invalidate('ui.render')
197 return r
198 })
199
200 on('ui.press', async ($, e, next) => {
201 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
202 const r = await next(e)
203 const target = e.element
204 const c = api((url, init) => $.http.fetch(url, init))
205 if (target === 'close') {
206 await $.ui.close({ id: PANE }).catch(() => undefined)
207 return r
208 }
209 if (target === 'refresh') {
210 notice = undefined
211 if (!isBusy) await read(c)
212 } else if (target === 'more') {
213 if (!isBusy && cursor) await read(c, true)
214 } else if (target.startsWith('like:')) {
215 const p = posts.find(x => x.cid === target.slice(5))
216 if (p && p.likeUri !== 'pending') {
217 // optimistic: draw the heart now, put it back if Bluesky refuses
218 const before = { likeUri: p.likeUri, likeCount: p.likeCount }
219 const wasLiked = Boolean(p.likeUri)
220 p.likeUri = wasLiked ? undefined : 'pending'
221 p.likeCount = Math.max(0, p.likeCount + (wasLiked ? -1 : 1))
222 $.ui.invalidate('ui.render')
223 try {
224 if (wasLiked) await c.unlike(before.likeUri!)
225 else p.likeUri = await c.like(p)
226 } catch (err) {
227 Object.assign(p, before)
228 notice = { text: `${wasLiked ? 'Unlike' : 'Like'} failed: ${message(err)}`, tone: 'error' }
229 if (err instanceof BskyError && err.status === 401) error = err.message
230 }
231 }
232 }
233 $.ui.invalidate('ui.render')
234 return r
235 })
236
237 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
238 if (e.requestId !== PANE) return next(e)
239 // a pane the engine kept open across a reload or a resume: read once, poll again
240 if (isConfigured && !isOpen) {
241 isOpen = true
242 if (lastReadAt === 0 && !isBusy) void read(api((url, init) => $.http.fetch(url, init))).then(() => $.ui.invalidate('ui.render'))
243 }
244 const els = $.ui.resolve(e)
245 const { Box, Text, Button } = els
246 // mobile draws no field and no link: there the timeline is read-only
247 const Input = 'Input' in els ? els.Input : undefined
248 const Link = 'Link' in els ? els.Link : undefined
249 const W = Math.max(30, e.props.bodyColumns - 1)
250 const now = Date.now()
251 const noop = () => {}
252 const me = client?.session?.handle ?? handle
253 const counter = MAX_GRAPHEMES - draftLength
254 const counterColor = counter < 0 ? 'error' : counter < 20 ? 'warning' : GREY
255
256 const rows = posts.map(p => {
257 const isLiked = Boolean(p.likeUri)
258 const lines = p.text.split('\n').filter((l, i, all) => l.trim() !== '' || (i > 0 && i < all.length - 1))
259 return (
260 <Box key={`p:${p.uri}`} flexDirection="column" width={W}>
261 {p.repostBy ? <Text color={GREEN} wrap="truncate-end">{nb(`↻ reposted by ${p.repostBy}`)}</Text> : null}
262 <Text wrap="truncate-end">
263 <Text color={SKY} bold>{p.name || p.handle}</Text>
264 <Text color={GREY}>{nb(` @${p.handle} · ${ago(p.createdAt, now)}`)}</Text>
265 </Text>
266 {p.replyTo ? <Text color={SKY} dimColor wrap="truncate-end">{nb(`↳ replying to @${p.replyTo}`)}</Text> : null}
267 {lines.map((l, i) => (
268 <Text key={`l:${i}`}>{l || ' '}</Text>
269 ))}
270 {p.embed ? <Text color={GREY} wrap="truncate-end">{p.embed}</Text> : null}
271 <Box flexDirection="row" columnGap={2}>
272 <Box flexDirection="row">
273 <Text color={isLiked ? PINK : GREY}>{nb(isLiked ? '♥ ' : '♡ ')}</Text>
274 {/* a label string, not rich children: Claude Code 2.1.287 (and the Desktop build) skips the whole render on those */}
275 <Button key={`like:${p.cid}`} plain dimColor={!isLiked} label={compact(p.likeCount)} onPress={noop} />
276 </Box>
277 <Text color={GREY}>{nb(`↩ ${compact(p.replyCount)}`)}</Text>
278 <Text color={GREY}>{nb(`↻ ${compact(p.repostCount)}`)}</Text>
279 {Link ? <Link href={postUrl(p)} label="open ↗" /> : null}
280 </Box>
281 <Text color={RULE}>{'─'.repeat(W)}</Text>
282 </Box>
283 )
284 })
285
286 return (
287 <Box flexDirection="column" width={W}>
288 <Box key="head" flexDirection="column" width={W}>
289 <Box flexDirection="row" justifyContent="space-between" width={W}>
290 <Text>
291 <Text backgroundColor={SKY} color={INK} bold>{nb(' 🦋 BLUESKY ')}</Text>
292 <Text backgroundColor="#1b2a41" color="#e6f0ff" bold>{nb(' FOLLOWING ')}</Text>
293 </Text>
294 <Text color={GREY}>{nb([`@${me}`, isBusy ? 'updating…' : lastReadAt ? `updated ${ago(new Date(lastReadAt).toISOString(), now)}` : ''].filter(Boolean).join(' · '))}</Text>
295 </Box>
296 <Text color={RULE}>{'─'.repeat(W)}</Text>
297 {error ? <Text color="error">{`! ${error}`}</Text> : null}
298 {notice ? <Text color={notice.tone}>{`${notice.tone === 'success' ? '✓' : '!'} ${notice.text}`}</Text> : null}
299 </Box>
300
301 {Input ? (
302 <Box key="compose-box" flexDirection="column" width={W} marginBottom={1}>
303 <Input
304 key={`compose:${composeGeneration}`}
305 label="✎ post"
306 placeholder="What's up?"
307 submitLabel={isPosting ? 'posting…' : 'post'}
308 onSubmit={noop}
309 />
310 <Text color={counterColor}>{nb(`${counter} left · Enter posts · links and #tags are clickable`)}</Text>
311 </Box>
312 ) : null}
313
314 {posts.length === 0 && !error ? <Text color={GREY}>{isBusy ? 'Loading your timeline…' : 'Nothing here yet: follow a few accounts on bsky.app'}</Text> : null}
315 {rows}
316
317 <Box key="foot" flexDirection="column" width={W}>
318 <Box flexDirection="row" columnGap={1}>
319 {cursor && posts.length < MAX_POSTS ? <Button key="more" label="more" hotkey="m" onPress={noop} /> : null}
320 <Button key="refresh" label="refresh" hotkey="r" onPress={noop} />
321 <Button key="close" label="close" onPress={noop} />
322 </Box>
323 <Text color={GREY}>{nb(`${posts.length} posts · refreshes every ${Math.round(pollMs / 1000)}s while open`)}</Text>
324 </Box>
325 </Box>
326 )
327 })
328}
329hooks/bsky-api.ts 313 lines1/**
2 * Bluesky's XRPC API, the parts bluesky-feed uses. Pure functions and one
3 * small client over an injected fetch, so the tests drive it without a
4 * network and the hooks module passes `$.http.fetch`.
5 *
6 * Shapes checked against the lexicons shipped in @atproto/api 0.28:
7 * com.atproto.server.createSession / refreshSession, app.bsky.feed.getTimeline
8 * (FeedViewPost, PostView, ViewerState, ReasonRepost), app.bsky.feed.post,
9 * app.bsky.feed.like, app.bsky.richtext.facet, com.atproto.repo.createRecord /
10 * deleteRecord.
11 */
12
13export type Fetch = (
14 url: string,
15 init?: { method?: string; headers?: Record<string, string>; body?: string },
16) => Promise<{ status: number; ok: boolean; text: string }>
17
18export type Session = { did: string; handle: string; accessJwt: string; refreshJwt: string }
19
20export type Post = {
21 uri: string
22 cid: string
23 handle: string
24 name: string
25 text: string
26 createdAt: string
27 likeCount: number
28 repostCount: number
29 replyCount: number
30 /** The viewer's like record, when they liked it: what an unlike deletes. */
31 likeUri?: string
32 /** Who reposted it into the timeline. */
33 repostBy?: string
34 /** The handle of the post this one answers. */
35 replyTo?: string
36 /** One line describing images, a video, a link card or a quoted post. */
37 embed?: string
38}
39
40export const MAX_GRAPHEMES = 300
41
42export class BskyError extends Error {
43 readonly status: number
44 readonly code: string | undefined
45
46 constructor(message: string, status: number, code?: string) {
47 super(message)
48 this.status = status
49 this.code = code
50 }
51}
52
53type Json = Record<string, unknown>
54const obj = (v: unknown): Json => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : {})
55const str = (v: unknown): string => (typeof v === 'string' ? v : '')
56const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
57
58export const clampInt = (v: unknown, fallback: number, min: number, max: number): number => {
59 const n = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : fallback
60 return Math.min(max, Math.max(min, n))
61}
62
63/**
64 * `https://bsky.social` from whatever the option holds; never a trailing slash.
65 * https only, so the app password never travels in cleartext; plain http is
66 * kept for a PDS on this machine (localhost, 127.0.0.1), as in development.
67 */
68export const serviceUrl = (v: unknown): string => {
69 const s = str(v).trim().replace(/\/+$/, '')
70 if (!s) return 'https://bsky.social'
71 if (/^https:\/\//.test(s)) return s
72 if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(s)) return s
73 if (/^[a-z]+:\/\//i.test(s)) return 'https://bsky.social'
74 return `https://${s}`
75}
76
77/** `@dan.bsky.social` and `dan.bsky.social` are the same identifier. */
78export const handleOf = (v: unknown): string => str(v).trim().replace(/^@/, '')
79
80export const rkeyOf = (uri: string): string => uri.split('/').pop() ?? ''
81
82export const postUrl = (p: Pick<Post, 'uri' | 'handle'>): string =>
83 `https://bsky.app/profile/${p.handle}/post/${rkeyOf(p.uri)}`
84
85export function graphemes(text: string): number {
86 const Seg = (Intl as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment: (s: string) => Iterable<unknown> } }).Segmenter
87 if (Seg) {
88 let n = 0
89 for (const _ of new Seg(undefined, { granularity: 'grapheme' }).segment(text)) n++
90 return n
91 }
92 return [...text].length
93}
94
95type Facet = { index: { byteStart: number; byteEnd: number }; features: Json[] }
96
97/**
98 * Links and hashtags as richtext facets, so they are clickable on bsky.app.
99 * Offsets are UTF-8 byte offsets. Mentions are left plain: they need the
100 * handle resolved to a DID, one more request per name.
101 */
102export function facetsOf(text: string): Facet[] {
103 const enc = new TextEncoder()
104 const bytes = (s: string) => enc.encode(s).length
105 const out: Facet[] = []
106 for (const m of text.matchAll(/https?:\/\/[^\s<>"]+/g)) {
107 const uri = m[0].replace(/[.,;:!?)\]}'"]+$/, '')
108 const start = bytes(text.slice(0, m.index))
109 out.push({ index: { byteStart: start, byteEnd: start + bytes(uri) }, features: [{ $type: 'app.bsky.richtext.facet#link', uri }] })
110 }
111 for (const m of text.matchAll(/(^|\s)#([^\s#.,;:!?)\]}'"]+)/g)) {
112 // Bluesky does not treat a tag of digits alone (#1) as a hashtag
113 if (/^\d+$/.test(m[2]!)) continue
114 const at = (m.index ?? 0) + m[1]!.length
115 const start = bytes(text.slice(0, at))
116 out.push({ index: { byteStart: start, byteEnd: start + bytes(`#${m[2]}`) }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: m[2] }] })
117 }
118 return out.sort((a, b) => a.index.byteStart - b.index.byteStart)
119}
120
121function describeEmbed(v: unknown): string | undefined {
122 const e = obj(v)
123 const type = str(e.$type)
124 if (type.startsWith('app.bsky.embed.images')) {
125 const n = Array.isArray(e.images) ? e.images.length : 1
126 return n === 1 ? '▣ image' : `▣ ${n} images`
127 }
128 if (type.startsWith('app.bsky.embed.video')) return '▶ video'
129 if (type.startsWith('app.bsky.embed.external')) {
130 const x = obj(e.external)
131 return `🔗 ${str(x.title) || str(x.uri)}`
132 }
133 if (type.startsWith('app.bsky.embed.recordWithMedia')) {
134 return [describeEmbed(e.media), describeEmbed(e.record)].filter(Boolean).join(' · ')
135 }
136 if (type.startsWith('app.bsky.embed.record')) {
137 const r = obj(obj(e.record).record ?? e.record)
138 const author = str(obj(r.author).handle)
139 return author ? `❝ quoting @${author}` : '❝ quote'
140 }
141 return undefined
142}
143
144/** The timeline's FeedViewPosts as Posts; anything malformed is dropped. */
145export function parseTimeline(text: string): { posts: Post[]; cursor?: string } {
146 const body = obj(JSON.parse(text))
147 const feed = Array.isArray(body.feed) ? body.feed : []
148 const posts: Post[] = []
149 for (const item of feed) {
150 const it = obj(item)
151 const p = obj(it.post)
152 const author = obj(p.author)
153 const record = obj(p.record)
154 if (!str(p.uri) || !str(p.cid) || !str(author.handle)) continue
155 const reason = obj(it.reason)
156 const parent = obj(obj(it.reply).parent)
157 posts.push({
158 uri: str(p.uri),
159 cid: str(p.cid),
160 handle: str(author.handle),
161 name: str(author.displayName).trim(),
162 text: str(record.text),
163 createdAt: str(record.createdAt) || str(p.indexedAt),
164 likeCount: num(p.likeCount),
165 repostCount: num(p.repostCount),
166 replyCount: num(p.replyCount),
167 likeUri: str(obj(p.viewer).like) || undefined,
168 repostBy: str(reason.$type).endsWith('#reasonRepost') ? str(obj(reason.by).displayName).trim() || str(obj(reason.by).handle) : undefined,
169 replyTo: str(obj(parent.author).handle) || undefined,
170 embed: describeEmbed(p.embed),
171 })
172 }
173 const cursor = str(body.cursor) || undefined
174 return { posts, cursor }
175}
176
177/** `now`, `5m`, `3h`, `2d`, then the date. */
178export function ago(iso: string, now: number): string {
179 const t = Date.parse(iso)
180 if (!Number.isFinite(t)) return ''
181 const s = Math.max(0, Math.round((now - t) / 1000))
182 if (s < 60) return 'now'
183 if (s < 3600) return `${Math.floor(s / 60)}m`
184 if (s < 86_400) return `${Math.floor(s / 3600)}h`
185 if (s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
186 return new Date(t).toISOString().slice(0, 10)
187}
188
189/** 1234 -> 1.2K; the pane is narrow. */
190export const compact = (n: number): string =>
191 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K` : String(n)
192
193/**
194 * One account's session over XRPC. Logs in on first use with the app
195 * password, refreshes an expired access token with the refresh token, and
196 * logs in again when the refresh is refused. The password and the tokens stay
197 * in memory, are sent only to `service`, and never appear in an error.
198 */
199export class Client {
200 session: Session | undefined
201 private pendingLogin: Promise<Session> | undefined
202 private readonly fetch: Fetch
203 private readonly service: string
204 private readonly identifier: string
205 private readonly password: string
206
207 constructor(fetch: Fetch, service: string, identifier: string, password: string) {
208 this.fetch = fetch
209 this.service = service
210 this.identifier = identifier
211 this.password = password
212 }
213
214 private async xrpc(nsid: string, opts: { method?: 'GET' | 'POST'; token?: string; body?: unknown; params?: Record<string, string> }): Promise<Json> {
215 const qs = opts.params ? `?${new URLSearchParams(opts.params).toString()}` : ''
216 const headers: Record<string, string> = {}
217 if (opts.token) headers.authorization = `Bearer ${opts.token}`
218 if (opts.body !== undefined) headers['content-type'] = 'application/json'
219 const res = await this.fetch(`${this.service}/xrpc/${nsid}${qs}`, {
220 method: opts.method ?? 'GET',
221 headers,
222 body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
223 })
224 let data: Json = {}
225 try {
226 data = obj(res.text ? JSON.parse(res.text) : {})
227 } catch {
228 data = {}
229 }
230 if (!res.ok) throw new BskyError(str(data.message) || str(data.error) || `HTTP ${res.status}`, res.status, str(data.error) || undefined)
231 return data
232 }
233
234 private take(d: Json): Session {
235 const s = { did: str(d.did), handle: str(d.handle), accessJwt: str(d.accessJwt), refreshJwt: str(d.refreshJwt) }
236 if (!s.did || !s.accessJwt) throw new BskyError('Bluesky answered the login without a session', 500)
237 this.session = s
238 return s
239 }
240
241 /** One login at a time: a poll, a press and a post racing share it. */
242 login(): Promise<Session> {
243 this.pendingLogin ??= this.createSession().finally(() => {
244 this.pendingLogin = undefined
245 })
246 return this.pendingLogin
247 }
248
249 private async createSession(): Promise<Session> {
250 try {
251 return this.take(await this.xrpc('com.atproto.server.createSession', { method: 'POST', body: { identifier: this.identifier, password: this.password } }))
252 } catch (err) {
253 if (err instanceof BskyError && (err.status === 401 || err.code === 'AuthenticationRequired'))
254 throw new BskyError('Bluesky refused the handle or app password', 401, err.code)
255 throw err
256 }
257 }
258
259 private async refresh(): Promise<Session> {
260 const s = this.session
261 if (!s) return this.login()
262 try {
263 return this.take(await this.xrpc('com.atproto.server.refreshSession', { method: 'POST', token: s.refreshJwt }))
264 } catch (err) {
265 // a refused refresh token means log in again; an outage is reported as is
266 if (err instanceof BskyError && (err.status === 400 || err.status === 401)) return this.login()
267 throw err
268 }
269 }
270
271 /** A call with the access token, refreshed once if Bluesky says it expired. */
272 async call(nsid: string, opts: { method?: 'GET' | 'POST'; body?: unknown; params?: Record<string, string> } = {}): Promise<Json> {
273 const s = this.session ?? (await this.login())
274 try {
275 return await this.xrpc(nsid, { ...opts, token: s.accessJwt })
276 } catch (err) {
277 const expired = err instanceof BskyError && (err.code === 'ExpiredToken' || err.code === 'InvalidToken' || err.status === 401)
278 if (!expired) throw err
279 const fresh = await this.refresh()
280 return this.xrpc(nsid, { ...opts, token: fresh.accessJwt })
281 }
282 }
283
284 async timeline(limit: number, cursor?: string): Promise<{ posts: Post[]; cursor?: string }> {
285 const params: Record<string, string> = { limit: String(limit) }
286 if (cursor) params.cursor = cursor
287 return parseTimeline(JSON.stringify(await this.call('app.bsky.feed.getTimeline', { params })))
288 }
289
290 async post(text: string, now = new Date()): Promise<{ uri: string; cid: string }> {
291 const s = this.session ?? (await this.login())
292 const facets = facetsOf(text)
293 const record: Json = { $type: 'app.bsky.feed.post', text, createdAt: now.toISOString() }
294 if (facets.length > 0) record.facets = facets
295 const d = await this.call('com.atproto.repo.createRecord', { method: 'POST', body: { repo: s.did, collection: 'app.bsky.feed.post', record } })
296 return { uri: str(d.uri), cid: str(d.cid) }
297 }
298
299 async like(p: Pick<Post, 'uri' | 'cid'>, now = new Date()): Promise<string> {
300 const s = this.session ?? (await this.login())
301 const d = await this.call('com.atproto.repo.createRecord', {
302 method: 'POST',
303 body: { repo: s.did, collection: 'app.bsky.feed.like', record: { $type: 'app.bsky.feed.like', subject: { uri: p.uri, cid: p.cid }, createdAt: now.toISOString() } },
304 })
305 return str(d.uri)
306 }
307
308 async unlike(likeUri: string): Promise<void> {
309 const s = this.session ?? (await this.login())
310 await this.call('com.atproto.repo.deleteRecord', { method: 'POST', body: { repo: s.did, collection: 'app.bsky.feed.like', rkey: rkeyOf(likeUri) } })
311 }
312}
313