From be26e9b017b498695b667999ed76ec55bc9e11b5 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 2 May 2024 13:06:08 +0200 Subject: [PATCH 1/6] use Blob's with uploadBlob --- index.js | 9 ++-- index.web.js | 5 +- src/lib/api/api-polyfill.ts | 85 --------------------------------- src/lib/api/api-polyfill.web.ts | 3 -- src/lib/api/index.ts | 37 ++------------ src/lib/api/upload-blob.ts | 82 +++++++++++++++++++++++++++++++ src/lib/api/upload-blob.web.ts | 26 ++++++++++ src/lib/media/manip.ts | 8 +--- 8 files changed, 118 insertions(+), 137 deletions(-) delete mode 100644 src/lib/api/api-polyfill.ts delete mode 100644 src/lib/api/api-polyfill.web.ts create mode 100644 src/lib/api/upload-blob.ts create mode 100644 src/lib/api/upload-blob.web.ts diff --git a/index.js b/index.js index 7630d0538a..2f13ce1ea1 100644 --- a/index.js +++ b/index.js @@ -1,14 +1,11 @@ import 'react-native-gesture-handler' // must be first -import {LogBox} from 'react-native' - import '#/platform/polyfills' -import {IS_TEST} from '#/env' + +import {LogBox} from 'react-native' import {registerRootComponent} from 'expo' -import {doPolyfill} from '#/lib/api/api-polyfill' import App from '#/App' - -doPolyfill() +import {IS_TEST} from '#/env' if (IS_TEST) { LogBox.ignoreAllLogs() // suppress all logs in tests diff --git a/index.web.js b/index.web.js index 9623734512..be75bc772e 100644 --- a/index.web.js +++ b/index.web.js @@ -1,9 +1,8 @@ import '#/platform/markBundleStartTime' - import '#/platform/polyfills' + import {registerRootComponent} from 'expo' -import {doPolyfill} from '#/lib/api/api-polyfill' + import App from '#/App' -doPolyfill() registerRootComponent(App) diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts deleted file mode 100644 index e3aec76316..0000000000 --- a/src/lib/api/api-polyfill.ts +++ /dev/null @@ -1,85 +0,0 @@ -import RNFS from 'react-native-fs' -import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api' - -const GET_TIMEOUT = 15e3 // 15s -const POST_TIMEOUT = 60e3 // 60s - -export function doPolyfill() { - BskyAgent.configure({fetch: fetchHandler}) -} - -interface FetchHandlerResponse { - status: number - headers: Record - body: any -} - -async function fetchHandler( - reqUri: string, - reqMethod: string, - reqHeaders: Record, - reqBody: any, -): Promise { - const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] - if (reqMimeType && reqMimeType.startsWith('application/json')) { - reqBody = stringifyLex(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(), - reqMethod === 'post' ? POST_TIMEOUT : GET_TIMEOUT, - ) - - const res = await fetch(reqUri, { - method: reqMethod, - headers: reqHeaders, - body: reqBody, - signal: controller.signal, - }) - - const resStatus = res.status - const resHeaders: Record = {} - 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 = jsonToLex(await res.json()) - } else if (resMimeType.startsWith('text/')) { - resBody = await res.text() - } else if (resMimeType === 'application/vnd.ipld.car') { - resBody = await res.arrayBuffer() - } else { - throw new Error('Non-supported mime type') - } - } - - 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 deleted file mode 100644 index 1ad22b3d02..0000000000 --- a/src/lib/api/api-polyfill.web.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function doPolyfill() { - // no polyfill is needed on web -} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5b1c998cb8..13fee02703 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -6,7 +6,6 @@ import { AppBskyFeedThreadgate, BskyAgent, ComAtprotoLabelDefs, - ComAtprotoRepoUploadBlob, RichText, } from '@atproto/api' import {AtUri} from '@atproto/api' @@ -15,10 +14,13 @@ import {logger} from '#/logger' import {ThreadgateSetting} from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' -import {isNative, isWeb} from 'platform/detection' +import {isNative} from 'platform/detection' import {ImageModel} from 'state/models/media/image' import {LinkMeta} from '../link-meta/link-meta' import {safeDeleteAsync} from '../media/manip' +import {uploadBlob} from './upload-blob' + +export {uploadBlob} export interface ExternalEmbedDraft { uri: string @@ -28,25 +30,6 @@ export interface ExternalEmbedDraft { localThumb?: ImageModel } -export async function uploadBlob( - agent: BskyAgent, - blob: string, - encoding: string, -): Promise { - if (isWeb) { - // `blob` should be a data uri - return agent.uploadBlob(convertDataURIToUint8Array(blob), { - encoding, - }) - } else { - // `blob` should be a path to a file in the local FS - return agent.uploadBlob( - blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding}, - ) - } -} - interface PostOpts { rawText: string replyTo?: string @@ -308,15 +291,3 @@ export async function createThreadgate( }, }) } - -// helpers -// = - -function convertDataURIToUint8Array(uri: string): Uint8Array { - var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8)) - var binary = new Uint8Array(new ArrayBuffer(raw.length)) - for (let i = 0; i < raw.length; i++) { - binary[i] = raw.charCodeAt(i) - } - return binary -} diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts new file mode 100644 index 0000000000..0814d5185b --- /dev/null +++ b/src/lib/api/upload-blob.ts @@ -0,0 +1,82 @@ +import RNFS from 'react-native-fs' +import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api' + +/** + * @param encoding Allows overriding the blob's type + */ +export async function uploadBlob( + agent: BskyAgent, + input: string | Blob, + encoding?: string, +): Promise { + if (typeof input === 'string' && input.startsWith('file:')) { + const blob = await asBlob(input) + return agent.uploadBlob(blob, {encoding}) + } + + if (typeof input === 'string' && input.startsWith('/')) { + const blob = await asBlob(`file://${input}`) + return agent.uploadBlob(blob, {encoding}) + } + + if (typeof input === 'string' && input.startsWith('data:')) { + const blob = await fetch(input).then(r => r.blob()) + return agent.uploadBlob(blob, {encoding}) + } + + if (input instanceof Blob) { + return agent.uploadBlob(input, {encoding}) + } + + throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) +} + +async function asBlob(uri: string): Promise { + return withSafeFile(uri, async safeUri => { + // Note + // Android does not support `fetch()` on `file://` URIs. for this reason, we + // use XMLHttpRequest instead of simply calling: + + // return fetch(safeUri.replace('file:///', 'file:/')).then(r => r.blob()) + + return await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.onload = () => resolve(xhr.response) + xhr.onerror = () => reject(new Error('Failed to load blob')) + xhr.responseType = 'blob' + xhr.open('GET', safeUri, true) + xhr.send(null) + }) + }) +} + +// 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 +async function withSafeFile( + uri: string, + fn: (path: string) => Promise, +): Promise { + if (uri.endsWith('.jpeg') || uri.endsWith('.jpg')) { + // Since we don't "own" the file, we should avoid renaming or modifying it. + // Instead, let's copy it to a temporary file and use that (then remove the + // temporary file). + const newPath = uri.replace(/\.jpe?g$/, '.bin') + try { + await RNFS.copyFile(uri, newPath) + } catch { + // Failed to copy the file, just use the original + return await fn(uri) + } + try { + return await fn(newPath) + } finally { + // Remove the temporary file + await RNFS.unlink(newPath) + } + } else { + return fn(uri) + } +} diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts new file mode 100644 index 0000000000..d3c52190c1 --- /dev/null +++ b/src/lib/api/upload-blob.web.ts @@ -0,0 +1,26 @@ +import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api' + +/** + * @note It is recommended, on web, to use the `file` instance of the file + * selector input element, rather than a `data:` URL, to avoid + * loading the file into memory. `File` extends `Blob` "file" instances can + * be passed directly to this function. + */ +export async function uploadBlob( + agent: BskyAgent, + input: string | Blob, + encoding?: string, +): Promise { + if (typeof input === 'string' && input.startsWith('data:')) { + const blob = await fetch(input).then(r => r.blob()) + return agent.uploadBlob(blob, {encoding}) + } + + if (input instanceof Blob) { + return agent.uploadBlob(input, { + encoding, + }) + } + + throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) +} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 3e647004bb..3f01e98c5e 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) { // Normalize is necessary for Android, otherwise it doesn't delete. const normalizedPath = normalizePath(path) try { - await Promise.allSettled([ - deleteAsync(normalizedPath, {idempotent: true}), - // HACK: Try this one too. Might exist due to api-polyfill hack. - deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), { - idempotent: true, - }), - ]) + await deleteAsync(normalizedPath, {idempotent: true}) } catch (e) { console.error('Failed to delete file', e) } From e3e1a5c7a94e2d97edbfffe61e9afd573360f3c8 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 2 May 2024 13:29:33 +0200 Subject: [PATCH 2/6] use xrpc dispatcher --- jest/test-pds.ts | 4 ++-- src/lib/api/feed/custom.ts | 25 +++++++++++-------------- src/lib/api/index.ts | 2 +- src/state/queries/preferences/index.ts | 4 ++-- src/state/session/agent.ts | 11 ++++++++++- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 2fe623ca98..bfcc970c2f 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -156,7 +156,7 @@ class Mocker { } async createUser(name: string) { - const agent = new BskyAgent({service: this.agent.service}) + const agent = new BskyAgent({service: this.service}) const inviteRes = await agent.api.com.atproto.server.createInviteCode( {useCount: 1}, @@ -332,7 +332,7 @@ class Mocker { } async createInvite(forAccount: string) { - const agent = new BskyAgent({service: this.agent.service}) + const agent = new BskyAgent({service: this.service}) await agent.api.com.atproto.server.createInviteCode( {useCount: 1, forAccount}, { diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index eb54dd29c1..6db96a8d63 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,7 +1,6 @@ import { AppBskyFeedDefs, AppBskyFeedGetFeed as GetCustomFeed, - AtpAgent, BskyAgent, } from '@atproto/api' @@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI { const agent = this.agent const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed) - const res = agent.session + const res = agent.did ? await this.agent.app.bsky.feed.getFeed( { ...this.params, @@ -106,34 +105,32 @@ async function loggedOutFetch({ let contentLangs = getContentLanguages().join(',') // manually construct fetch call so we can add the `lang` cache-busting param - let res = await AtpAgent.fetch!( + let res = await fetch( `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ cursor ? `&cursor=${cursor}` : '' }&limit=${limit}&lang=${contentLangs}`, - 'GET', - {'Accept-Language': contentLangs}, - undefined, + {method: 'GET', headers: {'Accept-Language': contentLangs}}, ) - if (res.body?.feed?.length) { + let data = res.ok ? await res.json() : null + if (data?.feed?.length) { return { success: true, - data: res.body, + data, } } // no data, try again with language headers removed - res = await AtpAgent.fetch!( + res = await fetch( `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${ cursor ? `&cursor=${cursor}` : '' }&limit=${limit}`, - 'GET', - {'Accept-Language': ''}, - undefined, + {method: 'GET', headers: {'Accept-Language': ''}}, ) - if (res.body?.feed?.length) { + data = res.ok ? await res.json() : null + if (data?.feed?.length) { return { success: true, - data: res.body, + data, } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 13fee02703..d2d8bcde2c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -280,7 +280,7 @@ export async function createThreadgate( const postUrip = new AtUri(postUri) await agent.api.com.atproto.repo.putRecord({ - repo: agent.session!.did, + repo: agent.accountDid, collection: 'app.bsky.feed.threadgate', rkey: postUrip.rkey, record: { diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 9bb57fcaf6..a264abfe5d 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -37,14 +37,14 @@ export function usePreferencesQuery() { refetchOnWindowFocus: true, queryKey: preferencesQueryKey, queryFn: async () => { - if (agent.session?.did === undefined) { + if (!agent.did) { return DEFAULT_LOGGED_OUT_PREFERENCES } else { const res = await agent.getPreferences() // save to local storage to ensure there are labels on initial requests saveLabelers( - agent.session.did, + agent.did, res.moderationPrefs.labelers.map(l => l.did), ) diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 4456ab0bf9..24fd9b27d5 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,5 +1,14 @@ +<<<<<<< HEAD import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' import {TID} from '@atproto/common-web' +======= +import { + AtpSessionData, + AtpSessionEvent, + BskyAgent, + SessionDispatcher, +} from '@atproto/api' +>>>>>>> 1a535b0b7 (use xrpc dispatcher) import {networkRetry} from '#/lib/async/retry' import { @@ -35,7 +44,7 @@ export async function createAgentAndResume( ) { const agent = new BskyAgent({service: storedAccount.service}) if (storedAccount.pdsUrl) { - agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl) + agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl) } const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') const moderation = configureModerationForAccount(agent, storedAccount) From 3476d2853c43fbcc5711c1d3dcc410b8411c67a5 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 14 Jul 2024 09:35:26 +0200 Subject: [PATCH 3/6] clean merge conflict --- src/state/session/agent.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 24fd9b27d5..e9801d62cb 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,14 +1,5 @@ -<<<<<<< HEAD import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api' import {TID} from '@atproto/common-web' -======= -import { - AtpSessionData, - AtpSessionEvent, - BskyAgent, - SessionDispatcher, -} from '@atproto/api' ->>>>>>> 1a535b0b7 (use xrpc dispatcher) import {networkRetry} from '#/lib/async/retry' import { From a05371024cf59d48b6ca849d76e9dc9f0b4062c1 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 14 Jul 2024 09:35:56 +0200 Subject: [PATCH 4/6] work with packages from atproto repo --- metro.config.js | 66 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/metro.config.js b/metro.config.js index a49d95f9aa..83daa20333 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,6 +1,70 @@ // Learn more https://docs.expo.io/guides/customizing-metro +const {resolve} = require('node:path') const {getDefaultConfig} = require('expo/metro-config') -const cfg = getDefaultConfig(__dirname) + +const projectRoot = __dirname +const atprotoRoot = resolve(projectRoot, '../atproto') + +const cfg = getDefaultConfig(projectRoot) + +const atprotoPackagesDirs = [ + 'packages/api', + 'packages/aws', + 'packages/bsky', + 'packages/bsync', + 'packages/common', + 'packages/common-web', + 'packages/crypto', + 'packages/dev-env', + 'packages/dev-infra', + 'packages/did', + 'packages/identity', + 'packages/lex-cli', + 'packages/lexicon', + 'packages/ozone', + 'packages/pds', + 'packages/repo', + 'packages/syntax', + 'packages/xrpc', + 'packages/xrpc-server', + 'packages/oauth/jwk', + 'packages/oauth/jwk-jose', + 'packages/oauth/jwk-webcrypto', + 'packages/oauth/oauth-client', + 'packages/oauth/oauth-client-browser', + 'packages/oauth/oauth-client-node', + 'packages/oauth/oauth-provider', + 'packages/oauth/oauth-types', + 'packages/internal/did-resolver', + 'packages/internal/fetch', + 'packages/internal/fetch-node', + 'packages/internal/handle-resolver', + 'packages/internal/handle-resolver-node', + 'packages/internal/identity-resolver', + 'packages/internal/pipe', + 'packages/internal/rollup-plugin-bundle-manifest', + 'packages/internal/simple-store', + 'packages/internal/simple-store-memory', +] + +cfg.resolver.extraNodeModules = Object.fromEntries( + atprotoPackagesDirs.map(dir => { + const namespace = dir.includes('internal') ? '@atproto-labs' : '@atproto' + const name = dir.split('/').pop() + return [`${namespace}/${name}`, resolve(atprotoRoot, dir)] + }), +) + +cfg.watchFolders = [ + projectRoot, + atprotoRoot, + ...Object.values(cfg.resolver.extraNodeModules), +] + +cfg.resolver.nodeModulesPaths = [ + resolve(projectRoot, 'node_modules'), + resolve(atprotoRoot, 'node_modules'), +] cfg.resolver.sourceExts = process.env.RN_SRC_EXT ? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts) From 8ddb28d3c54b63fb81ca361e741e5a6a46c1d25f Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 30 Jul 2024 08:25:31 -0700 Subject: [PATCH 5/6] [Video] Uploads (#4754) * state for video uploads * get upload working * add a debug log * add post progress * progress * fetch data * add some progress info, web uploads * post on finished uploading (wip) * add a note * add some todos * clear video * merge some stuff * convert to `createUploadTask` * patch expo modules core * working native upload progress * platform fork * upload progress for web * cleanup * cleanup * more tweaks * simplify * fix type errors --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> --- patches/expo-modules-core+1.12.11.patch | 12 + src/lib/api/index.ts | 4 + src/lib/media/video/types.ts | 36 +++ src/state/queries/video/compress-video.ts | 31 +++ src/state/queries/video/util.ts | 15 ++ src/state/queries/video/video-upload.ts | 59 +++++ src/state/queries/video/video-upload.web.ts | 66 ++++++ src/state/queries/video/video.ts | 212 ++++++++++++++++++ src/state/shell/post-progress.tsx | 18 ++ src/view/com/composer/Composer.tsx | 193 +++++++++++----- src/view/com/composer/videos/VideoPreview.tsx | 1 + .../videos/VideoTranscodeProgress.tsx | 8 +- src/view/com/composer/videos/state.ts | 51 ----- 13 files changed, 594 insertions(+), 112 deletions(-) create mode 100644 src/lib/media/video/types.ts create mode 100644 src/state/queries/video/compress-video.ts create mode 100644 src/state/queries/video/util.ts create mode 100644 src/state/queries/video/video-upload.ts create mode 100644 src/state/queries/video/video-upload.web.ts create mode 100644 src/state/queries/video/video.ts create mode 100644 src/state/shell/post-progress.tsx delete mode 100644 src/view/com/composer/videos/state.ts diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch index 4878bb9f7e..a4ee027c81 100644 --- a/patches/expo-modules-core+1.12.11.patch +++ b/patches/expo-modules-core+1.12.11.patch @@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644 Map constants = new HashMap<>(3); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); +diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js +index 109d3fe..c7fce9e 100644 +--- a/node_modules/expo-modules-core/build/uuid/uuid.js ++++ b/node_modules/expo-modules-core/build/uuid/uuid.js +@@ -1,5 +1,7 @@ + import bytesToUuid from './lib/bytesToUuid'; + import { Uuidv5Namespace } from './uuid.types'; ++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled'; ++ensureNativeModulesAreInstalled(); + const nativeUuidv4 = globalThis?.expo?.uuidv4; + const nativeUuidv5 = globalThis?.expo?.uuidv5; + function uuidv4() { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 5b1c998cb8..12e30bf6c1 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -54,6 +54,10 @@ interface PostOpts { uri: string cid: string } + video?: { + uri: string + cid: string + } extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts new file mode 100644 index 0000000000..c458da96e0 --- /dev/null +++ b/src/lib/media/video/types.ts @@ -0,0 +1,36 @@ +/** + * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION. + * @temporary + * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented. + * Not joking, this is temporary. + */ + +export interface JobStatus { + jobId: string + did: string + cid: string + state: JobState + progress?: number + errorHuman?: string + errorMachine?: string +} + +export enum JobState { + JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED', + JOB_STATE_CREATED = 'JOB_STATE_CREATED', + JOB_STATE_ENCODING = 'JOB_STATE_ENCODING', + JOB_STATE_ENCODED = 'JOB_STATE_ENCODED', + JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING', + JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED', + JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING', + JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED', + JOB_STATE_FAILED = 'JOB_STATE_FAILED', + JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED', +} + +export interface UploadVideoResponse { + job_id: string + did: string + cid: string + state: JobState +} diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts new file mode 100644 index 0000000000..a2c739cfde --- /dev/null +++ b/src/state/queries/video/compress-video.ts @@ -0,0 +1,31 @@ +import {ImagePickerAsset} from 'expo-image-picker' +import {useMutation} from '@tanstack/react-query' + +import {CompressedVideo, compressVideo} from 'lib/media/video/compress' + +export function useCompressVideoMutation({ + onProgress, + onSuccess, + onError, +}: { + onProgress: (progress: number) => void + onError: (e: any) => void + onSuccess: (video: CompressedVideo) => void +}) { + return useMutation({ + mutationFn: async (asset: ImagePickerAsset) => { + return await compressVideo(asset.uri, { + onProgress: num => onProgress(trunc2dp(num)), + }) + }, + onError, + onSuccess, + onMutate: () => { + onProgress(0) + }, + }) +} + +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts new file mode 100644 index 0000000000..266d8aee37 --- /dev/null +++ b/src/state/queries/video/util.ts @@ -0,0 +1,15 @@ +const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' + +export const createVideoEndpointUrl = ( + route: string, + params?: Record, +) => { + const url = new URL(`${UPLOAD_ENDPOINT}`) + url.pathname = route + if (params) { + for (const key in params) { + url.searchParams.set(key, params[key]) + } + } + return url.href +} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts new file mode 100644 index 0000000000..4d7f7995c5 --- /dev/null +++ b/src/state/queries/video/video-upload.ts @@ -0,0 +1,59 @@ +import {createUploadTask, FileSystemUploadType} from 'expo-file-system' +import {useMutation} from '@tanstack/react-query' +import {nanoid} from 'nanoid/non-secure' + +import {CompressedVideo} from 'lib/media/video/compress' +import {UploadVideoResponse} from 'lib/media/video/types' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useSession} from 'state/session' +const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' + +export const useUploadVideoMutation = ({ + onSuccess, + onError, + setProgress, +}: { + onSuccess: (response: UploadVideoResponse) => void + onError: (e: any) => void + setProgress: (progress: number) => void +}) => { + const {currentAccount} = useSession() + + return useMutation({ + mutationFn: async (video: CompressedVideo) => { + const uri = createVideoEndpointUrl('/upload', { + did: currentAccount!.did, + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + }) + + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'dev-key': UPLOAD_HEADER, + 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => { + setProgress(p.totalBytesSent / p.totalBytesExpectedToSend) + }, + ) + const res = await uploadTask.uploadAsync() + + if (!res?.body) { + throw new Error('No response') + } + + // @TODO rm, useful for debugging/getting video cid + console.log('[VIDEO]', res.body) + const responseBody = JSON.parse(res.body) as UploadVideoResponse + onSuccess(responseBody) + return responseBody + }, + onError, + onSuccess, + }) +} diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts new file mode 100644 index 0000000000..b5b9e93bf9 --- /dev/null +++ b/src/state/queries/video/video-upload.web.ts @@ -0,0 +1,66 @@ +import {useMutation} from '@tanstack/react-query' +import {nanoid} from 'nanoid/non-secure' + +import {CompressedVideo} from 'lib/media/video/compress' +import {UploadVideoResponse} from 'lib/media/video/types' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useSession} from 'state/session' +const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' + +export const useUploadVideoMutation = ({ + onSuccess, + onError, + setProgress, +}: { + onSuccess: (response: UploadVideoResponse) => void + onError: (e: any) => void + setProgress: (progress: number) => void +}) => { + const {currentAccount} = useSession() + + return useMutation({ + mutationFn: async (video: CompressedVideo) => { + const uri = createVideoEndpointUrl('/upload', { + did: currentAccount!.did, + name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? + }) + + const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + + const xhr = new XMLHttpRequest() + const res = (await new Promise((resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) + }) + xhr.onloadend = () => { + if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as UploadVideoResponse + resolve(uploadRes) + onSuccess(uploadRes) + } else { + reject() + onError(new Error('Failed to upload video')) + } + } + xhr.onerror = () => { + reject() + onError(new Error('Failed to upload video')) + } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type? + // @TODO remove this header for prod + xhr.setRequestHeader('dev-key', UPLOAD_HEADER) + xhr.send(bytes) + })) as UploadVideoResponse + + // @TODO rm for prod + console.log('[VIDEO]', res) + return res + }, + onError, + onSuccess, + }) +} diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts new file mode 100644 index 0000000000..295db38b43 --- /dev/null +++ b/src/state/queries/video/video.ts @@ -0,0 +1,212 @@ +import React from 'react' +import {ImagePickerAsset} from 'expo-image-picker' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQuery} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {CompressedVideo} from 'lib/media/video/compress' +import {VideoTooLargeError} from 'lib/media/video/errors' +import {JobState, JobStatus} from 'lib/media/video/types' +import {useCompressVideoMutation} from 'state/queries/video/compress-video' +import {createVideoEndpointUrl} from 'state/queries/video/util' +import {useUploadVideoMutation} from 'state/queries/video/video-upload' + +type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' + +type Action = + | { + type: 'SetStatus' + status: Status + } + | { + type: 'SetProgress' + progress: number + } + | { + type: 'SetError' + error: string | undefined + } + | {type: 'Reset'} + | {type: 'SetAsset'; asset: ImagePickerAsset} + | {type: 'SetVideo'; video: CompressedVideo} + | {type: 'SetJobStatus'; jobStatus: JobStatus} + +export interface State { + status: Status + progress: number + asset?: ImagePickerAsset + video: CompressedVideo | null + jobStatus?: JobStatus + error?: string +} + +function reducer(state: State, action: Action): State { + let updatedState = state + if (action.type === 'SetStatus') { + updatedState = {...state, status: action.status} + } else if (action.type === 'SetProgress') { + updatedState = {...state, progress: action.progress} + } else if (action.type === 'SetError') { + updatedState = {...state, error: action.error} + } else if (action.type === 'Reset') { + updatedState = { + status: 'idle', + progress: 0, + video: null, + } + } else if (action.type === 'SetAsset') { + updatedState = {...state, asset: action.asset} + } else if (action.type === 'SetVideo') { + updatedState = {...state, video: action.video} + } else if (action.type === 'SetJobStatus') { + updatedState = {...state, jobStatus: action.jobStatus} + } + return updatedState +} + +export function useUploadVideo({ + setStatus, + onSuccess, +}: { + setStatus: (status: string) => void + onSuccess: () => void +}) { + const {_} = useLingui() + const [state, dispatch] = React.useReducer(reducer, { + status: 'idle', + progress: 0, + video: null, + }) + + const {setJobId} = useUploadStatusQuery({ + onStatusChange: (status: JobStatus) => { + // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user + // Leaving it for now though + dispatch({ + type: 'SetJobStatus', + jobStatus: status, + }) + setStatus(status.state.toString()) + }, + onSuccess: () => { + dispatch({ + type: 'SetStatus', + status: 'idle', + }) + onSuccess() + }, + }) + + const {mutate: onVideoCompressed} = useUploadVideoMutation({ + onSuccess: response => { + dispatch({ + type: 'SetStatus', + status: 'processing', + }) + setJobId(response.job_id) + }, + onError: e => { + dispatch({ + type: 'SetError', + error: _(msg`An error occurred while uploading the video.`), + }) + logger.error('Error uploading video', {safeMessage: e}) + }, + setProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + }) + + const {mutate: onSelectVideo} = useCompressVideoMutation({ + onProgress: p => { + dispatch({type: 'SetProgress', progress: p}) + }, + onError: e => { + if (e instanceof VideoTooLargeError) { + dispatch({ + type: 'SetError', + error: _(msg`The selected video is larger than 100MB.`), + }) + } else { + dispatch({ + type: 'SetError', + // @TODO better error message from server, left untranslated on purpose + error: 'An error occurred while compressing the video.', + }) + logger.error('Error compressing video', {safeMessage: e}) + } + }, + onSuccess: (video: CompressedVideo) => { + dispatch({ + type: 'SetVideo', + video, + }) + dispatch({ + type: 'SetStatus', + status: 'uploading', + }) + onVideoCompressed(video) + }, + }) + + const selectVideo = (asset: ImagePickerAsset) => { + dispatch({ + type: 'SetAsset', + asset, + }) + dispatch({ + type: 'SetStatus', + status: 'compressing', + }) + onSelectVideo(asset) + } + + const clearVideo = () => { + // @TODO cancel any running jobs + dispatch({type: 'Reset'}) + } + + return { + state, + dispatch, + selectVideo, + clearVideo, + } +} + +const useUploadStatusQuery = ({ + onStatusChange, + onSuccess, +}: { + onStatusChange: (status: JobStatus) => void + onSuccess: () => void +}) => { + const [enabled, setEnabled] = React.useState(true) + const [jobId, setJobId] = React.useState() + + const {isLoading, isError} = useQuery({ + queryKey: ['video-upload'], + queryFn: async () => { + const url = createVideoEndpointUrl(`/job/${jobId}/status`) + const res = await fetch(url) + const status = (await res.json()) as JobStatus + if (status.state === JobState.JOB_STATE_COMPLETED) { + setEnabled(false) + onSuccess() + } + onStatusChange(status) + return status + }, + enabled: Boolean(jobId && enabled), + refetchInterval: 1500, + }) + + return { + isLoading, + isError, + setJobId: (_jobId: string) => { + setJobId(_jobId) + }, + } +} diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx new file mode 100644 index 0000000000..0df2a6be4a --- /dev/null +++ b/src/state/shell/post-progress.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +interface PostProgressState { + progress: number + status: 'pending' | 'success' | 'error' | 'idle' + error?: string +} + +const PostProgressContext = React.createContext({ + progress: 0, + status: 'idle', +}) + +export function Provider() {} + +export function usePostProgress() { + return React.useContext(PostProgressContext) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 72b6fae5fd..08ce4441f0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -13,10 +13,16 @@ import { Keyboard, KeyboardAvoidingView, LayoutChangeEvent, + StyleProp, StyleSheet, View, + ViewStyle, } from 'react-native' +// @ts-expect-error no type definition +import ProgressCircle from 'react-native-progress/Circle' import Animated, { + FadeIn, + FadeOut, interpolateColor, useAnimatedStyle, useSharedValue, @@ -55,6 +61,7 @@ import { import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateSetting} from '#/state/queries/threadgate' +import {useUploadVideo} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' @@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles' import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' import {useDialogStateControlContext} from 'state/dialogs' import {GalleryModel} from 'state/models/media/gallery' +import {State as VideoUploadState} from 'state/queries/video/video' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a, useTheme} from '#/alf' @@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' import {SelectVideoBtn} from './videos/SelectVideoBtn' -import {useVideoState} from './videos/state' import {VideoPreview} from './videos/VideoPreview' import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' @@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState( initQuote, ) + const { - video, - onSelectVideo, - videoPending, - videoProcessingData, + selectVideo, clearVideo, - videoProcessingProgress, - } = useVideoState({setError}) + state: videoUploadState, + } = useUploadVideo({ + setStatus: (status: string) => setProcessingState(status), + onSuccess: () => { + if (publishOnUpload) { + onPressPublish(true) + } + }, + }) + const [publishOnUpload, setPublishOnUpload] = useState(false) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) @@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({ return false }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) - const onPressPublish = async () => { + const onPressPublish = async (finishedUploading?: boolean) => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } @@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({ return } + if ( + !finishedUploading && + videoUploadState.status !== 'idle' && + videoUploadState.asset + ) { + setPublishOnUpload(true) + return + } + setError('') if ( @@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({ : _(msg`What's up?`) const canSelectImages = - gallery.size < 4 && !extLink && !video && !videoPending - const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) + gallery.size < 4 && + !extLink && + videoUploadState.status === 'idle' && + !videoUploadState.video + const hasMedia = + gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({ shape="default" size="small" style={[a.rounded_full, a.py_sm]} - onPress={onPressPublish}> + onPress={() => onPressPublish()} + disabled={ + videoUploadState.status !== 'idle' && publishOnUpload + }> {replyTo ? ( Reply @@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({ autoFocus setRichText={setRichText} onPhotoPasted={onPhotoPasted} - onPressPublish={onPressPublish} + onPressPublish={() => onPressPublish()} onNewLink={onNewLink} onError={setError} accessible={true} @@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({ )} - {quote ? ( - - - + + {quote ? ( + + + + + {quote.uri !== initQuote?.uri && ( + setQuote(undefined)} /> + )} - {quote.uri !== initQuote?.uri && ( - setQuote(undefined)} /> - )} - - ) : null} - {videoPending && videoProcessingData ? ( - - ) : ( - video && ( + ) : null} + {videoUploadState.status === 'compressing' && + videoUploadState.asset ? ( + + ) : videoUploadState.video ? ( // remove suspense when we get rid of lazy - + - ) - )} + ) : null} + @@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - - - {gate('videos') && ( - + ) : ( + + + {gate('videos') && ( + + )} + + - )} - - - {!isMobile ? ( - - ) : null} - + {!isMobile ? ( + + ) : null} + + )} @@ -893,3 +931,44 @@ const styles = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, }, }) + +function ToolbarWrapper({ + style, + children, +}: { + style: StyleProp + children: React.ReactNode +}) { + if (isWeb) return children + return ( + + {children} + + ) +} + +function VideoUploadToolbar({state}: {state: VideoUploadState}) { + const t = useTheme() + + const progress = + state.status === 'compressing' || state.status === 'uploading' + ? state.progress + : state.jobStatus?.progress ?? 100 + + return ( + + + {state.status} + + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index b04cdf1c8b..8e2a22852d 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -17,6 +17,7 @@ export function VideoPreview({ const player = useVideoPlayer(video.uri, player => { player.loop = true player.play() + player.volume = 0 }) return ( diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx index 79407cd3ef..db58448a30 100644 --- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -9,15 +9,15 @@ import {Text} from '#/components/Typography' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' export function VideoTranscodeProgress({ - input, + asset, progress, }: { - input: ImagePickerAsset + asset: ImagePickerAsset progress: number }) { const t = useTheme() - const aspectRatio = input.width / input.height + const aspectRatio = asset.width / asset.height return ( - + void}) { - const {_} = useLingui() - const [progress, setProgress] = useState(0) - - const {mutate, data, isPending, isError, reset, variables} = useMutation({ - mutationFn: async (asset: ImagePickerAsset) => { - const compressed = await compressVideo(asset.uri, { - onProgress: num => setProgress(trunc2dp(num)), - }) - - return compressed - }, - onError: (e: any) => { - // Don't log these errors in sentry, just let the user know - if (e instanceof VideoTooLargeError) { - Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark') - return - } - logger.error('Failed to compress video', {safeError: e}) - setError(_(msg`Could not compress video`)) - }, - onMutate: () => { - setProgress(0) - }, - }) - - return { - video: data, - onSelectVideo: mutate, - videoPending: isPending, - videoProcessingData: variables, - videoError: isError, - clearVideo: reset, - videoProcessingProgress: progress, - } -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -} From c3e77b56ffab9deb9f9a730ea984d801d84a1b94 Mon Sep 17 00:00:00 2001 From: GSMT Date: Wed, 31 Jul 2024 00:19:23 +0200 Subject: [PATCH 6/6] useDedupe callback (#4855) --- src/lib/hooks/useDedupe.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts index d9432cb2c2..13b5b83f58 100644 --- a/src/lib/hooks/useDedupe.ts +++ b/src/lib/hooks/useDedupe.ts @@ -3,7 +3,7 @@ import React from 'react' export const useDedupe = () => { const canDo = React.useRef(true) - return React.useRef((cb: () => unknown) => { + return React.useCallback((cb: () => unknown) => { if (canDo.current) { canDo.current = false setTimeout(() => { @@ -13,5 +13,5 @@ export const useDedupe = () => { return true } return false - }).current + }, []) }