Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
3.9 kB · 95 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596// "Share on Bluesky": hand the article to Bluesky's composer and stop.//// Why a compose intent and not a post we write ourselves://// - Posting from here would need `repo:app.bsky.feed.post` in// oauth/client-metadata.json, which means a hosted metadata deploy before// any release that ships it, re-consent for every existing session, and a// failure mode ("this sign-in cannot post") that the feedback form already// demonstrates. The intent link needs no scope and no session at all: it// works signed out.// - A record embed (app.bsky.embed.record with a strongRef) can technically// point at a `site.standard.document`, but the Bluesky appview hydrates// only the record types it knows, so a document would arrive in the feed// as a not-found quote. What the Bluesky client itself builds when you// paste a URL is app.bsky.embed.external — a link card from the page's own// Open Graph tags — and that is exactly what the composer will build from// the URL this link hands it.//// So the text is a title and a URL, and Bluesky does the rest.
import type { DocInfo, PubInfo } from './types'import { pubSiteUrl } from './readers'
/** Bluesky's documented compose intent (docs.bsky.app "Action Intent Links"). */const COMPOSE = 'https://bsky.app/intent/compose'
/** * Bluesky's own limit on a post, which the intent link inherits: 300 * *grapheme clusters*, not code units. A long title is cut rather than * refused, and the URL is what must survive the cut. */export const POST_LIMIT = 300
const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : undefined
export function graphemes(text: string): string[] { if (!segmenter) return [...text] return [...segmenter.segment(text)].map((s) => s.segment)}
/** Cut to `limit` graphemes, marking the cut when there was one. */export function truncate(text: string, limit: number): string { const parts = graphemes(text) if (parts.length <= limit) return text if (limit <= 1) return '…' return `${parts.slice(0, limit - 1).join('').trimEnd()}…`}
/** * The page the share should point at: the article when the popup knows one, * the publication's own site otherwise. `pageUrl` — the tab the popup is open * on — wins over a reconstructed document URL, since it is the URL the reader * actually reached and the one whose Open Graph tags the composer will read. */export function shareUrl(pub: PubInfo, doc?: DocInfo, pageUrl?: string): string | null { if (pageUrl && /^https?:\/\//.test(pageUrl)) return pageUrl const site = pubSiteUrl(pub) if (!site || !doc?.record.path) return site const path = doc.record.path.startsWith('/') ? doc.record.path : `/${doc.record.path}` return site.replace(/\/+$/, '') + path}
/** * What lands in the composer: the article's title (or the publication's name * when the page is not an article) and the link, with the title cut to * whatever room the link leaves. Nothing is posted here — the reader still * has to press post in Bluesky, and can edit every word of it first. */export function shareText(pub: PubInfo, doc?: DocInfo, pageUrl?: string): string | null { const url = shareUrl(pub, doc, pageUrl) if (!url) return null const title = (doc?.record.title ?? pub.record.name).trim() const room = POST_LIMIT - graphemes(url).length - 2 if (room < 8) return url return `${truncate(title, room)}\n\n${url}`}
export function composeUrl(text: string): string { const u = new URL(COMPOSE) u.searchParams.set('text', text) return u.toString()}
/** The whole thing: the link to open, or null when there is nothing to share. */export function shareOnBlueskyUrl( pub: PubInfo, doc?: DocInfo, pageUrl?: string,): string | null { const text = shareText(pub, doc, pageUrl) return text ? composeUrl(text) : null}