Bluesky app fork with some witchin' additions 馃挮
1import {
2 type AppBskyFeedDefs,
3 type AppBskyGraphDefs,
4 type BskyAgent,
5 type ComAtprotoRepoStrongRef,
6} from '@atproto/api'
7import {AtUri} from '@atproto/api'
8
9import {POST_IMG_MAX} from '#/lib/constants'
10import {getLinkMeta} from '#/lib/link-meta/link-meta'
11import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
12import {downloadAndResize} from '#/lib/media/manip'
13import {
14 createStarterPackUri,
15 parseStarterPackUri,
16} from '#/lib/strings/starter-pack'
17import {
18 convertBskyAppUrlIfNeeded,
19 isBskyCustomFeedUrl,
20 isBskyListUrl,
21 isBskyPostUrl,
22 isBskyStarterPackUrl,
23 isBskyStartUrl,
24 isShortLink,
25 makeRecordUri,
26} from '#/lib/strings/url-helpers'
27import {type ComposerImage} from '#/state/gallery'
28import {createComposerImage} from '#/state/gallery'
29import {type Gif} from '#/state/queries/tenor'
30import {createGIFDescription} from '../gif-alt-text'
31
32type ResolvedExternalLink = {
33 type: 'external'
34 uri: string
35 title: string
36 description: string
37 thumb: ComposerImage | undefined
38}
39
40type ResolvedPostRecord = {
41 type: 'record'
42 record: ComAtprotoRepoStrongRef.Main
43 kind: 'post'
44 view: AppBskyFeedDefs.PostView
45}
46
47type ResolvedFeedRecord = {
48 type: 'record'
49 record: ComAtprotoRepoStrongRef.Main
50 kind: 'feed'
51 view: AppBskyFeedDefs.GeneratorView
52}
53
54type ResolvedListRecord = {
55 type: 'record'
56 record: ComAtprotoRepoStrongRef.Main
57 kind: 'list'
58 view: AppBskyGraphDefs.ListView
59}
60
61type ResolvedStarterPackRecord = {
62 type: 'record'
63 record: ComAtprotoRepoStrongRef.Main
64 kind: 'starter-pack'
65 view: AppBskyGraphDefs.StarterPackView
66}
67
68export type ResolvedLink =
69 | ResolvedExternalLink
70 | ResolvedPostRecord
71 | ResolvedFeedRecord
72 | ResolvedListRecord
73 | ResolvedStarterPackRecord
74
75export class EmbeddingDisabledError extends Error {
76 constructor() {
77 super('Embedding is disabled for this record')
78 }
79}
80
81export async function resolveLink(
82 agent: BskyAgent,
83 uri: string,
84): Promise<ResolvedLink> {
85 let resolvedUri = uri
86 if (isShortLink(resolvedUri)) {
87 resolvedUri = await resolveShortLink(resolvedUri)
88 }
89 if (isBskyPostUrl(uri)) {
90 uri = convertBskyAppUrlIfNeeded(uri)
91 const [_0, user, _1, rkey] = uri.split('/').filter(Boolean)
92 const recordUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
93 const post = await getPost({uri: recordUri})
94 if (post.viewer?.embeddingDisabled) {
95 throw new EmbeddingDisabledError()
96 }
97 return {
98 type: 'record',
99 record: {
100 cid: post.cid,
101 uri: post.uri,
102 },
103 kind: 'post',
104 view: post,
105 }
106 }
107 if (isBskyCustomFeedUrl(resolvedUri)) {
108 resolvedUri = convertBskyAppUrlIfNeeded(resolvedUri)
109 const [_0, handleOrDid, _1, rkey] = resolvedUri.split('/').filter(Boolean)
110 const did = await fetchDid(handleOrDid)
111 const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
112 const res = await agent.app.bsky.feed.getFeedGenerator({feed})
113 return {
114 type: 'record',
115 record: {
116 uri: res.data.view.uri,
117 cid: res.data.view.cid,
118 },
119 kind: 'feed',
120 view: res.data.view,
121 }
122 }
123 if (isBskyListUrl(resolvedUri)) {
124 resolvedUri = convertBskyAppUrlIfNeeded(resolvedUri)
125 const [_0, handleOrDid, _1, rkey] = resolvedUri.split('/').filter(Boolean)
126 const did = await fetchDid(handleOrDid)
127 const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
128 const res = await agent.app.bsky.graph.getList({list})
129 return {
130 type: 'record',
131 record: {
132 uri: res.data.list.uri,
133 cid: res.data.list.cid,
134 },
135 kind: 'list',
136 view: res.data.list,
137 }
138 }
139 if (isBskyStartUrl(resolvedUri) || isBskyStarterPackUrl(resolvedUri)) {
140 const parsed = parseStarterPackUri(resolvedUri)
141 if (!parsed) {
142 throw new Error(
143 'Unexpectedly called getStarterPackAsEmbed with a non-starterpack url',
144 )
145 }
146 const did = await fetchDid(parsed.name)
147 const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
148 const res = await agent.app.bsky.graph.getStarterPack({starterPack})
149 return {
150 type: 'record',
151 record: {
152 uri: res.data.starterPack.uri,
153 cid: res.data.starterPack.cid,
154 },
155 kind: 'starter-pack',
156 view: res.data.starterPack,
157 }
158 }
159 return resolveExternal(agent, resolvedUri)
160
161 // Forked from useGetPost. TODO: move into RQ.
162 async function getPost({uri}: {uri: string}) {
163 const urip = new AtUri(uri)
164 if (!urip.host.startsWith('did:')) {
165 const res = await agent.resolveHandle({
166 handle: urip.host,
167 })
168 // @ts-expect-error TODO new-sdk-migration
169 urip.host = res.data.did
170 }
171 const res = await agent.getPosts({
172 uris: [urip.toString()],
173 })
174 if (res.success && res.data.posts[0]) {
175 return res.data.posts[0]
176 }
177 throw new Error('getPost: post not found')
178 }
179
180 // Forked from useFetchDid. TODO: move into RQ.
181 async function fetchDid(handleOrDid: string) {
182 let identifier = handleOrDid
183 if (!identifier.startsWith('did:')) {
184 const res = await agent.resolveHandle({handle: identifier})
185 identifier = res.data.did
186 }
187 return identifier
188 }
189}
190
191export async function resolveGif(
192 agent: BskyAgent,
193 gif: Gif,
194): Promise<ResolvedExternalLink> {
195 const gifUrl = gif.media_formats.gif.url
196 const params = new URLSearchParams()
197 params.set('hh', String(gif.media_formats.gif.dims[1]))
198 params.set('ww', String(gif.media_formats.gif.dims[0]))
199
200 // For Klipy GIFs, embed video format slugs so parseKlipyGif can
201 // swap to the right format per platform at render time. Klipy uses
202 // different filename slugs per format (unlike Tenor where format is
203 // encoded in the URL ID), so this info must travel with the URL.
204 try {
205 const url = new URL(gifUrl)
206 if (url.hostname === 'static.klipy.com') {
207 const mp4Slug = getFileSlug(gif.media_formats.mp4?.url)
208 const webmSlug = getFileSlug(gif.media_formats.webm?.url)
209 if (mp4Slug) params.set('mp4', mp4Slug)
210 if (webmSlug) params.set('webm', webmSlug)
211 }
212 } catch {}
213
214 const uri = `${gifUrl}?${params.toString()}`
215 const altText = gif.content_description || gif.title
216 return {
217 type: 'external',
218 uri,
219 title: altText,
220 description: createGIFDescription(altText),
221 thumb: await imageToThumb(gif.media_formats.preview.url),
222 }
223}
224
225function getFileSlug(url: string | undefined): string | undefined {
226 if (!url) return undefined
227 const filename = url.split('/').pop()
228 if (!filename) return undefined
229 const dotIndex = filename.lastIndexOf('.')
230 return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
231}
232
233async function resolveExternal(
234 agent: BskyAgent,
235 uri: string,
236): Promise<ResolvedExternalLink> {
237 const result = await getLinkMeta(agent, uri)
238 return {
239 type: 'external',
240 uri: result.url,
241 title: result.title ?? '',
242 description: result.description ?? '',
243 thumb: result.image ? await imageToThumb(result.image) : undefined,
244 }
245}
246
247export async function imageToThumb(
248 imageUri: string,
249): Promise<ComposerImage | undefined> {
250 try {
251 const img = await downloadAndResize({
252 uri: imageUri,
253 width: POST_IMG_MAX.width,
254 height: POST_IMG_MAX.height,
255 mode: 'contain',
256 maxSize: POST_IMG_MAX.size,
257 timeout: 15e3,
258 })
259 if (img) {
260 return await createComposerImage(img)
261 }
262 } catch {}
263}