Something went wrong. Try again.
Bluesky app fork with some witchin' additions 馃挮 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
12 kB 路 399 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400import {type Client} from '@atproto/lex'import { type AtIdentifierString, AtUri, type AtUriString, type HandleString,} from '@atproto/syntax'
import {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 { getColorSets, THEME_COLLECTION, type ThemeRecord,} from '#/features/themes/types'import {parseThemeUrl} from '#/features/themes/urls'import {app, chat, com} from '#/lexicons'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: com.atproto.repo.strongRef.Main kind: 'post' view: app.bsky.feed.defs.PostView}
type ResolvedFeedRecord = { type: 'record' record: com.atproto.repo.strongRef.Main kind: 'feed' view: app.bsky.feed.defs.GeneratorView}
type ResolvedListRecord = { type: 'record' record: com.atproto.repo.strongRef.Main kind: 'list' view: app.bsky.graph.defs.ListView}
type ResolvedStarterPackRecord = { type: 'record' record: com.atproto.repo.strongRef.Main kind: 'starter-pack' view: app.bsky.graph.defs.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') }}
/** * The clients a link resolution may need. * * Everything but the chat-invite branch is an appview read, and the chat invite * preview goes through the chat client so it is proxied to the chat service. * Both are passed together because the caller cannot know which branch a URL * will take until it is parsed. */export type LinkResolvers = { appviewClient: Client chatClient: Client}
export async function resolveLink( {appviewClient, chatClient}: LinkResolvers, 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 data = await appviewClient.call(app.bsky.feed.getFeedGenerator, { feed: feed, }) return { type: 'record', record: { uri: data.view.uri, cid: data.view.cid, }, kind: 'feed', view: 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 data = await appviewClient.call(app.bsky.graph.getList, { list: list, }) return { type: 'record', record: { uri: data.list.uri, cid: data.list.cid, }, kind: 'list', view: data.list, } } const chatInviteCode = getChatInviteCodeFromUrl(uri) if (chatInviteCode) { const data = await chatClient.call(chat.bsky.group.getJoinLinkPreviews, { codes: [chatInviteCode], }) return { type: 'chat-invite', uri, code: chatInviteCode, view: 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 data = await appviewClient.call(app.bsky.graph.getStarterPack, { starterPack: starterPack as AtUriString, }) return { type: 'record', record: { uri: data.starterPack.uri, cid: data.starterPack.cid, }, kind: 'starter-pack', view: data.starterPack, } } const themeRoute = parseThemeUrl(resolvedUri) if (themeRoute) { try { return await resolveThemeExternal(appviewClient, resolvedUri, themeRoute) } catch { // The deployed page metadata remains a safe fallback if direct record // resolution is temporarily unavailable. } } return resolveExternal(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 data = await appviewClient.call( com.atproto.identity.resolveHandle, {handle: urip.host as HandleString}, ) urip.host = data.did } const data = await appviewClient.call(app.bsky.feed.getPosts, { uris: [urip.toString()], }) if (data.posts[0]) { return 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 data = await appviewClient.call( com.atproto.identity.resolveHandle, {handle: identifier as HandleString}, ) identifier = data.did } return identifier }}
function didWebUrl(did: string) { const parts = did.slice('did:web:'.length).split(':').map(decodeURIComponent) const host = parts.shift() return parts.length ? `https://${host}/${parts.join('/')}/did.json` : `https://${host}/.well-known/did.json`}
async function resolveThemeExternal( appviewClient: Client, uri: string, route: {name: string; rkey: string},): Promise<ResolvedExternalLink> { let did = route.name if (!did.startsWith('did:')) { const resolved = await appviewClient.call( com.atproto.identity.resolveHandle, {handle: did as HandleString}, ) did = resolved.did } const didDocumentUrl = did.startsWith('did:web:') ? didWebUrl(did) : `https://plc.directory/${encodeURIComponent(did)}` const didDocumentResponse = await fetch(didDocumentUrl) if (!didDocumentResponse.ok) throw new Error('DID document not found') const didDocument = (await didDocumentResponse.json()) as { service?: {id: string; serviceEndpoint: string}[] } const pds = didDocument.service?.find(service => service.id.endsWith('#atproto_pds'), )?.serviceEndpoint if (!pds) throw new Error('PDS not found') const recordUrl = new URL('/xrpc/com.atproto.repo.getRecord', pds) recordUrl.searchParams.set('repo', did) recordUrl.searchParams.set('collection', THEME_COLLECTION) recordUrl.searchParams.set('rkey', route.rkey) const response = await fetch(recordUrl) if (!response.ok) throw new Error('Theme not found') const result = (await response.json()) as { uri: string cid: string value: ThemeRecord } if (!result.value?.name || !result.value.base?.colors) { throw new Error('Invalid theme') } let author = route.name try { const profile = await appviewClient.call(app.bsky.actor.getProfile, { actor: did as AtIdentifierString, }) author = profile.handle } catch {} const count = getColorSets(result.value).length const description = result.value.description ?? `${count} ${count === 1 ? 'variant' : 'variants'} 路 @${author.replace(/^@/, '')}` const page = new URL(uri) const thumbnail = new URL( `/theme-og/${encodeURIComponent(route.name)}/${encodeURIComponent(route.rkey)}`, page.origin, ) return { type: 'external', uri, title: result.value.name, description, thumb: await imageToThumb(thumbnail.toString()), associatedRefs: [{uri: result.uri as AtUriString, cid: result.cid}], }}
export async function resolveGif(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(uri: string): Promise<ResolvedExternalLink> { const result = await getLinkMeta(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 {}}