Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫
Something went wrong. Try again.
8.4 kB · 295 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296import { type AppBskyFeedDefs, type AppBskyGraphDefs, type AtpAgent, type ComAtprotoRepoStrongRef,} from '@atproto/api'import {AtUri} from '@atproto/api'
import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'import {downloadAndResize} from '#/lib/media/manip'import { createStarterPackUri, parseStarterPackUri,} from '#/lib/strings/starter-pack'import { convertBskyAppUrlIfNeeded, getChatInviteCodeFromUrl, isBskyCustomFeedUrl, isBskyListUrl, isBskyPostUrl, isBskyStarterPackUrl, isBskyStartUrl, isShortLink, makeRecordUri,} from '#/lib/strings/url-helpers'import {type ComposerImage} from '#/state/gallery'import {createComposerImage} from '#/state/gallery'import {type ChatInvitePreview} from '#/state/queries/join-links'import {type Gif} from '#/features/gifPicker/types'import {createGIFDescription} from '../gif-alt-text'
type ResolvedExternalLink = { type: 'external' uri: string title: string description: string thumb: ComposerImage | undefined /** * The AT-URI of the Atmosphere record representing this external content, if * it exists. Example: a site.standard.document record. */ associatedRefs?: LinkMeta['associatedRefs'] view?: LinkMeta['view']}
type ResolvedPostRecord = { type: 'record' record: ComAtprotoRepoStrongRef.Main kind: 'post' view: AppBskyFeedDefs.PostView}
type ResolvedFeedRecord = { type: 'record' record: ComAtprotoRepoStrongRef.Main kind: 'feed' view: AppBskyFeedDefs.GeneratorView}
type ResolvedListRecord = { type: 'record' record: ComAtprotoRepoStrongRef.Main kind: 'list' view: AppBskyGraphDefs.ListView}
type ResolvedStarterPackRecord = { type: 'record' record: ComAtprotoRepoStrongRef.Main kind: 'starter-pack' view: AppBskyGraphDefs.StarterPackView}
type ResolvedChatInvite = { type: 'chat-invite' uri: string code: string view?: ChatInvitePreview}
export type ResolvedLink = | ResolvedExternalLink | ResolvedPostRecord | ResolvedFeedRecord | ResolvedListRecord | ResolvedStarterPackRecord | ResolvedChatInvite
export class EmbeddingDisabledError extends Error { constructor() { super('Embedding is disabled for this record') }}
export async function resolveLink( agent: AtpAgent, uri: string,): Promise<ResolvedLink> { let resolvedUri = uri if (isShortLink(resolvedUri)) { resolvedUri = await resolveShortLink(resolvedUri) } if (isBskyPostUrl(uri)) { uri = convertBskyAppUrlIfNeeded(uri) const [_0, user, _1, rkey] = uri.split('/').filter(Boolean) const recordUri = makeRecordUri(user, 'app.bsky.feed.post', rkey) const post = await getPost({uri: recordUri}) if (post.viewer?.embeddingDisabled) { throw new EmbeddingDisabledError() } return { type: 'record', record: { cid: post.cid, uri: post.uri, }, kind: 'post', view: post, } } if (isBskyCustomFeedUrl(resolvedUri)) { resolvedUri = convertBskyAppUrlIfNeeded(resolvedUri) const [_0, handleOrDid, _1, rkey] = resolvedUri.split('/').filter(Boolean) const did = await fetchDid(handleOrDid) const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) const res = await agent.app.bsky.feed.getFeedGenerator({feed}) return { type: 'record', record: { uri: res.data.view.uri, cid: res.data.view.cid, }, kind: 'feed', view: res.data.view, } } if (isBskyListUrl(resolvedUri)) { resolvedUri = convertBskyAppUrlIfNeeded(resolvedUri) const [_0, handleOrDid, _1, rkey] = resolvedUri.split('/').filter(Boolean) const did = await fetchDid(handleOrDid) const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) const res = await agent.app.bsky.graph.getList({list}) return { type: 'record', record: { uri: res.data.list.uri, cid: res.data.list.cid, }, kind: 'list', view: res.data.list, } } const chatInviteCode = getChatInviteCodeFromUrl(uri) if (chatInviteCode) { const res = await agent.chat.bsky.group.getJoinLinkPreviews( {codes: [chatInviteCode]}, {headers: DM_SERVICE_HEADERS}, ) return { type: 'chat-invite', uri, code: chatInviteCode, view: res.data.joinLinkPreviews[0], } } if (isBskyStartUrl(resolvedUri) || isBskyStarterPackUrl(resolvedUri)) { const parsed = parseStarterPackUri(resolvedUri) if (!parsed) { throw new Error( 'Unexpectedly called getStarterPackAsEmbed with a non-starterpack url', ) } const did = await fetchDid(parsed.name) const starterPack = createStarterPackUri({did, rkey: parsed.rkey}) const res = await agent.app.bsky.graph.getStarterPack({starterPack}) return { type: 'record', record: { uri: res.data.starterPack.uri, cid: res.data.starterPack.cid, }, kind: 'starter-pack', view: res.data.starterPack, } } return resolveExternal(agent, resolvedUri)
// Forked from useGetPost. TODO: move into RQ. async function getPost({uri}: {uri: string}) { const urip = new AtUri(uri) if (!urip.host.startsWith('did:')) { const res = await agent.resolveHandle({ handle: urip.host, }) // @ts-expect-error TODO new-sdk-migration urip.host = res.data.did } const res = await agent.getPosts({ uris: [urip.toString()], }) if (res.success && res.data.posts[0]) { return res.data.posts[0] } throw new Error('getPost: post not found') }
// Forked from useFetchDid. TODO: move into RQ. async function fetchDid(handleOrDid: string) { let identifier = handleOrDid if (!identifier.startsWith('did:')) { const res = await agent.resolveHandle({handle: identifier}) identifier = res.data.did } return identifier }}
export async function resolveGif( agent: AtpAgent, gif: Gif,): Promise<ResolvedExternalLink> { const gifUrl = gif.media_formats.gif.url const params = new URLSearchParams() params.set('hh', String(gif.media_formats.gif.dims[1])) params.set('ww', String(gif.media_formats.gif.dims[0]))
// For Klipy GIFs, embed video format slugs so parseKlipyGif can // swap to the right format per platform at render time. Klipy uses // different filename slugs per format (unlike Tenor where format is // encoded in the URL ID), so this info must travel with the URL. try { const url = new URL(gifUrl) if (url.hostname === 'static.klipy.com') { const mp4Slug = getFileSlug(gif.media_formats.mp4?.url) const webmSlug = getFileSlug(gif.media_formats.webm?.url) if (mp4Slug) params.set('mp4', mp4Slug) if (webmSlug) params.set('webm', webmSlug) } } catch {}
const uri = `${gifUrl}?${params.toString()}` const altText = gif.content_description || gif.title return { type: 'external', uri, title: altText, description: createGIFDescription(altText), thumb: await imageToThumb(gif.media_formats.preview.url), }}
function getFileSlug(url: string | undefined): string | undefined { if (!url) return undefined const filename = url.split('/').pop() if (!filename) return undefined const dotIndex = filename.lastIndexOf('.') return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined}
async function resolveExternal( agent: AtpAgent, uri: string,): Promise<ResolvedExternalLink> { const result = await getLinkMeta(agent, uri) return { type: 'external', uri: result.url, title: result.title ?? '', description: result.description ?? '', thumb: result.image ? await imageToThumb(result.image) : undefined, /* * New fields from Standard Site integration. Other fields are derived from * opengraph/oembed as before. */ associatedRefs: result.associatedRefs, view: result.view, }}
export async function imageToThumb( imageUri: string,): Promise<ComposerImage | undefined> { try { const img = await downloadAndResize({ uri: imageUri, ...IMAGE_SIZE_CONFIG_2K_1MB, timeout: 15e3, }) if (img) { return await createComposerImage(img) } } catch {}}