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