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