[Sheets] [Pt. 15] Fix up dialogs inside the composer (#5589)

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: dan <dan.abramov@gmail.com>
This commit is contained in:
Hailey
2024-10-02 22:08:36 -07:00
committed by GitHub
parent 058ddb0c53
commit 89ac12516d
14 changed files with 954 additions and 829 deletions
@@ -38,6 +38,7 @@ import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable' import {NormalizedRNGHPressable} from '#/components/NormalizedRNGHPressable'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = { export type PostInteractionSettingsFormProps = {
@@ -55,13 +56,15 @@ export type PostInteractionSettingsFormProps = {
export function PostInteractionSettingsControlledDialog({ export function PostInteractionSettingsControlledDialog({
control, control,
Portal,
...rest ...rest
}: PostInteractionSettingsFormProps & { }: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
Portal?: PortalComponent
}) { }) {
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control} Portal={Portal}>
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)} label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}> style={[{maxWidth: 500}, a.w_full]}>
@@ -230,7 +233,6 @@ export function PostInteractionSettingsForm({
}: PostInteractionSettingsFormProps) { }: PostInteractionSettingsFormProps) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate') const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState( const [quotesEnabled, setQuotesEnabled] = React.useState(
!( !(
@@ -437,7 +439,6 @@ export function PostInteractionSettingsForm({
<Button <Button
label={_(msg`Save`)} label={_(msg`Save`)}
onPress={onSave} onPress={onSave}
onAccessibilityEscape={control.close}
color="primary" color="primary"
size="large" size="large"
variant="solid" variant="solid"
-39
View File
@@ -1,39 +0,0 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/types'
import {compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({
onProgress,
onSuccess,
onError,
signal,
}: {
onProgress: (progress: number) => void
onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void
signal: AbortSignal
}) {
return useMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
signal,
),
onError,
onSuccess,
onMutate: () => {
onProgress(0)
},
})
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
+1 -4
View File
@@ -1,4 +1,3 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api' import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
@@ -17,12 +16,10 @@ export const createVideoEndpointUrl = (
return url.href return url.href
} }
export function useVideoAgent() { export function createVideoAgent() {
return useMemo(() => {
return new AtpAgent({ return new AtpAgent({
service: VIDEO_SERVICE, service: VIDEO_SERVICE,
}) })
}, [])
} }
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+12 -24
View File
@@ -1,55 +1,44 @@
import {useCallback} from 'react' import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VIDEO_SERVICE_DID} from '#/lib/constants' import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors' import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {useAgent} from '#/state/session' import {createVideoAgent} from './util'
import {useVideoAgent} from './util'
export function useServiceAuthToken({ export async function getServiceAuthToken({
agent,
aud, aud,
lxm, lxm,
exp, exp,
}: { }: {
agent: BskyAgent
aud?: string aud?: string
lxm: string lxm: string
exp?: number exp?: number
}) { }) {
const agent = useAgent()
return useCallback(async () => {
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
if (!pdsAud) { if (!pdsAud) {
throw new Error('Agent does not have a PDS URL') throw new Error('Agent does not have a PDS URL')
} }
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
aud: aud ?? pdsAud, aud: aud ?? pdsAud,
lxm, lxm,
exp, exp,
}) })
return serviceAuth.token return serviceAuth.token
}, [agent, aud, lxm, exp])
} }
export function useVideoUploadLimits() { export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
const agent = useVideoAgent() const token = await getServiceAuthToken({
const getToken = useServiceAuthToken({ agent,
lxm: 'app.bsky.video.getUploadLimits', lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID, aud: VIDEO_SERVICE_DID,
}) })
const {_} = useLingui() const videoAgent = createVideoAgent()
const {data: limits} = await videoAgent.app.bsky.video
return useCallback(async () => { .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
const {data: limits} = await agent.app.bsky.video
.getUploadLimits(
{},
{headers: {Authorization: `Bearer ${await getToken()}`}},
)
.catch(err => { .catch(err => {
if (err instanceof Error) { if (err instanceof Error) {
throw new UploadLimitError(err.message) throw new UploadLimitError(err.message)
@@ -69,5 +58,4 @@ export function useVideoUploadLimits() {
) )
} }
} }
}, [agent, _, getToken])
} }
+36 -33
View File
@@ -1,59 +1,65 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system' import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {AppBskyVideoDefs} from '@atproto/api' import {AppBskyVideoDefs, BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useSession} from '#/state/session' import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({ export async function uploadVideo({
onSuccess, video,
onError, agent,
did,
setProgress, setProgress,
signal, signal,
_,
}: { }: {
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void video: CompressedVideo
onError: (e: any) => void agent: BskyAgent
did: string
setProgress: (progress: number) => void setProgress: (progress: number) => void
signal: AbortSignal signal: AbortSignal
}) => { _: I18n['_']
const {currentAccount} = useSession() }) {
const getToken = useServiceAuthToken({ if (signal.aborted) {
lxm: 'com.atproto.repo.uploadBlob', throw new AbortError()
exp: Date.now() / 1000 + 60 * 30, // 30 minutes }
}) await getVideoUploadLimits(agent, _)
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
}) })
if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
const uploadTask = createUploadTask( const uploadTask = createUploadTask(
uri, uri,
video.uri, video.uri,
{ {
headers: { headers: {
'content-type': video.mimeType, 'content-type': video.mimeType,
Authorization: `Bearer ${await getToken()}`, Authorization: `Bearer ${token}`,
}, },
httpMethod: 'POST', httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT, uploadType: FileSystemUploadType.BINARY_CONTENT,
}, },
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
) )
if (signal.aborted) {
throw new AbortError()
}
const res = await uploadTask.uploadAsync() const res = await uploadTask.uploadAsync()
if (!res?.body) { if (!res?.body) {
@@ -63,14 +69,11 @@ export const useUploadVideoMutation = ({
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
if (!responseBody.jobId) { if (!responseBody.jobId) {
throw new ServerError( throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
responseBody.error || _(msg`Failed to upload video`),
)
} }
if (signal.aborted) {
throw new AbortError()
}
return responseBody return responseBody
}, signal),
onError,
onSuccess,
})
} }
+39 -30
View File
@@ -1,52 +1,60 @@
import {AppBskyVideoDefs} from '@atproto/api' import {AppBskyVideoDefs} from '@atproto/api'
import {BskyAgent} from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useSession} from '#/state/session' import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({ export async function uploadVideo({
onSuccess, video,
onError, agent,
did,
setProgress, setProgress,
signal, signal,
_,
}: { }: {
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void video: CompressedVideo
onError: (e: any) => void agent: BskyAgent
did: string
setProgress: (progress: number) => void setProgress: (progress: number) => void
signal: AbortSignal signal: AbortSignal
}) => { _: I18n['_']
const {currentAccount} = useSession() }) {
const getToken = useServiceAuthToken({ if (signal.aborted) {
lxm: 'com.atproto.repo.uploadBlob', throw new AbortError()
exp: Date.now() / 1000 + 60 * 30, // 30 minutes }
}) await getVideoUploadLimits(agent, _)
const checkLimits = useVideoUploadLimits()
const {_} = useLingui()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did,
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
}) })
let bytes = video.bytes let bytes = video.bytes
if (!bytes) { if (!bytes) {
if (signal.aborted) {
throw new AbortError()
}
bytes = await fetch(video.uri).then(res => res.arrayBuffer()) bytes = await fetch(video.uri).then(res => res.arrayBuffer())
} }
const token = await getToken() if (signal.aborted) {
throw new AbortError()
}
const token = await getServiceAuthToken({
agent,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
if (signal.aborted) {
throw new AbortError()
}
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>( const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => { (resolve, reject) => {
@@ -55,7 +63,9 @@ export const useUploadVideoMutation = ({
setProgress(progress) setProgress(progress)
}) })
xhr.onloadend = () => { xhr.onloadend = () => {
if (xhr.readyState === 4) { if (signal.aborted) {
reject(new AbortError())
} else if (xhr.readyState === 4) {
const uploadRes = JSON.parse( const uploadRes = JSON.parse(
xhr.responseText, xhr.responseText,
) as AppBskyVideoDefs.JobStatus ) as AppBskyVideoDefs.JobStatus
@@ -78,9 +88,8 @@ export const useUploadVideoMutation = ({
throw new ServerError(res.error || _(msg`Failed to upload video`)) throw new ServerError(res.error || _(msg`Failed to upload video`))
} }
if (signal.aborted) {
throw new AbortError()
}
return res return res
}, signal),
onError,
onSuccess,
})
} }
+361 -291
View File
@@ -1,12 +1,11 @@
import React, {useCallback, useEffect} from 'react'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api'
import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' import {compressVideo} from '#/lib/media/video/compress'
import { import {
ServerError, ServerError,
UploadLimitError, UploadLimitError,
@@ -14,338 +13,409 @@ import {
} from '#/lib/media/video/errors' } from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {createVideoAgent} from '#/state/queries/video/util'
import {useCompressVideoMutation} from '#/state/queries/video/compress-video' import {uploadVideo} from '#/state/queries/video/video-upload'
import {useVideoAgent} from '#/state/queries/video/util'
import {useUploadVideoMutation} from '#/state/queries/video/video-upload'
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
type Action = type Action =
| {type: 'SetStatus'; status: Status} | {type: 'to_idle'; nextController: AbortController}
| {type: 'SetProgress'; progress: number} | {
| {type: 'SetError'; error: string | undefined} type: 'idle_to_compressing'
| {type: 'Reset'} asset: ImagePickerAsset
| {type: 'SetAsset'; asset: ImagePickerAsset} signal: AbortSignal
| {type: 'SetDimensions'; width: number; height: number} }
| {type: 'SetVideo'; video: CompressedVideo} | {
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} type: 'compressing_to_uploading'
| {type: 'SetComplete'; blobRef: BlobRef} video: CompressedVideo
signal: AbortSignal
}
| {
type: 'uploading_to_processing'
jobId: string
signal: AbortSignal
}
| {type: 'to_error'; error: string; signal: AbortSignal}
| {
type: 'to_done'
blobRef: BlobRef
signal: AbortSignal
}
| {type: 'update_progress'; progress: number; signal: AbortSignal}
| {
type: 'update_dimensions'
width: number
height: number
signal: AbortSignal
}
| {
type: 'update_job_status'
jobStatus: AppBskyVideoDefs.JobStatus
signal: AbortSignal
}
export interface State { type IdleState = {
status: Status status: 'idle'
progress: number progress: 0
asset?: ImagePickerAsset
video: CompressedVideo | null
jobStatus?: AppBskyVideoDefs.JobStatus
blobRef?: BlobRef
error?: string
abortController: AbortController abortController: AbortController
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} asset?: undefined
video?: undefined
jobId?: undefined
pendingPublish?: undefined
} }
export type VideoUploadDispatch = (action: Action) => void type ErrorState = {
status: 'error'
progress: 100
abortController: AbortController
asset: ImagePickerAsset | null
video: CompressedVideo | null
jobId: string | null
error: string
pendingPublish?: undefined
}
function reducer(queryClient: QueryClient) { type CompressingState = {
return (state: State, action: Action): State => { status: 'compressing'
let updatedState = state progress: number
if (action.type === 'SetStatus') { abortController: AbortController
updatedState = {...state, status: action.status} asset: ImagePickerAsset
} else if (action.type === 'SetProgress') { video?: undefined
updatedState = {...state, progress: action.progress} jobId?: undefined
} else if (action.type === 'SetError') { pendingPublish?: undefined
updatedState = {...state, error: action.error} }
} else if (action.type === 'Reset') {
state.abortController.abort() type UploadingState = {
queryClient.cancelQueries({ status: 'uploading'
queryKey: ['video'], progress: number
}) abortController: AbortController
updatedState = { asset: ImagePickerAsset
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
}
type ProcessingState = {
status: 'processing'
progress: number
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
}
type DoneState = {
status: 'done'
progress: 100
abortController: AbortController
asset: ImagePickerAsset
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
}
export type State =
| IdleState
| ErrorState
| CompressingState
| UploadingState
| ProcessingState
| DoneState
export function createVideoState(
abortController: AbortController = new AbortController(),
): IdleState {
return {
status: 'idle', status: 'idle',
progress: 0, progress: 0,
video: null, abortController,
blobRef: undefined,
abortController: new AbortController(),
} }
} else if (action.type === 'SetAsset') { }
updatedState = {
export function videoReducer(state: State, action: Action): State {
if (action.type === 'to_idle') {
return createVideoState(action.nextController)
}
if (action.signal.aborted || action.signal !== state.abortController.signal) {
// This action is stale and the process that spawned it is no longer relevant.
return state
}
if (action.type === 'to_error') {
return {
status: 'error',
progress: 100,
abortController: state.abortController,
error: action.error,
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
}
} else if (action.type === 'update_progress') {
if (state.status === 'compressing' || state.status === 'uploading') {
return {
...state, ...state,
asset: action.asset, progress: action.progress,
}
}
} else if (action.type === 'idle_to_compressing') {
if (state.status === 'idle') {
return {
status: 'compressing', status: 'compressing',
error: undefined, progress: 0,
abortController: state.abortController,
asset: action.asset,
} }
} else if (action.type === 'SetDimensions') {
updatedState = {
...state,
asset: state.asset
? {...state.asset, width: action.width, height: action.height}
: undefined,
} }
} else if (action.type === 'SetVideo') { } else if (action.type === 'update_dimensions') {
updatedState = {...state, video: action.video, status: 'uploading'} if (state.asset) {
} else if (action.type === 'SetJobStatus') { return {
updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetComplete') {
updatedState = {
...state, ...state,
asset: {...state.asset, width: action.width, height: action.height},
}
}
} else if (action.type === 'compressing_to_uploading') {
if (state.status === 'compressing') {
return {
status: 'uploading',
progress: 0,
abortController: state.abortController,
asset: state.asset,
video: action.video,
}
}
return state
} else if (action.type === 'uploading_to_processing') {
if (state.status === 'uploading') {
return {
status: 'processing',
progress: 0,
abortController: state.abortController,
asset: state.asset,
video: state.video,
jobId: action.jobId,
jobStatus: null,
}
}
} else if (action.type === 'update_job_status') {
if (state.status === 'processing') {
return {
...state,
jobStatus: action.jobStatus,
progress:
action.jobStatus.progress !== undefined
? action.jobStatus.progress / 100
: state.progress,
}
}
} else if (action.type === 'to_done') {
if (state.status === 'processing') {
return {
status: 'done',
progress: 100,
abortController: state.abortController,
asset: state.asset,
video: state.video,
pendingPublish: { pendingPublish: {
blobRef: action.blobRef, blobRef: action.blobRef,
mutableProcessed: false, mutableProcessed: false,
}, },
status: 'done',
} }
} }
return updatedState
} }
console.error(
'Unexpected video action (' +
action.type +
') while in ' +
state.status +
' state',
)
return state
} }
export function useUploadVideo({ function trunc2dp(num: number) {
setStatus, return Math.trunc(num * 100) / 100
initialVideoUri, }
}: {
setStatus: (status: string) => void
onSuccess: () => void
initialVideoUri?: string
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const [state, dispatch] = React.useReducer(reducer(queryClient), {
status: 'idle',
progress: 0,
video: null,
abortController: new AbortController(),
})
const {setJobId} = useUploadStatusQuery({ export async function processVideo(
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { asset: ImagePickerAsset,
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user dispatch: (action: Action) => void,
// Leaving it for now though agent: BskyAgent,
did: string,
signal: AbortSignal,
_: I18n['_'],
) {
dispatch({ dispatch({
type: 'SetJobStatus', type: 'idle_to_compressing',
jobStatus: status,
})
setStatus(status.state.toString())
},
onSuccess: blobRef => {
dispatch({
type: 'SetComplete',
blobRef,
})
},
onError: useCallback(
error => {
logger.error('Error processing video', {safeMessage: error})
dispatch({
type: 'SetError',
error: _(msg`Video failed to process`),
})
},
[_],
),
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
onSuccess: response => {
dispatch({
type: 'SetStatus',
status: 'processing',
})
setJobId(response.jobId)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
let message
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
message = _(msg`You are not allowed to upload videos.`)
break
case 'Uploading is disabled at the moment':
message = _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
break
case "Failed to get user's upload stats":
message = _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
break
case 'User has exceeded daily upload bytes limit':
message = _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
break
case 'User has exceeded daily upload videos limit':
message = _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
break
case 'Account is not old enough to upload videos':
message = _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
break
default:
message = e.message
break
}
dispatch({
type: 'SetError',
error: message,
})
} else {
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})
},
signal: state.abortController.signal,
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 50MB.`),
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while compressing the video.`),
})
logger.error('Error compressing video', {safeMessage: e})
}
},
signal: state.abortController.signal,
})
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
// compression step on native converts to mp4, so no need to check there
if (isWeb) {
const mimeType = getMimeType(asset)
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
}
}
dispatch({
type: 'SetAsset',
asset, asset,
signal,
}) })
onSelectVideo(asset)
let video: CompressedVideo | undefined
try {
video = await compressVideo(asset, {
onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
}, },
[_, onSelectVideo], signal,
) })
} catch (e) {
const clearVideo = () => { const message = getCompressErrorMessage(e, _)
dispatch({type: 'Reset'}) if (message !== null) {
} dispatch({
type: 'to_error',
const updateVideoDimensions = useCallback((width: number, height: number) => { error: message,
dispatch({ signal,
type: 'SetDimensions',
width,
height,
}) })
}, [])
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initialVideoUri) {
selectVideo({uri: initialVideoUri} as ImagePickerAsset)
} }
}, [initialVideoUri, selectVideo]) return
return {
state,
dispatch,
selectVideo,
clearVideo,
updateVideoDimensions,
} }
} dispatch({
type: 'compressing_to_uploading',
video,
signal,
})
const useUploadStatusQuery = ({ let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
onStatusChange, try {
onSuccess, uploadResponse = await uploadVideo({
onError, video,
}: { agent,
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void did,
onSuccess: (blobRef: BlobRef) => void signal,
onError: (error: Error) => void _,
}) => { setProgress: p => {
const videoAgent = useVideoAgent() dispatch({type: 'update_progress', progress: p, signal})
const [enabled, setEnabled] = React.useState(true) },
const [jobId, setJobId] = React.useState<string>() })
} catch (e) {
const message = getUploadErrorMessage(e, _)
if (message !== null) {
dispatch({
type: 'to_error',
error: message,
signal,
})
}
return
}
const {error} = useQuery({ const jobId = uploadResponse.jobId
queryKey: ['video', 'upload status', jobId], dispatch({
queryFn: async () => { type: 'uploading_to_processing',
if (!jobId) return // this won't happen, can ignore jobId,
signal,
})
let pollFailures = 0
while (true) {
if (signal.aborted) {
return // Exit async loop
}
const videoAgent = createVideoAgent()
let status: JobStatus | undefined
let blob: BlobRef | undefined
try {
const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
status = response.data.jobStatus
pollFailures = 0
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
const status = data.jobStatus
if (status.state === 'JOB_STATE_COMPLETED') { if (status.state === 'JOB_STATE_COMPLETED') {
setEnabled(false) blob = status.blob
if (!status.blob) if (!blob) {
throw new Error('Job completed, but did not return a blob') throw new Error('Job completed, but did not return a blob')
onSuccess(status.blob) }
} else if (status.state === 'JOB_STATE_FAILED') { } else if (status.state === 'JOB_STATE_FAILED') {
throw new Error(status.error ?? 'Job failed to process') throw new Error(status.error ?? 'Job failed to process')
} }
onStatusChange(status) } catch (e) {
return status if (!status) {
}, pollFailures++
enabled: Boolean(jobId && enabled), if (pollFailures < 50) {
refetchInterval: 1500, await new Promise(resolve => setTimeout(resolve, 5000))
continue // Continue async loop
}
}
logger.error('Error processing video', {safeMessage: e})
dispatch({
type: 'to_error',
error: _(msg`Video failed to process`),
signal,
}) })
return // Exit async loop
useEffect(() => {
if (error) {
onError(error)
setEnabled(false)
} }
}, [error, onError])
return { if (blob) {
setJobId: (_jobId: string) => { dispatch({
setJobId(_jobId) type: 'to_done',
setEnabled(true) blobRef: blob,
}, signal,
})
} else {
dispatch({
type: 'update_job_status',
jobStatus: status,
signal,
})
}
if (
status.state !== 'JOB_STATE_COMPLETED' &&
status.state !== 'JOB_STATE_FAILED'
) {
await new Promise(resolve => setTimeout(resolve, 1500))
continue // Continue async loop
}
return // Exit async loop
} }
} }
function getMimeType(asset: ImagePickerAsset) { function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
if (isWeb) { if (e instanceof AbortError) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') return null
if (!mimeType) {
throw new Error('Could not determine mime type')
} }
return mimeType if (e instanceof VideoTooLargeError) {
return _(msg`The selected video is larger than 50MB.`)
} }
if (!asset.mimeType) { logger.error('Error compressing video', {safeMessage: e})
throw new Error('Could not determine mime type') return _(msg`An error occurred while compressing the video.`)
} }
return asset.mimeType
function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null {
if (e instanceof AbortError) {
return null
}
logger.error('Error uploading video', {safeMessage: e})
if (e instanceof ServerError || e instanceof UploadLimitError) {
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
switch (e.message) {
case 'User is not allowed to upload videos':
return _(msg`You are not allowed to upload videos.`)
case 'Uploading is disabled at the moment':
return _(
msg`Hold up! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
case "Failed to get user's upload stats":
return _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
case 'User has exceeded daily upload bytes limit':
return _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
case 'User has exceeded daily upload videos limit':
return _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
case 'Account is not old enough to upload videos':
return _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
default:
return e.message
}
}
return _(msg`An error occurred while uploading the video.`)
} }
+90 -42
View File
@@ -36,6 +36,7 @@ import Animated, {
ZoomOut, ZoomOut,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ImagePickerAsset} from 'expo-image-picker'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedGetPostThread,
@@ -82,9 +83,10 @@ import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
import { import {
createVideoState,
processVideo,
State as VideoUploadState, State as VideoUploadState,
useUploadVideo, videoReducer,
VideoUploadDispatch,
} from '#/state/queries/video/video' } 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'
@@ -118,10 +120,13 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography' import {Text as NewText} from '#/components/Typography'
import {composerReducer, createComposerState} from './state' import {composerReducer, createComposerState} from './state'
const Portal = createPortalGroup()
const MAX_IMAGES = 4 const MAX_IMAGES = 4
type CancelRef = { type CancelRef = {
@@ -147,7 +152,8 @@ export const ComposePost = ({
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const currentDid = currentAccount!.did
const {data: currentProfile} = useProfileQuery({did: currentDid})
const {isModalActive} = useModals() const {isModalActive} = useModals()
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
const pal = usePalette('default') const pal = usePalette('default')
@@ -189,21 +195,50 @@ export const ComposePost = ({
const [videoAltText, setVideoAltText] = useState('') const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
const { const [videoUploadState, videoDispatch] = useReducer(
selectVideo, videoReducer,
clearVideo, undefined,
state: videoUploadState, createVideoState,
updateVideoDimensions, )
dispatch: videoUploadDispatch,
} = useUploadVideo({ const selectVideo = React.useCallback(
setStatus: setProcessingState, (asset: ImagePickerAsset) => {
onSuccess: () => { processVideo(
if (publishOnUpload) { asset,
onPressPublish(true) videoDispatch,
} agent,
currentDid,
videoUploadState.abortController.signal,
_,
)
}, },
initialVideoUri: initVideoUri, [_, videoUploadState.abortController, videoDispatch, agent, currentDid],
)
// Whenever we receive an initial video uri, we should immediately run compression if necessary
useEffect(() => {
if (initVideoUri) {
selectVideo({uri: initVideoUri} as ImagePickerAsset)
}
}, [initVideoUri, selectVideo])
const clearVideo = React.useCallback(() => {
videoUploadState.abortController.abort()
videoDispatch({type: 'to_idle', nextController: new AbortController()})
}, [videoUploadState.abortController, videoDispatch])
const updateVideoDimensions = useCallback(
(width: number, height: number) => {
videoDispatch({
type: 'update_dimensions',
width,
height,
signal: videoUploadState.abortController.signal,
}) })
},
[videoUploadState.abortController],
)
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false) const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -400,17 +435,16 @@ export const ComposePost = ({
postgate, postgate,
onStateChange: setProcessingState, onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage), langs: toPostLanguages(langPrefs.postLanguage),
video: videoUploadState.pendingPublish?.blobRef video:
videoUploadState.status === 'done'
? { ? {
blobRef: videoUploadState.pendingPublish.blobRef, blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText, altText: videoAltText,
captions: captions, captions: captions,
aspectRatio: videoUploadState.asset aspectRatio: {
? { width: videoUploadState.asset.width,
width: videoUploadState.asset?.width, height: videoUploadState.asset.height,
height: videoUploadState.asset?.height, },
}
: undefined,
} }
: undefined, : undefined,
}) })
@@ -598,12 +632,16 @@ export const ComposePost = ({
const keyboardVerticalOffset = useKeyboardVerticalOffset() const keyboardVerticalOffset = useKeyboardVerticalOffset()
return ( return (
<Portal.Provider>
<KeyboardAvoidingView <KeyboardAvoidingView
testID="composePostView" testID="composePostView"
behavior={isIOS ? 'padding' : 'height'} behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset} keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}> style={a.flex_1}>
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> <View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<Animated.View <Animated.View
style={topBarAnimatedStyle} style={topBarAnimatedStyle}
layout={native(LinearTransition)}> layout={native(LinearTransition)}>
@@ -694,7 +732,7 @@ export const ComposePost = ({
error={error} error={error}
videoUploadState={videoUploadState} videoUploadState={videoUploadState}
clearError={() => setError('')} clearError={() => setError('')}
videoUploadDispatch={videoUploadDispatch} clearVideo={clearVideo}
/> />
</Animated.View> </Animated.View>
<Animated.ScrollView <Animated.ScrollView
@@ -734,7 +772,11 @@ export const ComposePost = ({
/> />
</View> </View>
<Gallery images={images} dispatch={dispatch} /> <Gallery
images={images}
dispatch={dispatch}
Portal={Portal.Portal}
/>
{images.length === 0 && extLink && ( {images.length === 0 && extLink && (
<View style={a.relative}> <View style={a.relative}>
<ExternalEmbed <ExternalEmbed
@@ -749,6 +791,7 @@ export const ComposePost = ({
link={extLink} link={extLink}
gif={extGif} gif={extGif}
onSubmit={handleChangeGifAltText} onSubmit={handleChangeGifAltText}
Portal={Portal.Portal}
/> />
</View> </View>
)} )}
@@ -778,6 +821,7 @@ export const ComposePost = ({
saveAltText={setVideoAltText} saveAltText={setVideoAltText}
captions={captions} captions={captions}
setCaptions={setCaptions} setCaptions={setCaptions}
Portal={Portal.Portal}
/> />
</Animated.View> </Animated.View>
)} )}
@@ -806,6 +850,7 @@ export const ComposePost = ({
onChangeThreadgateAllowUISettings onChangeThreadgateAllowUISettings
} }
style={bottomBarAnimatedStyle} style={bottomBarAnimatedStyle}
Portal={Portal.Portal}
/> />
)} )}
<View <View
@@ -861,9 +906,11 @@ export const ComposePost = ({
onConfirm={onClose} onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)} confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative" confirmButtonColor="negative"
withoutPortal={true} Portal={Portal.Portal}
/> />
</KeyboardAvoidingView> </KeyboardAvoidingView>
<Portal.Outlet />
</Portal.Provider>
) )
} }
@@ -1084,25 +1131,25 @@ function ErrorBanner({
error: standardError, error: standardError,
videoUploadState, videoUploadState,
clearError, clearError,
videoUploadDispatch, clearVideo,
}: { }: {
error: string error: string
videoUploadState: VideoUploadState videoUploadState: VideoUploadState
clearError: () => void clearError: () => void
videoUploadDispatch: VideoUploadDispatch clearVideo: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const videoError = const videoError =
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined videoUploadState.status === 'error' ? videoUploadState.error : undefined
const error = standardError || videoError const error = standardError || videoError
const onClearError = () => { const onClearError = () => {
if (standardError) { if (standardError) {
clearError() clearError()
} else { } else {
videoUploadDispatch({type: 'Reset'}) clearVideo()
} }
} }
@@ -1137,7 +1184,7 @@ function ErrorBanner({
<ButtonIcon icon={X} /> <ButtonIcon icon={X} />
</Button> </Button>
</View> </View>
{videoError && videoUploadState.jobStatus?.jobId && ( {videoError && videoUploadState.jobId && (
<NewText <NewText
style={[ style={[
{paddingLeft: 28}, {paddingLeft: 28},
@@ -1146,7 +1193,7 @@ function ErrorBanner({
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_low, t.atoms.text_contrast_low,
]}> ]}>
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans> <Trans>Job ID: {videoUploadState.jobId}</Trans>
</NewText> </NewText>
)} )}
</View> </View>
@@ -1175,9 +1222,7 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) { function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const progress = state.jobStatus?.progress const progress = state.progress
? state.jobStatus.progress / 100
: state.progress
const shouldRotate = const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1) state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress let wheelProgress = shouldRotate ? 0.33 : progress
@@ -1213,16 +1258,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
case 'processing': case 'processing':
text = _('Processing video...') text = _('Processing video...')
break break
case 'error':
text = _('Error')
wheelProgress = 100
break
case 'done': case 'done':
text = _('Video uploaded') text = _('Video uploaded')
break break
} }
if (state.error) {
text = _('Error')
wheelProgress = 100
}
return ( return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<Animated.View style={[animatedStyle]}> <Animated.View style={[animatedStyle]}>
@@ -1230,7 +1274,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
size={30} size={30}
borderWidth={1} borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor} borderColor={t.atoms.border_contrast_low.borderColor}
color={state.error ? t.palette.negative_500 : t.palette.primary_500} color={
state.status === 'error'
? t.palette.negative_500
: t.palette.primary_500
}
progress={wheelProgress} progress={wheelProgress}
/> />
</Animated.View> </Animated.View>
+4 -1
View File
@@ -19,6 +19,7 @@ import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed' import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery' import {AltTextReminder} from './photos/Gallery'
@@ -27,10 +28,12 @@ export function GifAltText({
link: linkProp, link: linkProp,
gif, gif,
onSubmit, onSubmit,
Portal,
}: { }: {
link: ExternalEmbedDraft link: ExternalEmbedDraft
gif?: Gif gif?: Gif
onSubmit: (alt: string) => void onSubmit: (alt: string) => void
Portal: PortalComponent
}) { }) {
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const {_} = useLingui() const {_} = useLingui()
@@ -95,7 +98,7 @@ export function GifAltText({
<AltTextReminder /> <AltTextReminder />
<Dialog.Outer control={control}> <Dialog.Outer control={control} Portal={Portal}>
<AltTextInner <AltTextInner
onSubmit={onPressSubmit} onSubmit={onPressSubmit}
link={link} link={link}
+12 -1
View File
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {ComposerAction} from '../state' import {ComposerAction} from '../state'
import {EditImageDialog} from './EditImageDialog' import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog' import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -30,6 +31,7 @@ const IMAGE_GAP = 8
interface GalleryProps { interface GalleryProps {
images: ComposerImage[] images: ComposerImage[]
dispatch: (action: ComposerAction) => void dispatch: (action: ComposerAction) => void
Portal: PortalComponent
} }
export let Gallery = (props: GalleryProps): React.ReactNode => { export let Gallery = (props: GalleryProps): React.ReactNode => {
@@ -57,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions containerInfo: Dimensions
} }
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const GalleryInner = ({
images,
containerInfo,
dispatch,
Portal,
}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} = const {altTextControlStyle, imageControlsStyle, imageStyle} =
@@ -111,6 +118,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
onRemove={() => { onRemove={() => {
dispatch({type: 'embed_remove_image', image}) dispatch({type: 'embed_remove_image', image})
}} }}
Portal={Portal}
/> />
) )
})} })}
@@ -127,6 +135,7 @@ type GalleryItemProps = {
imageStyle?: ViewStyle imageStyle?: ViewStyle
onChange: (next: ComposerImage) => void onChange: (next: ComposerImage) => void
onRemove: () => void onRemove: () => void
Portal: PortalComponent
} }
const GalleryItem = ({ const GalleryItem = ({
@@ -136,6 +145,7 @@ const GalleryItem = ({
imageStyle, imageStyle,
onChange, onChange,
onRemove, onRemove,
Portal,
}: GalleryItemProps): React.ReactNode => { }: GalleryItemProps): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -230,6 +240,7 @@ const GalleryItem = ({
control={altTextControl} control={altTextControl}
image={image} image={image}
onChange={onChange} onChange={onChange}
Portal={Portal}
/> />
<EditImageDialog <EditImageDialog
@@ -11,17 +11,19 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = { type Props = {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
image: ComposerImage image: ComposerImage
onChange: (next: ComposerImage) => void onChange: (next: ComposerImage) => void
Portal: PortalComponent
} }
export const ImageAltTextDialog = (props: Props): React.ReactNode => { export const ImageAltTextDialog = (props: Props): React.ReactNode => {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control} Portal={props.Portal}>
<ImageAltTextInner {...props} /> <ImageAltTextInner {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -13,6 +13,7 @@ import * as Dialog from '#/components/Dialog'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {PortalComponent} from '#/components/Portal'
export function ThreadgateBtn({ export function ThreadgateBtn({
postgate, postgate,
@@ -20,6 +21,7 @@ export function ThreadgateBtn({
threadgateAllowUISettings, threadgateAllowUISettings,
onChangeThreadgateAllowUISettings, onChangeThreadgateAllowUISettings,
style, style,
Portal,
}: { }: {
postgate: AppBskyFeedPostgate.Record postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -28,6 +30,8 @@ export function ThreadgateBtn({
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>> style?: StyleProp<AnimatedStyle<ViewStyle>>
Portal: PortalComponent
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -77,6 +81,7 @@ export function ThreadgateBtn({
onChangePostgate={onChangePostgate} onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings} threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
Portal={Portal}
/> />
</> </>
) )
+36 -11
View File
@@ -9,12 +9,14 @@ import {
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {BSKY_SERVICE} from '#/lib/constants'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {getHostnameFromUrl} from '#/lib/strings/url-helpers'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {BSKY_SERVICE} from 'lib/constants'
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
@@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current, UIImagePickerPreferredAssetRepresentationMode.Current,
}) })
if (response.assets && response.assets.length > 0) { if (response.assets && response.assets.length > 0) {
if (isNative) { const asset = response.assets[0]
if (typeof response.assets[0].duration !== 'number')
throw Error('Asset is not a video')
if (response.assets[0].duration > VIDEO_MAX_DURATION) {
setError(_(msg`Videos must be less than 60 seconds long`))
return
}
}
try { try {
onSelectVideo(response.assets[0]) if (isWeb) {
// compression step on native converts to mp4, so no need to check there
const mimeType = getMimeType(asset)
if (
!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
) {
throw Error(_(msg`Unsupported video type: ${mimeType}`))
}
} else {
if (typeof asset.duration !== 'number') {
throw Error('Asset is not a video')
}
if (asset.duration > VIDEO_MAX_DURATION) {
throw Error(_(msg`Videos must be less than 60 seconds long`))
}
}
onSelectVideo(asset)
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message) setError(err.message)
@@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
/> />
) )
} }
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}
@@ -17,6 +17,7 @@ import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText' import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker' import {SubtitleFilePicker} from './SubtitleFilePicker'
@@ -29,6 +30,7 @@ interface Props {
setCaptions: React.Dispatch< setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]> React.SetStateAction<{lang: string; file: File}[]>
> >
Portal: PortalComponent
} }
export function SubtitleDialogBtn(props: Props) { export function SubtitleDialogBtn(props: Props) {
@@ -56,7 +58,7 @@ export function SubtitleDialogBtn(props: Props) {
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>} {isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
</ButtonText> </ButtonText>
</Button> </Button>
<Dialog.Outer control={control}> <Dialog.Outer control={control} Portal={props.Portal}>
<SubtitleDialogInner {...props} /> <SubtitleDialogInner {...props} />
</Dialog.Outer> </Dialog.Outer>
</View> </View>