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}