mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {
2 AppBskyActorDefs,
3 AppBskyFeedPost,
4 AppBskyGraphStarterpack,
5 ComAtprotoRepoStrongRef,
6} from '@atproto/api'
7import {AtUri} from '@atproto/api'
8import {BskyAgent} from '@atproto/api'
9
10import {POST_IMG_MAX} from '#/lib/constants'
11import {getLinkMeta} from '#/lib/link-meta/link-meta'
12import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
13import {downloadAndResize} from '#/lib/media/manip'
14import {
15 createStarterPackUri,
16 parseStarterPackUri,
17} from '#/lib/strings/starter-pack'
18import {
19 isBskyCustomFeedUrl,
20 isBskyListUrl,
21 isBskyPostUrl,
22 isBskyStarterPackUrl,
23 isBskyStartUrl,
24 isShortLink,
25} from '#/lib/strings/url-helpers'
26import {ComposerImage} from '#/state/gallery'
27import {createComposerImage} from '#/state/gallery'
28import {Gif} from '#/state/queries/tenor'
29import {createGIFDescription} from '../gif-alt-text'
30import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
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 meta: {
45 text: string
46 indexedAt: string
47 author: AppBskyActorDefs.ProfileViewBasic
48 }
49}
50
51type ResolvedOtherRecord = {
52 type: 'record'
53 record: ComAtprotoRepoStrongRef.Main
54 kind: 'other'
55 meta: {
56 // We should replace this with a hydrated record (e.g. feed, list, starter pack)
57 // and change the composer preview to use the actual post embed components:
58 title: string
59 }
60}
61
62export type ResolvedLink =
63 | ResolvedExternalLink
64 | ResolvedPostRecord
65 | ResolvedOtherRecord
66
67export class EmbeddingDisabledError extends Error {
68 constructor() {
69 super('Embedding is disabled for this record')
70 }
71}
72
73export async function resolveLink(
74 agent: BskyAgent,
75 uri: string,
76): Promise<ResolvedLink> {
77 if (isShortLink(uri)) {
78 uri = await resolveShortLink(uri)
79 }
80 if (isBskyPostUrl(uri)) {
81 uri = convertBskyAppUrlIfNeeded(uri)
82 const [_0, user, _1, rkey] = uri.split('/').filter(Boolean)
83 const recordUri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
84 const post = await getPost({uri: recordUri})
85 if (post.viewer?.embeddingDisabled) {
86 throw new EmbeddingDisabledError()
87 }
88 return {
89 type: 'record',
90 record: {
91 cid: post.cid,
92 uri: post.uri,
93 },
94 kind: 'post',
95 meta: {
96 text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
97 indexedAt: post.indexedAt,
98 author: post.author,
99 },
100 }
101 }
102 if (isBskyCustomFeedUrl(uri)) {
103 uri = convertBskyAppUrlIfNeeded(uri)
104 const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
105 const did = await fetchDid(handleOrDid)
106 const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
107 const res = await agent.app.bsky.feed.getFeedGenerator({feed})
108 return {
109 type: 'record',
110 record: {
111 uri: res.data.view.uri,
112 cid: res.data.view.cid,
113 },
114 kind: 'other',
115 meta: {
116 // TODO: Include hydrated content instead.
117 title: res.data.view.displayName,
118 },
119 }
120 }
121 if (isBskyListUrl(uri)) {
122 uri = convertBskyAppUrlIfNeeded(uri)
123 const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
124 const did = await fetchDid(handleOrDid)
125 const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
126 const res = await agent.app.bsky.graph.getList({list})
127 return {
128 type: 'record',
129 record: {
130 uri: res.data.list.uri,
131 cid: res.data.list.cid,
132 },
133 kind: 'other',
134 meta: {
135 // TODO: Include hydrated content instead.
136 title: res.data.list.name,
137 },
138 }
139 }
140 if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
141 const parsed = parseStarterPackUri(uri)
142 if (!parsed) {
143 throw new Error(
144 'Unexpectedly called getStarterPackAsEmbed with a non-starterpack url',
145 )
146 }
147 const did = await fetchDid(parsed.name)
148 const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
149 const res = await agent.app.bsky.graph.getStarterPack({starterPack})
150 const record = res.data.starterPack.record
151 return {
152 type: 'record',
153 record: {
154 uri: res.data.starterPack.uri,
155 cid: res.data.starterPack.cid,
156 },
157 kind: 'other',
158 meta: {
159 // TODO: Include hydrated content instead.
160 title: AppBskyGraphStarterpack.isRecord(record)
161 ? record.name
162 : 'Starter Pack',
163 },
164 }
165 }
166 return resolveExternal(agent, uri)
167
168 // Forked from useGetPost. TODO: move into RQ.
169 async function getPost({uri}: {uri: string}) {
170 const urip = new AtUri(uri)
171 if (!urip.host.startsWith('did:')) {
172 const res = await agent.resolveHandle({
173 handle: urip.host,
174 })
175 urip.host = res.data.did
176 }
177 const res = await agent.getPosts({
178 uris: [urip.toString()],
179 })
180 if (res.success && res.data.posts[0]) {
181 return res.data.posts[0]
182 }
183 throw new Error('getPost: post not found')
184 }
185
186 // Forked from useFetchDid. TODO: move into RQ.
187 async function fetchDid(handleOrDid: string) {
188 let identifier = handleOrDid
189 if (!identifier.startsWith('did:')) {
190 const res = await agent.resolveHandle({handle: identifier})
191 identifier = res.data.did
192 }
193 return identifier
194 }
195}
196
197export async function resolveGif(
198 agent: BskyAgent,
199 gif: Gif,
200): Promise<ResolvedExternalLink> {
201 const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
202 return {
203 type: 'external',
204 uri,
205 title: gif.content_description,
206 description: createGIFDescription(gif.content_description),
207 thumb: await imageToThumb(gif.media_formats.preview.url),
208 }
209}
210
211async function resolveExternal(
212 agent: BskyAgent,
213 uri: string,
214): Promise<ResolvedExternalLink> {
215 const result = await getLinkMeta(agent, uri)
216 return {
217 type: 'external',
218 uri: result.url,
219 title: result.title ?? '',
220 description: result.description ?? '',
221 thumb: result.image ? await imageToThumb(result.image) : undefined,
222 }
223}
224
225async function imageToThumb(
226 imageUri: string,
227): Promise<ComposerImage | undefined> {
228 try {
229 const img = await downloadAndResize({
230 uri: imageUri,
231 width: POST_IMG_MAX.width,
232 height: POST_IMG_MAX.height,
233 mode: 'contain',
234 maxSize: POST_IMG_MAX.size,
235 timeout: 15e3,
236 })
237 if (img) {
238 return await createComposerImage(img)
239 }
240 } catch {}
241}