mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at static-click 358 lines 9.3 kB view raw
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}