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