forked from
jollywhoppers.com/witchsky.app
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 uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
196 return {
197 type: 'external',
198 uri,
199 title: gif.content_description,
200 description: createGIFDescription(gif.content_description),
201 thumb: await imageToThumb(gif.media_formats.preview.url),
202 }
203}
204
205async function resolveExternal(
206 agent: BskyAgent,
207 uri: string,
208): Promise<ResolvedExternalLink> {
209 const result = await getLinkMeta(agent, uri)
210 return {
211 type: 'external',
212 uri: result.url,
213 title: result.title ?? '',
214 description: result.description ?? '',
215 thumb: result.image ? await imageToThumb(result.image) : undefined,
216 }
217}
218
219export async function imageToThumb(
220 imageUri: string,
221): Promise<ComposerImage | undefined> {
222 try {
223 const img = await downloadAndResize({
224 uri: imageUri,
225 width: POST_IMG_MAX.width,
226 height: POST_IMG_MAX.height,
227 mode: 'contain',
228 maxSize: POST_IMG_MAX.size,
229 timeout: 15e3,
230 })
231 if (img) {
232 return await createComposerImage(img)
233 }
234 } catch {}
235}