diff options
Diffstat (limited to 'src/lib/api')
-rw-r--r-- | src/lib/api/api-polyfill.ts | 79 | ||||
-rw-r--r-- | src/lib/api/api-polyfill.web.ts | 4 | ||||
-rw-r--r-- | src/lib/api/index.ts | 201 |
3 files changed, 284 insertions, 0 deletions
diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts new file mode 100644 index 000000000..3b5ba7518 --- /dev/null +++ b/src/lib/api/api-polyfill.ts @@ -0,0 +1,79 @@ +import AtpAgent from '@atproto/api' +import RNFS from 'react-native-fs' + +const TIMEOUT = 10e3 // 10s + +export function doPolyfill() { + AtpAgent.configure({fetch: fetchHandler}) +} + +interface FetchHandlerResponse { + status: number + headers: Record<string, string> + body: ArrayBuffer | undefined +} + +async function fetchHandler( + reqUri: string, + reqMethod: string, + reqHeaders: Record<string, string>, + reqBody: any, +): Promise<FetchHandlerResponse> { + const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] + if (reqMimeType && reqMimeType.startsWith('application/json')) { + reqBody = JSON.stringify(reqBody) + } else if ( + typeof reqBody === 'string' && + (reqBody.startsWith('/') || reqBody.startsWith('file:')) + ) { + if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) { + // HACK + // React native has a bug that inflates the size of jpegs on upload + // we get around that by renaming the file ext to .bin + // see https://github.com/facebook/react-native/issues/27099 + // -prf + const newPath = reqBody.replace(/\.jpe?g$/, '.bin') + await RNFS.moveFile(reqBody, newPath) + reqBody = newPath + } + // NOTE + // React native treats bodies with {uri: string} as file uploads to pull from cache + // -prf + reqBody = {uri: reqBody} + } + + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), TIMEOUT) + + const res = await fetch(reqUri, { + method: reqMethod, + headers: reqHeaders, + body: reqBody, + signal: controller.signal, + }) + + const resStatus = res.status + const resHeaders: Record<string, string> = {} + res.headers.forEach((value: string, key: string) => { + resHeaders[key] = value + }) + const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type'] + let resBody + if (resMimeType) { + if (resMimeType.startsWith('application/json')) { + resBody = await res.json() + } else if (resMimeType.startsWith('text/')) { + resBody = await res.text() + } else { + throw new Error('TODO: non-textual response body') + } + } + + clearTimeout(to) + + return { + status: resStatus, + headers: resHeaders, + body: resBody, + } +} diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts new file mode 100644 index 000000000..1469cf905 --- /dev/null +++ b/src/lib/api/api-polyfill.web.ts @@ -0,0 +1,4 @@ +export function doPolyfill() { + // TODO needed? native fetch may work fine -prf + // AtpApi.xrpc.fetch = fetchHandler +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 000000000..d800c376c --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,201 @@ +import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' +import {AtUri} from '../../third-party/uri' +import {RootStoreModel} from 'state/models/root-store' +import {extractEntities} from 'lib/strings/rich-text-detection' +import {isNetworkError} from 'lib/strings/errors' +import {LinkMeta} from '../link-meta/link-meta' +import {Image} from '../images' +import {RichText} from '../strings/rich-text' + +export interface ExternalEmbedDraft { + uri: string + isLoading: boolean + meta?: LinkMeta + localThumb?: Image +} + +export async function resolveName(store: RootStoreModel, didOrHandle: string) { + if (!didOrHandle) { + throw new Error('Invalid handle: ""') + } + if (didOrHandle.startsWith('did:')) { + return didOrHandle + } + const res = await store.api.com.atproto.handle.resolve({ + handle: didOrHandle, + }) + return res.data.did +} + +export async function post( + store: RootStoreModel, + rawText: string, + replyTo?: string, + extLink?: ExternalEmbedDraft, + images?: string[], + knownHandles?: Set<string>, + onStateChange?: (state: string) => void, +) { + let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined + let reply + const text = new RichText(rawText, undefined, { + cleanNewlines: true, + }).text.trim() + + onStateChange?.('Processing...') + const entities = extractEntities(text, knownHandles) + if (entities) { + for (const ent of entities) { + if (ent.type === 'mention') { + const prof = await store.profiles.getProfile(ent.value) + ent.value = prof.data.did + } + } + } + + if (images?.length) { + embed = { + $type: 'app.bsky.embed.images', + images: [], + } as AppBskyEmbedImages.Main + let i = 1 + for (const image of images) { + onStateChange?.(`Uploading image #${i++}...`) + const res = await store.api.com.atproto.blob.upload( + image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding: 'image/jpeg'}, + ) + embed.images.push({ + image: { + cid: res.data.cid, + mimeType: 'image/jpeg', + }, + alt: '', // TODO supply alt text + }) + } + } + + if (!embed && extLink) { + let thumb + if (extLink.localThumb) { + onStateChange?.('Uploading link thumbnail...') + let encoding + if (extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + extLink.localThumb.path.endsWith('.jpeg') || + extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await store.api.com.atproto.blob.upload( + extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding}, + ) + thumb = { + cid: thumbUploadRes.data.cid, + mimeType: encoding, + } + } + } + embed = { + $type: 'app.bsky.embed.external', + external: { + uri: extLink.uri, + title: extLink.meta?.title || '', + description: extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main + } + + if (replyTo) { + const replyToUrip = new AtUri(replyTo) + const parentPost = await store.api.app.bsky.feed.post.get({ + user: replyToUrip.host, + rkey: replyToUrip.rkey, + }) + if (parentPost) { + const parentRef = { + uri: parentPost.uri, + cid: parentPost.cid, + } + reply = { + root: parentPost.value.reply?.root || parentRef, + parent: parentRef, + } + } + } + + try { + onStateChange?.('Posting...') + return await store.api.app.bsky.feed.post.create( + {did: store.me.did || ''}, + { + text, + reply, + embed, + entities, + createdAt: new Date().toISOString(), + }, + ) + } catch (e: any) { + console.error(`Failed to create post: ${e.toString()}`) + if (isNetworkError(e)) { + throw new Error( + 'Post failed to upload. Please check your Internet connection and try again.', + ) + } else { + throw e + } + } +} + +export async function repost(store: RootStoreModel, uri: string, cid: string) { + return await store.api.app.bsky.feed.repost.create( + {did: store.me.did || ''}, + { + subject: {uri, cid}, + createdAt: new Date().toISOString(), + }, + ) +} + +export async function unrepost(store: RootStoreModel, repostUri: string) { + const repostUrip = new AtUri(repostUri) + return await store.api.app.bsky.feed.repost.delete({ + did: repostUrip.hostname, + rkey: repostUrip.rkey, + }) +} + +export async function follow( + store: RootStoreModel, + subjectDid: string, + subjectDeclarationCid: string, +) { + return await store.api.app.bsky.graph.follow.create( + {did: store.me.did || ''}, + { + subject: { + did: subjectDid, + declarationCid: subjectDeclarationCid, + }, + createdAt: new Date().toISOString(), + }, + ) +} + +export async function unfollow(store: RootStoreModel, followUri: string) { + const followUrip = new AtUri(followUri) + return await store.api.app.bsky.graph.follow.delete({ + did: followUrip.hostname, + rkey: followUrip.rkey, + }) +} |