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.trim()},
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 res = await uploadBlob(store, path, 'image/jpeg')
137 images.push({
138 image: res.data.blob,
139 alt: image.altText ?? '',
140 })
141 }
142
143 if (opts.quote) {
144 embed = {
145 $type: 'app.bsky.embed.recordWithMedia',
146 record: embed,
147 media: {
148 $type: 'app.bsky.embed.images',
149 images,
150 },
151 } as AppBskyEmbedRecordWithMedia.Main
152 } else {
153 embed = {
154 $type: 'app.bsky.embed.images',
155 images,
156 } as AppBskyEmbedImages.Main
157 }
158 }
159
160 // add external embed if present
161 if (opts.extLink && !opts.images?.length) {
162 if (opts.extLink.embed) {
163 embed = opts.extLink.embed
164 } else {
165 let thumb
166 if (opts.extLink.localThumb) {
167 opts.onStateChange?.('Uploading link thumbnail...')
168 let encoding
169 if (opts.extLink.localThumb.mime) {
170 encoding = opts.extLink.localThumb.mime
171 } else if (opts.extLink.localThumb.path.endsWith('.png')) {
172 encoding = 'image/png'
173 } else if (
174 opts.extLink.localThumb.path.endsWith('.jpeg') ||
175 opts.extLink.localThumb.path.endsWith('.jpg')
176 ) {
177 encoding = 'image/jpeg'
178 } else {
179 store.log.warn(
180 'Unexpected image format for thumbnail, skipping',
181 opts.extLink.localThumb.path,
182 )
183 }
184 if (encoding) {
185 const thumbUploadRes = await uploadBlob(
186 store,
187 opts.extLink.localThumb.path,
188 encoding,
189 )
190 thumb = thumbUploadRes.data.blob
191 }
192 }
193
194 if (opts.quote) {
195 embed = {
196 $type: 'app.bsky.embed.recordWithMedia',
197 record: embed,
198 media: {
199 $type: 'app.bsky.embed.external',
200 external: {
201 uri: opts.extLink.uri,
202 title: opts.extLink.meta?.title || '',
203 description: opts.extLink.meta?.description || '',
204 thumb,
205 },
206 } as AppBskyEmbedExternal.Main,
207 } as AppBskyEmbedRecordWithMedia.Main
208 } else {
209 embed = {
210 $type: 'app.bsky.embed.external',
211 external: {
212 uri: opts.extLink.uri,
213 title: opts.extLink.meta?.title || '',
214 description: opts.extLink.meta?.description || '',
215 thumb,
216 },
217 } as AppBskyEmbedExternal.Main
218 }
219 }
220 }
221
222 // add replyTo if post is a reply to another post
223 if (opts.replyTo) {
224 const replyToUrip = new AtUri(opts.replyTo)
225 const parentPost = await store.agent.getPost({
226 repo: replyToUrip.host,
227 rkey: replyToUrip.rkey,
228 })
229 if (parentPost) {
230 const parentRef = {
231 uri: parentPost.uri,
232 cid: parentPost.cid,
233 }
234 reply = {
235 root: parentPost.value.reply?.root || parentRef,
236 parent: parentRef,
237 }
238 }
239 }
240
241 // set labels
242 let labels: ComAtprotoLabelDefs.SelfLabels | undefined
243 if (opts.labels?.length) {
244 labels = {
245 $type: 'com.atproto.label.defs#selfLabels',
246 values: opts.labels.map(val => ({val})),
247 }
248 }
249
250 // add top 3 languages from user preferences if langs is provided
251 let langs = opts.langs
252 if (opts.langs) {
253 langs = opts.langs.slice(0, 3)
254 }
255
256 try {
257 opts.onStateChange?.('Posting...')
258 return await store.agent.post({
259 text: rt.text,
260 facets: rt.facets,
261 reply,
262 embed,
263 langs,
264 labels,
265 })
266 } catch (e: any) {
267 console.error(`Failed to create post: ${e.toString()}`)
268 if (isNetworkError(e)) {
269 throw new Error(
270 'Post failed to upload. Please check your Internet connection and try again.',
271 )
272 } else {
273 throw e
274 }
275 }
276}
277
278// helpers
279// =
280
281function convertDataURIToUint8Array(uri: string): Uint8Array {
282 var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8))
283 var binary = new Uint8Array(new ArrayBuffer(raw.length))
284 for (let i = 0; i < raw.length; i++) {
285 binary[i] = raw.charCodeAt(i)
286 }
287 return binary
288}