mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at typescript-check 272 lines 7.3 kB view raw
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}