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