Merge branch 'xrpc-rework' into hailey/upgrade-api
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-3
@@ -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)
|
||||
|
||||
+2
-2
@@ -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},
|
||||
{
|
||||
|
||||
+65
-1
@@ -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)
|
||||
|
||||
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
|
||||
|
||||
Map<String, Object> 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() {
|
||||
|
||||
@@ -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<string, string>
|
||||
body: any
|
||||
}
|
||||
|
||||
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 = 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<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 = 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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function doPolyfill() {
|
||||
// no polyfill is needed on web
|
||||
}
|
||||
+11
-14
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-34
@@ -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<ComAtprotoRepoUploadBlob.Response> {
|
||||
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
|
||||
@@ -54,6 +37,10 @@ interface PostOpts {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
@@ -297,7 +284,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: {
|
||||
@@ -308,15 +295,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
|
||||
}
|
||||
|
||||
@@ -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<ComAtprotoRepoUploadBlob.Response> {
|
||||
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<Blob> {
|
||||
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<T>(
|
||||
uri: string,
|
||||
fn: (path: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ComAtprotoRepoUploadBlob.Response> {
|
||||
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}`)
|
||||
}
|
||||
@@ -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
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
|
||||
|
||||
export const createVideoEndpointUrl = (
|
||||
route: string,
|
||||
params?: Record<string, string>,
|
||||
) => {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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<string>()
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,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)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
interface PostProgressState {
|
||||
progress: number
|
||||
status: 'pending' | 'success' | 'error' | 'idle'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PostProgressContext = React.createContext<PostProgressState>({
|
||||
progress: 0,
|
||||
status: 'idle',
|
||||
})
|
||||
|
||||
export function Provider() {}
|
||||
|
||||
export function usePostProgress() {
|
||||
return React.useContext(PostProgressContext)
|
||||
}
|
||||
@@ -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<ComposerOpts['quote'] | undefined>(
|
||||
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<Gif>()
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
@@ -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
|
||||
}>
|
||||
<ButtonText style={[a.text_md]}>
|
||||
{replyTo ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
@@ -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({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{quote ? (
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
<View style={[a.mt_md]}>
|
||||
{quote ? (
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{videoPending && videoProcessingData ? (
|
||||
<VideoTranscodeProgress
|
||||
input={videoProcessingData}
|
||||
progress={videoProcessingProgress}
|
||||
/>
|
||||
) : (
|
||||
video && (
|
||||
) : null}
|
||||
{videoUploadState.status === 'compressing' &&
|
||||
videoUploadState.asset ? (
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
// remove suspense when we get rid of lazy
|
||||
<Suspense fallback={null}>
|
||||
<VideoPreview video={video} clear={clearVideo} />
|
||||
<VideoPreview
|
||||
video={videoUploadState.video}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
|
||||
@@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
|
||||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={onSelectVideo}
|
||||
disabled={!canSelectImages}
|
||||
{videoUploadState.status !== 'idle' ? (
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
onSelectGif={onSelectGif}
|
||||
disabled={hasMedia}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
onSelectGif={onSelectGif}
|
||||
disabled={hasMedia}
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Open emoji picker`)}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<EmojiSmile size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Open emoji picker`)}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<EmojiSmile size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ToolbarWrapper>
|
||||
)}
|
||||
<View style={a.flex_1} />
|
||||
<SelectLangBtn />
|
||||
<CharProgress count={graphemeLength} />
|
||||
@@ -893,3 +931,44 @@ const styles = StyleSheet.create({
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
})
|
||||
|
||||
function ToolbarWrapper({
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
style: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (isWeb) return children
|
||||
return (
|
||||
<Animated.View
|
||||
style={style}
|
||||
entering={FadeIn.duration(400)}
|
||||
exiting={FadeOut.duration(400)}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
const t = useTheme()
|
||||
|
||||
const progress =
|
||||
state.status === 'compressing' || state.status === 'uploading'
|
||||
? state.progress
|
||||
: state.jobStatus?.progress ?? 100
|
||||
|
||||
return (
|
||||
<ToolbarWrapper
|
||||
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<ProgressCircle
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||
color={t.palette.primary_500}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>{state.status}</Text>
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function VideoPreview({
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
player.volume = 0
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||
]}>
|
||||
<VideoTranscodeBackdrop uri={input.uri} />
|
||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {useState} from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {compressVideo} from '#/lib/media/video/compress'
|
||||
import {logger} from '#/logger'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useVideoState({setError}: {setError: (error: string) => 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
|
||||
}
|
||||
Reference in New Issue
Block a user