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