[Video] Uploads (#4754)
* state for video uploads * get upload working * add a debug log * add post progress * progress * fetch data * add some progress info, web uploads * post on finished uploading (wip) * add a note * add some todos * clear video * merge some stuff * convert to `createUploadTask` * patch expo modules core * working native upload progress * platform fork * upload progress for web * cleanup * cleanup * more tweaks * simplify * fix type errors --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>zio/stable
parent
43ba0f21f6
commit
8ddb28d3c5
|
@ -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() {
|
||||||
|
|
|
@ -54,6 +54,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[]
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,6 +632,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View style={[a.mt_md]}>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={{pointerEvents: 'none'}}>
|
||||||
|
@ -612,19 +643,22 @@ export const ComposePost = observer(function ComposePost({
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{videoPending && videoProcessingData ? (
|
{videoUploadState.status === 'compressing' &&
|
||||||
|
videoUploadState.asset ? (
|
||||||
<VideoTranscodeProgress
|
<VideoTranscodeProgress
|
||||||
input={videoProcessingData}
|
asset={videoUploadState.asset}
|
||||||
progress={videoProcessingProgress}
|
progress={videoUploadState.progress}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : videoUploadState.video ? (
|
||||||
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,11 +675,14 @@ 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' ? (
|
||||||
|
<VideoUploadToolbar state={videoUploadState} />
|
||||||
|
) : (
|
||||||
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
{gate('videos') && (
|
{gate('videos') && (
|
||||||
<SelectVideoBtn
|
<SelectVideoBtn
|
||||||
onSelectVideo={onSelectVideo}
|
onSelectVideo={selectVideo}
|
||||||
disabled={!canSelectImages}
|
disabled={!canSelectImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -667,7 +704,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
<EmojiSmile size="lg" />
|
<EmojiSmile size="lg" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</ToolbarWrapper>
|
||||||
|
)}
|
||||||
<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
|
|
||||||
}
|
|
Loading…
Reference in New Issue