mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {
2 AppBskyEmbedExternal,
3 AppBskyEmbedImages,
4 AppBskyEmbedRecord,
5 AppBskyEmbedRecordWithMedia,
6 AppBskyEmbedVideo,
7 AtUri,
8 BlobRef,
9 BskyAgent,
10 ComAtprotoLabelDefs,
11 ComAtprotoRepoStrongRef,
12 RichText,
13} from '@atproto/api'
14import {QueryClient} from '@tanstack/react-query'
15
16import {isNetworkError} from '#/lib/strings/errors'
17import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
18import {logger} from '#/logger'
19import {compressImage} from '#/state/gallery'
20import {writePostgateRecord} from '#/state/queries/postgate'
21import {
22 fetchResolveGifQuery,
23 fetchResolveLinkQuery,
24} from '#/state/queries/resolve-link'
25import {
26 createThreadgateRecord,
27 threadgateAllowUISettingToAllowRecordValue,
28 writeThreadgateRecord,
29} from '#/state/queries/threadgate'
30import {ComposerDraft, EmbedDraft} from '#/view/com/composer/state/composer'
31import {createGIFDescription} from '../gif-alt-text'
32import {uploadBlob} from './upload-blob'
33
34export {uploadBlob}
35
36interface PostOpts {
37 draft: ComposerDraft
38 replyTo?: string
39 onStateChange?: (state: string) => void
40 langs?: string[]
41}
42
43export async function post(
44 agent: BskyAgent,
45 queryClient: QueryClient,
46 opts: PostOpts,
47) {
48 const draft = opts.draft
49 let reply
50 let rt = new RichText(
51 {text: draft.richtext.text.trimEnd()},
52 {cleanNewlines: true},
53 )
54
55 opts.onStateChange?.('Processing...')
56
57 await rt.detectFacets(agent)
58
59 rt = shortenLinks(rt)
60 rt = stripInvalidMentions(rt)
61
62 const embed = await resolveEmbed(
63 agent,
64 queryClient,
65 draft,
66 opts.onStateChange,
67 )
68
69 // add replyTo if post is a reply to another post
70 if (opts.replyTo) {
71 const replyToUrip = new AtUri(opts.replyTo)
72 const parentPost = await agent.getPost({
73 repo: replyToUrip.host,
74 rkey: replyToUrip.rkey,
75 })
76 if (parentPost) {
77 const parentRef = {
78 uri: parentPost.uri,
79 cid: parentPost.cid,
80 }
81 reply = {
82 root: parentPost.value.reply?.root || parentRef,
83 parent: parentRef,
84 }
85 }
86 }
87
88 // set labels
89 let labels: ComAtprotoLabelDefs.SelfLabels | undefined
90 if (draft.labels.length) {
91 labels = {
92 $type: 'com.atproto.label.defs#selfLabels',
93 values: draft.labels.map(val => ({val})),
94 }
95 }
96
97 // add top 3 languages from user preferences if langs is provided
98 let langs = opts.langs
99 if (opts.langs) {
100 langs = opts.langs.slice(0, 3)
101 }
102
103 let res
104 try {
105 opts.onStateChange?.('Posting...')
106 res = await agent.post({
107 text: rt.text,
108 facets: rt.facets,
109 reply,
110 embed,
111 langs,
112 labels,
113 })
114 } catch (e: any) {
115 logger.error(`Failed to create post`, {
116 safeMessage: e.message,
117 })
118 if (isNetworkError(e)) {
119 throw new Error(
120 'Post failed to upload. Please check your Internet connection and try again.',
121 )
122 } else {
123 throw e
124 }
125 }
126
127 if (draft.threadgate.some(tg => tg.type !== 'everybody')) {
128 try {
129 // TODO: this needs to be batch-created with the post!
130 await writeThreadgateRecord({
131 agent,
132 postUri: res.uri,
133 threadgate: createThreadgateRecord({
134 post: res.uri,
135 allow: threadgateAllowUISettingToAllowRecordValue(draft.threadgate),
136 }),
137 })
138 } catch (e: any) {
139 logger.error(`Failed to create threadgate`, {
140 context: 'composer',
141 safeMessage: e.message,
142 })
143 throw new Error(
144 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
145 )
146 }
147 }
148
149 if (
150 draft.postgate.embeddingRules?.length ||
151 draft.postgate.detachedEmbeddingUris?.length
152 ) {
153 try {
154 // TODO: this needs to be batch-created with the post!
155 await writePostgateRecord({
156 agent,
157 postUri: res.uri,
158 postgate: {
159 ...draft.postgate,
160 post: res.uri,
161 },
162 })
163 } catch (e: any) {
164 logger.error(`Failed to create postgate`, {
165 context: 'composer',
166 safeMessage: e.message,
167 })
168 throw new Error(
169 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
170 )
171 }
172 }
173
174 return res
175}
176
177async function resolveEmbed(
178 agent: BskyAgent,
179 queryClient: QueryClient,
180 draft: ComposerDraft,
181 onStateChange: ((state: string) => void) | undefined,
182): Promise<
183 | AppBskyEmbedImages.Main
184 | AppBskyEmbedVideo.Main
185 | AppBskyEmbedExternal.Main
186 | AppBskyEmbedRecord.Main
187 | AppBskyEmbedRecordWithMedia.Main
188 | undefined
189> {
190 if (draft.embed.quote) {
191 const [resolvedMedia, resolvedQuote] = await Promise.all([
192 resolveMedia(agent, queryClient, draft.embed, onStateChange),
193 resolveRecord(agent, queryClient, draft.embed.quote.uri),
194 ])
195 if (resolvedMedia) {
196 return {
197 $type: 'app.bsky.embed.recordWithMedia',
198 record: {
199 $type: 'app.bsky.embed.record',
200 record: resolvedQuote,
201 },
202 media: resolvedMedia,
203 }
204 }
205 return {
206 $type: 'app.bsky.embed.record',
207 record: resolvedQuote,
208 }
209 }
210 const resolvedMedia = await resolveMedia(
211 agent,
212 queryClient,
213 draft.embed,
214 onStateChange,
215 )
216 if (resolvedMedia) {
217 return resolvedMedia
218 }
219 if (draft.embed.link) {
220 const resolvedLink = await fetchResolveLinkQuery(
221 queryClient,
222 agent,
223 draft.embed.link.uri,
224 )
225 if (resolvedLink.type === 'record') {
226 return {
227 $type: 'app.bsky.embed.record',
228 record: resolvedLink.record,
229 }
230 }
231 }
232 return undefined
233}
234
235async function resolveMedia(
236 agent: BskyAgent,
237 queryClient: QueryClient,
238 embedDraft: EmbedDraft,
239 onStateChange: ((state: string) => void) | undefined,
240): Promise<
241 | AppBskyEmbedExternal.Main
242 | AppBskyEmbedImages.Main
243 | AppBskyEmbedVideo.Main
244 | undefined
245> {
246 if (embedDraft.media?.type === 'images') {
247 const imagesDraft = embedDraft.media.images
248 logger.debug(`Uploading images`, {
249 count: imagesDraft.length,
250 })
251 onStateChange?.(`Uploading images...`)
252 const images: AppBskyEmbedImages.Image[] = await Promise.all(
253 imagesDraft.map(async (image, i) => {
254 logger.debug(`Compressing image #${i}`)
255 const {path, width, height, mime} = await compressImage(image)
256 logger.debug(`Uploading image #${i}`)
257 const res = await uploadBlob(agent, path, mime)
258 return {
259 image: res.data.blob,
260 alt: image.alt,
261 aspectRatio: {width, height},
262 }
263 }),
264 )
265 return {
266 $type: 'app.bsky.embed.images',
267 images,
268 }
269 }
270 if (
271 embedDraft.media?.type === 'video' &&
272 embedDraft.media.video.status === 'done'
273 ) {
274 const videoDraft = embedDraft.media.video
275 const captions = await Promise.all(
276 videoDraft.captions
277 .filter(caption => caption.lang !== '')
278 .map(async caption => {
279 const {data} = await agent.uploadBlob(caption.file, {
280 encoding: 'text/vtt',
281 })
282 return {lang: caption.lang, file: data.blob}
283 }),
284 )
285 return {
286 $type: 'app.bsky.embed.video',
287 video: videoDraft.pendingPublish.blobRef,
288 alt: videoDraft.altText || undefined,
289 captions: captions.length === 0 ? undefined : captions,
290 aspectRatio: {
291 width: videoDraft.asset.width,
292 height: videoDraft.asset.height,
293 },
294 }
295 }
296 if (embedDraft.media?.type === 'gif') {
297 const gifDraft = embedDraft.media
298 const resolvedGif = await fetchResolveGifQuery(
299 queryClient,
300 agent,
301 gifDraft.gif,
302 )
303 let blob: BlobRef | undefined
304 if (resolvedGif.thumb) {
305 onStateChange?.('Uploading link thumbnail...')
306 const {path, mime} = resolvedGif.thumb.source
307 const response = await uploadBlob(agent, path, mime)
308 blob = response.data.blob
309 }
310 return {
311 $type: 'app.bsky.embed.external',
312 external: {
313 uri: resolvedGif.uri,
314 title: resolvedGif.title,
315 description: createGIFDescription(resolvedGif.title, gifDraft.alt),
316 thumb: blob,
317 },
318 }
319 }
320 if (embedDraft.link) {
321 const resolvedLink = await fetchResolveLinkQuery(
322 queryClient,
323 agent,
324 embedDraft.link.uri,
325 )
326 if (resolvedLink.type === 'external') {
327 let blob: BlobRef | undefined
328 if (resolvedLink.thumb) {
329 onStateChange?.('Uploading link thumbnail...')
330 const {path, mime} = resolvedLink.thumb.source
331 const response = await uploadBlob(agent, path, mime)
332 blob = response.data.blob
333 }
334 return {
335 $type: 'app.bsky.embed.external',
336 external: {
337 uri: resolvedLink.uri,
338 title: resolvedLink.title,
339 description: resolvedLink.description,
340 thumb: blob,
341 },
342 }
343 }
344 }
345 return undefined
346}
347
348async function resolveRecord(
349 agent: BskyAgent,
350 queryClient: QueryClient,
351 uri: string,
352): Promise<ComAtprotoRepoStrongRef.Main> {
353 const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
354 if (resolvedLink.type !== 'record') {
355 throw Error('Expected uri to resolve to a record')
356 }
357 return resolvedLink.record
358}