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