mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at remove-scenes 278 lines 7.6 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 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}