SLOPSHOPPER

bluesky-feed

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…

panecommandtoastnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bluesky-feed
│ ┃ bsky ✕ › fix the failing auth test and add an audit log call │ ┃  🦋 BLUESKY  FOLLOWING  │ ┃ ──────────────────────────────────────────── ● bluesky-feed: bluesky-feed: set handle and appPassword in the plugi │ ┃ ✎ post: What's up? ⏎ post ⏺ Read(src/auth.ts) │ ┃ 300 left · Enter posts · links and #tags are ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing here yet: follow a few accounts on b ⎿ Added 2 lines, removed 1 line │ ┃ [ refresh ] [ close ] ⏺ Bash(bun test) │ ┃ 0 posts · refreshes every 120s while open ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bsky │ ⎿ bluesky-feed: bsky: set handle and appPassword in the plugin opt │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · bsky
 🦋 BLUESKY  FOLLOWING  @ ─────────────────────────────────────────────────────── ✎ post: What's up? ⏎ post 300 left · Enter posts · links and #tags are clickable Nothing here yet: follow a few accounts on bsky.app [ refresh ] [ close ] 0 posts · refreshes every 120s while open
README

bluesky-feed

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).

  • Timeline: your Following timeline (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.
  • Post: type in the field and press Enter. The counter turns yellow near Bluesky's 300-character limit and red past it. Links and #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.
  • Like: press the count next to the heart to like a post. Press it again to unlike, which deletes your like record. The heart turns pink right away and goes back if Bluesky refuses.
  • open ↗ opens the post on bsky.app.

On Claude Code's mobile surface the pane is read-only: the mobile surface draws no input field.

Why Bluesky

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.

Scope and secrets

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.

Options

  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.

Install

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

Source 2 files
hooks/bluesky-feed.tsx 329 lines
1/**
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}
329
hooks/bsky-api.ts 313 lines
1/**
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