mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import { 2 AppBskyEmbedExternal, 3 AppBskyEmbedImages, 4 AppBskyEmbedRecord, 5 AppBskyEmbedRecordWithMedia, 6 AppBskyFeedThreadgate, 7 BskyAgent, 8 ComAtprotoLabelDefs, 9 ComAtprotoRepoUploadBlob, 10 RichText, 11} from '@atproto/api' 12import {AtUri} from '@atproto/api' 13 14import {logger} from '#/logger' 15import {ThreadgateSetting} from '#/state/queries/threadgate' 16import {isNetworkError} from 'lib/strings/errors' 17import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' 18import {isNative, isWeb} from 'platform/detection' 19import {ImageModel} from 'state/models/media/image' 20import {LinkMeta} from '../link-meta/link-meta' 21import {safeDeleteAsync} from '../media/manip' 22 23export interface ExternalEmbedDraft { 24 uri: string 25 isLoading: boolean 26 meta?: LinkMeta 27 embed?: AppBskyEmbedRecord.Main 28 localThumb?: ImageModel 29} 30 31export async function uploadBlob( 32 agent: BskyAgent, 33 blob: string, 34 encoding: string, 35): Promise<ComAtprotoRepoUploadBlob.Response> { 36 if (isWeb) { 37 // `blob` should be a data uri 38 return agent.uploadBlob(convertDataURIToUint8Array(blob), { 39 encoding, 40 }) 41 } else { 42 // `blob` should be a path to a file in the local FS 43 return agent.uploadBlob( 44 blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts 45 {encoding}, 46 ) 47 } 48} 49 50interface PostOpts { 51 rawText: string 52 replyTo?: string 53 quote?: { 54 uri: string 55 cid: string 56 } 57 extLink?: ExternalEmbedDraft 58 images?: ImageModel[] 59 labels?: string[] 60 threadgate?: ThreadgateSetting[] 61 onStateChange?: (state: string) => void 62 langs?: string[] 63} 64 65export async function post(agent: BskyAgent, opts: PostOpts) { 66 let embed: 67 | AppBskyEmbedImages.Main 68 | AppBskyEmbedExternal.Main 69 | AppBskyEmbedRecord.Main 70 | AppBskyEmbedRecordWithMedia.Main 71 | undefined 72 let reply 73 let rt = new RichText( 74 {text: opts.rawText.trimEnd()}, 75 { 76 cleanNewlines: true, 77 }, 78 ) 79 80 opts.onStateChange?.('Processing...') 81 await rt.detectFacets(agent) 82 rt = shortenLinks(rt) 83 rt = stripInvalidMentions(rt) 84 85 // add quote embed if present 86 if (opts.quote) { 87 embed = { 88 $type: 'app.bsky.embed.record', 89 record: { 90 uri: opts.quote.uri, 91 cid: opts.quote.cid, 92 }, 93 } as AppBskyEmbedRecord.Main 94 } 95 96 // add image embed if present 97 if (opts.images?.length) { 98 logger.debug(`Uploading images`, { 99 count: opts.images.length, 100 }) 101 102 const images: AppBskyEmbedImages.Image[] = [] 103 for (const image of opts.images) { 104 opts.onStateChange?.(`Uploading image #${images.length + 1}...`) 105 logger.debug(`Compressing image`) 106 await image.compress() 107 const path = image.compressed?.path ?? image.path 108 const {width, height} = image.compressed || image 109 logger.debug(`Uploading image`) 110 const res = await uploadBlob(agent, path, 'image/jpeg') 111 if (isNative) { 112 safeDeleteAsync(path) 113 } 114 images.push({ 115 image: res.data.blob, 116 alt: image.altText ?? '', 117 aspectRatio: {width, height}, 118 }) 119 } 120 121 if (opts.quote) { 122 embed = { 123 $type: 'app.bsky.embed.recordWithMedia', 124 record: embed, 125 media: { 126 $type: 'app.bsky.embed.images', 127 images, 128 }, 129 } as AppBskyEmbedRecordWithMedia.Main 130 } else { 131 embed = { 132 $type: 'app.bsky.embed.images', 133 images, 134 } as AppBskyEmbedImages.Main 135 } 136 } 137 138 // add external embed if present 139 if (opts.extLink && !opts.images?.length) { 140 if (opts.extLink.embed) { 141 embed = opts.extLink.embed 142 } else { 143 let thumb 144 if (opts.extLink.localThumb) { 145 opts.onStateChange?.('Uploading link thumbnail...') 146 let encoding 147 if (opts.extLink.localThumb.mime) { 148 encoding = opts.extLink.localThumb.mime 149 } else if (opts.extLink.localThumb.path.endsWith('.png')) { 150 encoding = 'image/png' 151 } else if ( 152 opts.extLink.localThumb.path.endsWith('.jpeg') || 153 opts.extLink.localThumb.path.endsWith('.jpg') 154 ) { 155 encoding = 'image/jpeg' 156 } else { 157 logger.warn('Unexpected image format for thumbnail, skipping', { 158 thumbnail: opts.extLink.localThumb.path, 159 }) 160 } 161 if (encoding) { 162 const thumbUploadRes = await uploadBlob( 163 agent, 164 opts.extLink.localThumb.path, 165 encoding, 166 ) 167 thumb = thumbUploadRes.data.blob 168 if (isNative) { 169 safeDeleteAsync(opts.extLink.localThumb.path) 170 } 171 } 172 } 173 174 if (opts.quote) { 175 embed = { 176 $type: 'app.bsky.embed.recordWithMedia', 177 record: embed, 178 media: { 179 $type: 'app.bsky.embed.external', 180 external: { 181 uri: opts.extLink.uri, 182 title: opts.extLink.meta?.title || '', 183 description: opts.extLink.meta?.description || '', 184 thumb, 185 }, 186 } as AppBskyEmbedExternal.Main, 187 } as AppBskyEmbedRecordWithMedia.Main 188 } else { 189 embed = { 190 $type: 'app.bsky.embed.external', 191 external: { 192 uri: opts.extLink.uri, 193 title: opts.extLink.meta?.title || '', 194 description: opts.extLink.meta?.description || '', 195 thumb, 196 }, 197 } as AppBskyEmbedExternal.Main 198 } 199 } 200 } 201 202 // add replyTo if post is a reply to another post 203 if (opts.replyTo) { 204 const replyToUrip = new AtUri(opts.replyTo) 205 const parentPost = await agent.getPost({ 206 repo: replyToUrip.host, 207 rkey: replyToUrip.rkey, 208 }) 209 if (parentPost) { 210 const parentRef = { 211 uri: parentPost.uri, 212 cid: parentPost.cid, 213 } 214 reply = { 215 root: parentPost.value.reply?.root || parentRef, 216 parent: parentRef, 217 } 218 } 219 } 220 221 // set labels 222 let labels: ComAtprotoLabelDefs.SelfLabels | undefined 223 if (opts.labels?.length) { 224 labels = { 225 $type: 'com.atproto.label.defs#selfLabels', 226 values: opts.labels.map(val => ({val})), 227 } 228 } 229 230 // add top 3 languages from user preferences if langs is provided 231 let langs = opts.langs 232 if (opts.langs) { 233 langs = opts.langs.slice(0, 3) 234 } 235 236 let res 237 try { 238 opts.onStateChange?.('Posting...') 239 res = await agent.post({ 240 text: rt.text, 241 facets: rt.facets, 242 reply, 243 embed, 244 langs, 245 labels, 246 }) 247 } catch (e: any) { 248 console.error(`Failed to create post: ${e.toString()}`) 249 if (isNetworkError(e)) { 250 throw new Error( 251 'Post failed to upload. Please check your Internet connection and try again.', 252 ) 253 } else { 254 throw e 255 } 256 } 257 258 try { 259 // TODO: this needs to be batch-created with the post! 260 if (opts.threadgate?.length) { 261 await createThreadgate(agent, res.uri, opts.threadgate) 262 } 263 } catch (e: any) { 264 console.error(`Failed to create threadgate: ${e.toString()}`) 265 throw new Error( 266 'Post reply-controls failed to be set. Your post was created but anyone can reply to it.', 267 ) 268 } 269 270 return res 271} 272 273async function createThreadgate( 274 agent: BskyAgent, 275 postUri: string, 276 threadgate: ThreadgateSetting[], 277) { 278 let allow: ( 279 | AppBskyFeedThreadgate.MentionRule 280 | AppBskyFeedThreadgate.FollowingRule 281 | AppBskyFeedThreadgate.ListRule 282 )[] = [] 283 if (!threadgate.find(v => v.type === 'nobody')) { 284 for (const rule of threadgate) { 285 if (rule.type === 'mention') { 286 allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) 287 } else if (rule.type === 'following') { 288 allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) 289 } else if (rule.type === 'list') { 290 allow.push({ 291 $type: 'app.bsky.feed.threadgate#listRule', 292 list: rule.list, 293 }) 294 } 295 } 296 } 297 298 const postUrip = new AtUri(postUri) 299 await agent.api.app.bsky.feed.threadgate.create( 300 {repo: agent.session!.did, rkey: postUrip.rkey}, 301 {post: postUri, createdAt: new Date().toISOString(), allow}, 302 ) 303} 304 305// helpers 306// = 307 308function convertDataURIToUint8Array(uri: string): Uint8Array { 309 var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8)) 310 var binary = new Uint8Array(new ArrayBuffer(raw.length)) 311 for (let i = 0; i < raw.length; i++) { 312 binary[i] = raw.charCodeAt(i) 313 } 314 return binary 315}