mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1/**
2 * The environment is a place where services and shared dependencies between
3 * models live. They are made available to every model via dependency injection.
4 */
5
6// import {ReactNativeStore} from './auth'
7import {
8 sessionClient as AtpApi,
9 APP_BSKY_GRAPH,
10 AppBskyEmbedImages,
11 AppBskyEmbedExternal,
12} from '@atproto/api'
13import {AtUri} from '../../third-party/uri'
14import {RootStoreModel} from '../models/root-store'
15import {extractEntities} from '../../lib/strings'
16import {isNetworkError} from '../../lib/errors'
17import {downloadAndResize} from '../../lib/images'
18import {getLikelyType, LikelyType, getLinkMeta} from '../../lib/link-meta'
19
20const TIMEOUT = 10e3 // 10s
21
22export function doPolyfill() {
23 AtpApi.xrpc.fetch = fetchHandler
24}
25
26export async function post(
27 store: RootStoreModel,
28 text: string,
29 replyTo?: string,
30 images?: string[],
31 knownHandles?: Set<string>,
32 onStateChange?: (state: string) => void,
33) {
34 let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
35 let reply
36
37 onStateChange?.('Processing...')
38 const entities = extractEntities(text, knownHandles)
39 if (entities) {
40 for (const ent of entities) {
41 if (ent.type === 'mention') {
42 const prof = await store.profiles.getProfile(ent.value)
43 ent.value = prof.data.did
44 }
45 }
46 }
47
48 if (images?.length) {
49 embed = {
50 $type: 'app.bsky.embed.images',
51 images: [],
52 } as AppBskyEmbedImages.Main
53 let i = 1
54 for (const image of images) {
55 onStateChange?.(`Uploading image #${i++}...`)
56 const res = await store.api.com.atproto.blob.upload(
57 image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
58 {encoding: 'image/jpeg'},
59 )
60 embed.images.push({
61 image: {
62 cid: res.data.cid,
63 mimeType: 'image/jpeg',
64 },
65 alt: '', // TODO supply alt text
66 })
67 }
68 }
69
70 if (!embed && entities) {
71 const link = entities.find(
72 ent =>
73 ent.type === 'link' &&
74 getLikelyType(ent.value || '') === LikelyType.HTML,
75 )
76 if (link) {
77 try {
78 onStateChange?.(`Fetching link metadata...`)
79 let thumb
80 const linkMeta = await getLinkMeta(link.value)
81 if (linkMeta.image) {
82 onStateChange?.(`Downloading link thumbnail...`)
83 const thumbLocal = await downloadAndResize({
84 uri: linkMeta.image,
85 width: 250,
86 height: 250,
87 mode: 'contain',
88 maxSize: 100000,
89 timeout: 15e3,
90 }).catch(() => undefined)
91 if (thumbLocal) {
92 onStateChange?.(`Uploading link thumbnail...`)
93 let encoding
94 if (thumbLocal.uri.endsWith('.png')) {
95 encoding = 'image/png'
96 } else if (
97 thumbLocal.uri.endsWith('.jpeg') ||
98 thumbLocal.uri.endsWith('.jpg')
99 ) {
100 encoding = 'image/jpeg'
101 } else {
102 store.log.warn(
103 'Unexpected image format for thumbnail, skipping',
104 thumbLocal.uri,
105 )
106 }
107 if (encoding) {
108 const thumbUploadRes = await store.api.com.atproto.blob.upload(
109 thumbLocal.uri, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
110 {encoding},
111 )
112 thumb = {
113 cid: thumbUploadRes.data.cid,
114 mimeType: encoding,
115 }
116 }
117 }
118 }
119 embed = {
120 $type: 'app.bsky.embed.external',
121 external: {
122 uri: link.value,
123 title: linkMeta.title || linkMeta.url,
124 description: linkMeta.description || '',
125 thumb,
126 },
127 } as AppBskyEmbedExternal.Main
128 } catch (e: any) {
129 store.log.warn(`Failed to fetch link meta for ${link.value}`, e)
130 }
131 }
132 }
133
134 if (replyTo) {
135 const replyToUrip = new AtUri(replyTo)
136 const parentPost = await store.api.app.bsky.feed.post.get({
137 user: replyToUrip.host,
138 rkey: replyToUrip.rkey,
139 })
140 if (parentPost) {
141 const parentRef = {
142 uri: parentPost.uri,
143 cid: parentPost.cid,
144 }
145 reply = {
146 root: parentPost.value.reply?.root || parentRef,
147 parent: parentRef,
148 }
149 }
150 }
151
152 try {
153 onStateChange?.(`Posting...`)
154 return await store.api.app.bsky.feed.post.create(
155 {did: store.me.did || ''},
156 {
157 text,
158 reply,
159 embed,
160 entities,
161 createdAt: new Date().toISOString(),
162 },
163 )
164 } catch (e: any) {
165 console.error(`Failed to create post: ${e.toString()}`)
166 if (isNetworkError(e)) {
167 throw new Error(
168 'Post failed to upload. Please check your Internet connection and try again.',
169 )
170 } else {
171 throw e
172 }
173 }
174}
175
176export async function repost(store: RootStoreModel, uri: string, cid: string) {
177 return await store.api.app.bsky.feed.repost.create(
178 {did: store.me.did || ''},
179 {
180 subject: {uri, cid},
181 createdAt: new Date().toISOString(),
182 },
183 )
184}
185
186export async function unrepost(store: RootStoreModel, repostUri: string) {
187 const repostUrip = new AtUri(repostUri)
188 return await store.api.app.bsky.feed.repost.delete({
189 did: repostUrip.hostname,
190 rkey: repostUrip.rkey,
191 })
192}
193
194export async function follow(
195 store: RootStoreModel,
196 subjectDid: string,
197 subjectDeclarationCid: string,
198) {
199 return await store.api.app.bsky.graph.follow.create(
200 {did: store.me.did || ''},
201 {
202 subject: {
203 did: subjectDid,
204 declarationCid: subjectDeclarationCid,
205 },
206 createdAt: new Date().toISOString(),
207 },
208 )
209}
210
211export async function unfollow(store: RootStoreModel, followUri: string) {
212 const followUrip = new AtUri(followUri)
213 return await store.api.app.bsky.graph.follow.delete({
214 did: followUrip.hostname,
215 rkey: followUrip.rkey,
216 })
217}
218
219interface FetchHandlerResponse {
220 status: number
221 headers: Record<string, string>
222 body: ArrayBuffer | undefined
223}
224
225async function fetchHandler(
226 reqUri: string,
227 reqMethod: string,
228 reqHeaders: Record<string, string>,
229 reqBody: any,
230): Promise<FetchHandlerResponse> {
231 const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
232 if (reqMimeType && reqMimeType.startsWith('application/json')) {
233 reqBody = JSON.stringify(reqBody)
234 } else if (
235 typeof reqBody === 'string' &&
236 (reqBody.startsWith('/') || reqBody.startsWith('file:'))
237 ) {
238 // NOTE
239 // React native treats bodies with {uri: string} as file uploads to pull from cache
240 // -prf
241 reqBody = {uri: reqBody}
242 }
243
244 const controller = new AbortController()
245 const to = setTimeout(() => controller.abort(), TIMEOUT)
246
247 const res = await fetch(reqUri, {
248 method: reqMethod,
249 headers: reqHeaders,
250 body: reqBody,
251 signal: controller.signal,
252 })
253
254 const resStatus = res.status
255 const resHeaders: Record<string, string> = {}
256 res.headers.forEach((value: string, key: string) => {
257 resHeaders[key] = value
258 })
259 const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type']
260 let resBody
261 if (resMimeType) {
262 if (resMimeType.startsWith('application/json')) {
263 resBody = await res.json()
264 } else if (resMimeType.startsWith('text/')) {
265 resBody = await res.text()
266 } else {
267 throw new Error('TODO: non-textual response body')
268 }
269 }
270
271 clearTimeout(to)
272
273 return {
274 status: resStatus,
275 headers: resHeaders,
276 body: resBody,
277 }
278}