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 'react-native-gesture-handler' // must be first
|
||||||
import {LogBox} from 'react-native'
|
|
||||||
|
|
||||||
import '#/platform/polyfills'
|
import '#/platform/polyfills'
|
||||||
import {IS_TEST} from '#/env'
|
|
||||||
|
import {LogBox} from 'react-native'
|
||||||
import {registerRootComponent} from 'expo'
|
import {registerRootComponent} from 'expo'
|
||||||
import {doPolyfill} from '#/lib/api/api-polyfill'
|
|
||||||
|
|
||||||
import App from '#/App'
|
import App from '#/App'
|
||||||
|
import {IS_TEST} from '#/env'
|
||||||
doPolyfill()
|
|
||||||
|
|
||||||
if (IS_TEST) {
|
if (IS_TEST) {
|
||||||
LogBox.ignoreAllLogs() // suppress all logs in tests
|
LogBox.ignoreAllLogs() // suppress all logs in tests
|
||||||
|
|||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
import '#/platform/markBundleStartTime'
|
import '#/platform/markBundleStartTime'
|
||||||
|
|
||||||
import '#/platform/polyfills'
|
import '#/platform/polyfills'
|
||||||
|
|
||||||
import {registerRootComponent} from 'expo'
|
import {registerRootComponent} from 'expo'
|
||||||
import {doPolyfill} from '#/lib/api/api-polyfill'
|
|
||||||
import App from '#/App'
|
import App from '#/App'
|
||||||
|
|
||||||
doPolyfill()
|
|
||||||
registerRootComponent(App)
|
registerRootComponent(App)
|
||||||
|
|||||||
+2
-2
@@ -156,7 +156,7 @@ class Mocker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createUser(name: string) {
|
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(
|
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||||
{useCount: 1},
|
{useCount: 1},
|
||||||
@@ -332,7 +332,7 @@ class Mocker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createInvite(forAccount: string) {
|
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(
|
await agent.api.com.atproto.server.createInviteCode(
|
||||||
{useCount: 1, forAccount},
|
{useCount: 1, forAccount},
|
||||||
{
|
{
|
||||||
|
|||||||
+65
-1
@@ -1,6 +1,70 @@
|
|||||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
|
const {resolve} = require('node:path')
|
||||||
const {getDefaultConfig} = require('expo/metro-config')
|
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
|
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
|
||||||
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
|
? 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);
|
Map<String, Object> constants = new HashMap<>(3);
|
||||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
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 {
|
import {
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedGetFeed as GetCustomFeed,
|
AppBskyFeedGetFeed as GetCustomFeed,
|
||||||
AtpAgent,
|
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI {
|
|||||||
const agent = this.agent
|
const agent = this.agent
|
||||||
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
|
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
|
||||||
|
|
||||||
const res = agent.session
|
const res = agent.did
|
||||||
? await this.agent.app.bsky.feed.getFeed(
|
? await this.agent.app.bsky.feed.getFeed(
|
||||||
{
|
{
|
||||||
...this.params,
|
...this.params,
|
||||||
@@ -106,34 +105,32 @@ async function loggedOutFetch({
|
|||||||
let contentLangs = getContentLanguages().join(',')
|
let contentLangs = getContentLanguages().join(',')
|
||||||
|
|
||||||
// manually construct fetch call so we can add the `lang` cache-busting param
|
// 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}${
|
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||||
cursor ? `&cursor=${cursor}` : ''
|
cursor ? `&cursor=${cursor}` : ''
|
||||||
}&limit=${limit}&lang=${contentLangs}`,
|
}&limit=${limit}&lang=${contentLangs}`,
|
||||||
'GET',
|
{method: 'GET', headers: {'Accept-Language': contentLangs}},
|
||||||
{'Accept-Language': contentLangs},
|
|
||||||
undefined,
|
|
||||||
)
|
)
|
||||||
if (res.body?.feed?.length) {
|
let data = res.ok ? await res.json() : null
|
||||||
|
if (data?.feed?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: res.body,
|
data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// no data, try again with language headers removed
|
// 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}${
|
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||||
cursor ? `&cursor=${cursor}` : ''
|
cursor ? `&cursor=${cursor}` : ''
|
||||||
}&limit=${limit}`,
|
}&limit=${limit}`,
|
||||||
'GET',
|
{method: 'GET', headers: {'Accept-Language': ''}},
|
||||||
{'Accept-Language': ''},
|
|
||||||
undefined,
|
|
||||||
)
|
)
|
||||||
if (res.body?.feed?.length) {
|
data = res.ok ? await res.json() : null
|
||||||
|
if (data?.feed?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: res.body,
|
data,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-34
@@ -6,7 +6,6 @@ import {
|
|||||||
AppBskyFeedThreadgate,
|
AppBskyFeedThreadgate,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
ComAtprotoLabelDefs,
|
ComAtprotoLabelDefs,
|
||||||
ComAtprotoRepoUploadBlob,
|
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
@@ -15,10 +14,13 @@ import {logger} from '#/logger'
|
|||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
import {isNetworkError} from 'lib/strings/errors'
|
import {isNetworkError} from 'lib/strings/errors'
|
||||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
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 {ImageModel} from 'state/models/media/image'
|
||||||
import {LinkMeta} from '../link-meta/link-meta'
|
import {LinkMeta} from '../link-meta/link-meta'
|
||||||
import {safeDeleteAsync} from '../media/manip'
|
import {safeDeleteAsync} from '../media/manip'
|
||||||
|
import {uploadBlob} from './upload-blob'
|
||||||
|
|
||||||
|
export {uploadBlob}
|
||||||
|
|
||||||
export interface ExternalEmbedDraft {
|
export interface ExternalEmbedDraft {
|
||||||
uri: string
|
uri: string
|
||||||
@@ -28,25 +30,6 @@ export interface ExternalEmbedDraft {
|
|||||||
localThumb?: ImageModel
|
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 {
|
interface PostOpts {
|
||||||
rawText: string
|
rawText: string
|
||||||
replyTo?: string
|
replyTo?: string
|
||||||
@@ -54,6 +37,10 @@ interface PostOpts {
|
|||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
|
video?: {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
@@ -297,7 +284,7 @@ export async function createThreadgate(
|
|||||||
|
|
||||||
const postUrip = new AtUri(postUri)
|
const postUrip = new AtUri(postUri)
|
||||||
await agent.api.com.atproto.repo.putRecord({
|
await agent.api.com.atproto.repo.putRecord({
|
||||||
repo: agent.session!.did,
|
repo: agent.accountDid,
|
||||||
collection: 'app.bsky.feed.threadgate',
|
collection: 'app.bsky.feed.threadgate',
|
||||||
rkey: postUrip.rkey,
|
rkey: postUrip.rkey,
|
||||||
record: {
|
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 = () => {
|
export const useDedupe = () => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useRef((cb: () => unknown) => {
|
return React.useCallback((cb: () => unknown) => {
|
||||||
if (canDo.current) {
|
if (canDo.current) {
|
||||||
canDo.current = false
|
canDo.current = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -13,5 +13,5 @@ export const useDedupe = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}).current
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) {
|
|||||||
// Normalize is necessary for Android, otherwise it doesn't delete.
|
// Normalize is necessary for Android, otherwise it doesn't delete.
|
||||||
const normalizedPath = normalizePath(path)
|
const normalizedPath = normalizePath(path)
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await deleteAsync(normalizedPath, {idempotent: true})
|
||||||
deleteAsync(normalizedPath, {idempotent: true}),
|
|
||||||
// HACK: Try this one too. Might exist due to api-polyfill hack.
|
|
||||||
deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
|
|
||||||
idempotent: true,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete file', 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,
|
refetchOnWindowFocus: true,
|
||||||
queryKey: preferencesQueryKey,
|
queryKey: preferencesQueryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (agent.session?.did === undefined) {
|
if (!agent.did) {
|
||||||
return DEFAULT_LOGGED_OUT_PREFERENCES
|
return DEFAULT_LOGGED_OUT_PREFERENCES
|
||||||
} else {
|
} else {
|
||||||
const res = await agent.getPreferences()
|
const res = await agent.getPreferences()
|
||||||
|
|
||||||
// save to local storage to ensure there are labels on initial requests
|
// save to local storage to ensure there are labels on initial requests
|
||||||
saveLabelers(
|
saveLabelers(
|
||||||
agent.session.did,
|
agent.did,
|
||||||
res.moderationPrefs.labelers.map(l => l.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})
|
const agent = new BskyAgent({service: storedAccount.service})
|
||||||
if (storedAccount.pdsUrl) {
|
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 gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
|
||||||
const moderation = configureModerationForAccount(agent, storedAccount)
|
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,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
|
StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
// @ts-expect-error no type definition
|
||||||
|
import ProgressCircle from 'react-native-progress/Circle'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -55,6 +61,7 @@ import {
|
|||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {Gif} from '#/state/queries/tenor'
|
import {Gif} from '#/state/queries/tenor'
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
|
import {useUploadVideo} from '#/state/queries/video/video'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
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 {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {useDialogStateControlContext} from 'state/dialogs'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
|
import {State as VideoUploadState} from 'state/queries/video/video'
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
|||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
||||||
import {useVideoState} from './videos/state'
|
|
||||||
import {VideoPreview} from './videos/VideoPreview'
|
import {VideoPreview} from './videos/VideoPreview'
|
||||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
||||||
|
|
||||||
@@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
video,
|
selectVideo,
|
||||||
onSelectVideo,
|
|
||||||
videoPending,
|
|
||||||
videoProcessingData,
|
|
||||||
clearVideo,
|
clearVideo,
|
||||||
videoProcessingProgress,
|
state: videoUploadState,
|
||||||
} = useVideoState({setError})
|
} = useUploadVideo({
|
||||||
|
setStatus: (status: string) => setProcessingState(status),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (publishOnUpload) {
|
||||||
|
onPressPublish(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||||
const [extGif, setExtGif] = useState<Gif>()
|
const [extGif, setExtGif] = useState<Gif>()
|
||||||
const [labels, setLabels] = useState<string[]>([])
|
const [labels, setLabels] = useState<string[]>([])
|
||||||
@@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
return false
|
return false
|
||||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||||
|
|
||||||
const onPressPublish = async () => {
|
const onPressPublish = async (finishedUploading?: boolean) => {
|
||||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!finishedUploading &&
|
||||||
|
videoUploadState.status !== 'idle' &&
|
||||||
|
videoUploadState.asset
|
||||||
|
) {
|
||||||
|
setPublishOnUpload(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
: _(msg`What's up?`)
|
: _(msg`What's up?`)
|
||||||
|
|
||||||
const canSelectImages =
|
const canSelectImages =
|
||||||
gallery.size < 4 && !extLink && !video && !videoPending
|
gallery.size < 4 &&
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
|
!extLink &&
|
||||||
|
videoUploadState.status === 'idle' &&
|
||||||
|
!videoUploadState.video
|
||||||
|
const hasMedia =
|
||||||
|
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
@@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
style={[a.rounded_full, a.py_sm]}
|
style={[a.rounded_full, a.py_sm]}
|
||||||
onPress={onPressPublish}>
|
onPress={() => onPressPublish()}
|
||||||
|
disabled={
|
||||||
|
videoUploadState.status !== 'idle' && publishOnUpload
|
||||||
|
}>
|
||||||
<ButtonText style={[a.text_md]}>
|
<ButtonText style={[a.text_md]}>
|
||||||
{replyTo ? (
|
{replyTo ? (
|
||||||
<Trans context="action">Reply</Trans>
|
<Trans context="action">Reply</Trans>
|
||||||
@@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
autoFocus
|
autoFocus
|
||||||
setRichText={setRichText}
|
setRichText={setRichText}
|
||||||
onPhotoPasted={onPhotoPasted}
|
onPhotoPasted={onPhotoPasted}
|
||||||
onPressPublish={onPressPublish}
|
onPressPublish={() => onPressPublish()}
|
||||||
onNewLink={onNewLink}
|
onNewLink={onNewLink}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
@@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{quote ? (
|
<View style={[a.mt_md]}>
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
{quote ? (
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<QuoteEmbed quote={quote} />
|
<View style={{pointerEvents: 'none'}}>
|
||||||
|
<QuoteEmbed quote={quote} />
|
||||||
|
</View>
|
||||||
|
{quote.uri !== initQuote?.uri && (
|
||||||
|
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{quote.uri !== initQuote?.uri && (
|
) : null}
|
||||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
{videoUploadState.status === 'compressing' &&
|
||||||
)}
|
videoUploadState.asset ? (
|
||||||
</View>
|
<VideoTranscodeProgress
|
||||||
) : null}
|
asset={videoUploadState.asset}
|
||||||
{videoPending && videoProcessingData ? (
|
progress={videoUploadState.progress}
|
||||||
<VideoTranscodeProgress
|
/>
|
||||||
input={videoProcessingData}
|
) : videoUploadState.video ? (
|
||||||
progress={videoProcessingProgress}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
video && (
|
|
||||||
// remove suspense when we get rid of lazy
|
// remove suspense when we get rid of lazy
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<VideoPreview video={video} clear={clearVideo} />
|
<VideoPreview
|
||||||
|
video={videoUploadState.video}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
) : null}
|
||||||
)}
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
|
|
||||||
@@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
styles.bottomBar,
|
styles.bottomBar,
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
{videoUploadState.status !== 'idle' ? (
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<VideoUploadToolbar state={videoUploadState} />
|
||||||
{gate('videos') && (
|
) : (
|
||||||
<SelectVideoBtn
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
onSelectVideo={onSelectVideo}
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
disabled={!canSelectImages}
|
{gate('videos') && (
|
||||||
|
<SelectVideoBtn
|
||||||
|
onSelectVideo={selectVideo}
|
||||||
|
disabled={!canSelectImages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
|
<SelectGifBtn
|
||||||
|
onClose={focusTextInput}
|
||||||
|
onSelectGif={onSelectGif}
|
||||||
|
disabled={hasMedia}
|
||||||
/>
|
/>
|
||||||
)}
|
{!isMobile ? (
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
<Button
|
||||||
<SelectGifBtn
|
onPress={onEmojiButtonPress}
|
||||||
onClose={focusTextInput}
|
style={a.p_sm}
|
||||||
onSelectGif={onSelectGif}
|
label={_(msg`Open emoji picker`)}
|
||||||
disabled={hasMedia}
|
accessibilityHint={_(msg`Open emoji picker`)}
|
||||||
/>
|
variant="ghost"
|
||||||
{!isMobile ? (
|
shape="round"
|
||||||
<Button
|
color="primary">
|
||||||
onPress={onEmojiButtonPress}
|
<EmojiSmile size="lg" />
|
||||||
style={a.p_sm}
|
</Button>
|
||||||
label={_(msg`Open emoji picker`)}
|
) : null}
|
||||||
accessibilityHint={_(msg`Open emoji picker`)}
|
</ToolbarWrapper>
|
||||||
variant="ghost"
|
)}
|
||||||
shape="round"
|
|
||||||
color="primary">
|
|
||||||
<EmojiSmile size="lg" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View style={a.flex_1} />
|
<View style={a.flex_1} />
|
||||||
<SelectLangBtn />
|
<SelectLangBtn />
|
||||||
<CharProgress count={graphemeLength} />
|
<CharProgress count={graphemeLength} />
|
||||||
@@ -893,3 +931,44 @@ const styles = StyleSheet.create({
|
|||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
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 => {
|
const player = useVideoPlayer(video.uri, player => {
|
||||||
player.loop = true
|
player.loop = true
|
||||||
player.play()
|
player.play()
|
||||||
|
player.volume = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
|
|||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
export function VideoTranscodeProgress({
|
export function VideoTranscodeProgress({
|
||||||
input,
|
asset,
|
||||||
progress,
|
progress,
|
||||||
}: {
|
}: {
|
||||||
input: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
progress: number
|
progress: number
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const aspectRatio = input.width / input.height
|
const aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||||
]}>
|
]}>
|
||||||
<VideoTranscodeBackdrop uri={input.uri} />
|
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
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