[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:
@@ -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"
|
||||||
|
|||||||
@@ -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 +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 & {})) {
|
||||||
|
|||||||
@@ -1,73 +1,61 @@
|
|||||||
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()
|
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||||
|
if (!pdsAud) {
|
||||||
return useCallback(async () => {
|
throw new Error('Agent does not have a PDS URL')
|
||||||
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
}
|
||||||
|
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
||||||
if (!pdsAud) {
|
aud: aud ?? pdsAud,
|
||||||
throw new Error('Agent does not have a PDS URL')
|
lxm,
|
||||||
}
|
exp,
|
||||||
|
})
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
return serviceAuth.token
|
||||||
aud: aud ?? pdsAud,
|
|
||||||
lxm,
|
|
||||||
exp,
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
.catch(err => {
|
||||||
.getUploadLimits(
|
if (err instanceof Error) {
|
||||||
{},
|
throw new UploadLimitError(err.message)
|
||||||
{headers: {Authorization: `Bearer ${await getToken()}`}},
|
|
||||||
)
|
|
||||||
.catch(err => {
|
|
||||||
if (err instanceof Error) {
|
|
||||||
throw new UploadLimitError(err.message)
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!limits.canUpload) {
|
|
||||||
if (limits.message) {
|
|
||||||
throw new UploadLimitError(limits.message)
|
|
||||||
} else {
|
} else {
|
||||||
throw new UploadLimitError(
|
throw err
|
||||||
_(
|
|
||||||
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!limits.canUpload) {
|
||||||
|
if (limits.message) {
|
||||||
|
throw new UploadLimitError(limits.message)
|
||||||
|
} else {
|
||||||
|
throw new UploadLimitError(
|
||||||
|
_(
|
||||||
|
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [agent, _, getToken])
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,79 @@
|
|||||||
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) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
await getVideoUploadLimits(agent, _)
|
||||||
|
|
||||||
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
|
did,
|
||||||
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
const token = await getServiceAuthToken({
|
||||||
|
agent,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
})
|
})
|
||||||
const checkLimits = useVideoUploadLimits()
|
const uploadTask = createUploadTask(
|
||||||
const {_} = useLingui()
|
uri,
|
||||||
|
video.uri,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'content-type': video.mimeType,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
httpMethod: 'POST',
|
||||||
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
},
|
||||||
|
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
||||||
|
)
|
||||||
|
|
||||||
return useMutation({
|
if (signal.aborted) {
|
||||||
mutationKey: ['video', 'upload'],
|
throw new AbortError()
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
}
|
||||||
await checkLimits()
|
const res = await uploadTask.uploadAsync()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
if (!res?.body) {
|
||||||
did: currentAccount!.did,
|
throw new Error('No response')
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const uploadTask = createUploadTask(
|
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||||
uri,
|
|
||||||
video.uri,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'content-type': video.mimeType,
|
|
||||||
Authorization: `Bearer ${await getToken()}`,
|
|
||||||
},
|
|
||||||
httpMethod: 'POST',
|
|
||||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
|
||||||
},
|
|
||||||
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
|
||||||
)
|
|
||||||
const res = await uploadTask.uploadAsync()
|
|
||||||
|
|
||||||
if (!res?.body) {
|
if (!responseBody.jobId) {
|
||||||
throw new Error('No response')
|
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
if (!responseBody.jobId) {
|
}
|
||||||
throw new ServerError(
|
return responseBody
|
||||||
responseBody.error || _(msg`Failed to upload video`),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return responseBody
|
|
||||||
}, signal),
|
|
||||||
onError,
|
|
||||||
onSuccess,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,95 @@
|
|||||||
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) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
await getVideoUploadLimits(agent, _)
|
||||||
|
|
||||||
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
|
did,
|
||||||
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
let bytes = video.bytes
|
||||||
|
if (!bytes) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
const token = await getServiceAuthToken({
|
||||||
|
agent,
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
})
|
})
|
||||||
const checkLimits = useVideoUploadLimits()
|
|
||||||
const {_} = useLingui()
|
|
||||||
|
|
||||||
return useMutation({
|
if (signal.aborted) {
|
||||||
mutationKey: ['video', 'upload'],
|
throw new AbortError()
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
}
|
||||||
await checkLimits()
|
const xhr = new XMLHttpRequest()
|
||||||
|
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
(resolve, reject) => {
|
||||||
did: currentAccount!.did,
|
xhr.upload.addEventListener('progress', e => {
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
const progress = e.loaded / e.total
|
||||||
|
setProgress(progress)
|
||||||
})
|
})
|
||||||
|
xhr.onloadend = () => {
|
||||||
let bytes = video.bytes
|
if (signal.aborted) {
|
||||||
if (!bytes) {
|
reject(new AbortError())
|
||||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
} else if (xhr.readyState === 4) {
|
||||||
|
const uploadRes = JSON.parse(
|
||||||
|
xhr.responseText,
|
||||||
|
) as AppBskyVideoDefs.JobStatus
|
||||||
|
resolve(uploadRes)
|
||||||
|
} else {
|
||||||
|
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
xhr.onerror = () => {
|
||||||
const token = await getToken()
|
reject(new ServerError(_(msg`Failed to upload video`)))
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
|
||||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
|
||||||
(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 AppBskyVideoDefs.JobStatus
|
|
||||||
resolve(uploadRes)
|
|
||||||
} else {
|
|
||||||
reject(new ServerError(_(msg`Failed to upload video`)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xhr.onerror = () => {
|
|
||||||
reject(new ServerError(_(msg`Failed to upload video`)))
|
|
||||||
}
|
|
||||||
xhr.open('POST', uri)
|
|
||||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
|
||||||
xhr.send(bytes)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!res.jobId) {
|
|
||||||
throw new ServerError(res.error || _(msg`Failed to upload video`))
|
|
||||||
}
|
}
|
||||||
|
xhr.open('POST', uri)
|
||||||
|
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
|
xhr.send(bytes)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return res
|
if (!res.jobId) {
|
||||||
}, signal),
|
throw new ServerError(res.error || _(msg`Failed to upload video`))
|
||||||
onError,
|
}
|
||||||
onSuccess,
|
|
||||||
})
|
if (signal.aborted) {
|
||||||
|
throw new AbortError()
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|||||||
+371
-301
@@ -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
|
||||||
status: 'idle',
|
video: CompressedVideo
|
||||||
progress: 0,
|
jobId?: undefined
|
||||||
video: null,
|
pendingPublish?: undefined
|
||||||
blobRef: undefined,
|
}
|
||||||
abortController: new AbortController(),
|
|
||||||
}
|
type ProcessingState = {
|
||||||
} else if (action.type === 'SetAsset') {
|
status: 'processing'
|
||||||
updatedState = {
|
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',
|
||||||
|
progress: 0,
|
||||||
|
abortController,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
} else if (action.type === 'update_dimensions') {
|
||||||
|
if (state.asset) {
|
||||||
|
return {
|
||||||
...state,
|
...state,
|
||||||
asset: state.asset
|
asset: {...state.asset, width: action.width, height: action.height},
|
||||||
? {...state.asset, width: action.width, height: action.height}
|
|
||||||
: undefined,
|
|
||||||
}
|
}
|
||||||
} else if (action.type === 'SetVideo') {
|
}
|
||||||
updatedState = {...state, video: action.video, status: 'uploading'}
|
} else if (action.type === 'compressing_to_uploading') {
|
||||||
} else if (action.type === 'SetJobStatus') {
|
if (state.status === 'compressing') {
|
||||||
updatedState = {...state, jobStatus: action.jobStatus}
|
return {
|
||||||
} else if (action.type === 'SetComplete') {
|
status: 'uploading',
|
||||||
updatedState = {
|
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,
|
...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 (' +
|
||||||
export function useUploadVideo({
|
action.type +
|
||||||
setStatus,
|
') while in ' +
|
||||||
initialVideoUri,
|
state.status +
|
||||||
}: {
|
' state',
|
||||||
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({
|
|
||||||
onStatusChange: (status: AppBskyVideoDefs.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: 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! We’re gradually giving access to video, and you’re 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,
|
|
||||||
})
|
|
||||||
onSelectVideo(asset)
|
|
||||||
},
|
|
||||||
[_, onSelectVideo],
|
|
||||||
)
|
)
|
||||||
|
return state
|
||||||
const clearVideo = () => {
|
|
||||||
dispatch({type: 'Reset'})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateVideoDimensions = useCallback((width: number, height: number) => {
|
|
||||||
dispatch({
|
|
||||||
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 {
|
|
||||||
state,
|
|
||||||
dispatch,
|
|
||||||
selectVideo,
|
|
||||||
clearVideo,
|
|
||||||
updateVideoDimensions,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useUploadStatusQuery = ({
|
function trunc2dp(num: number) {
|
||||||
onStatusChange,
|
return Math.trunc(num * 100) / 100
|
||||||
onSuccess,
|
}
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
|
|
||||||
onSuccess: (blobRef: BlobRef) => void
|
|
||||||
onError: (error: Error) => void
|
|
||||||
}) => {
|
|
||||||
const videoAgent = useVideoAgent()
|
|
||||||
const [enabled, setEnabled] = React.useState(true)
|
|
||||||
const [jobId, setJobId] = React.useState<string>()
|
|
||||||
|
|
||||||
const {error} = useQuery({
|
export async function processVideo(
|
||||||
queryKey: ['video', 'upload status', jobId],
|
asset: ImagePickerAsset,
|
||||||
queryFn: async () => {
|
dispatch: (action: Action) => void,
|
||||||
if (!jobId) return // this won't happen, can ignore
|
agent: BskyAgent,
|
||||||
|
did: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
_: I18n['_'],
|
||||||
|
) {
|
||||||
|
dispatch({
|
||||||
|
type: 'idle_to_compressing',
|
||||||
|
asset,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
let video: CompressedVideo | undefined
|
||||||
|
try {
|
||||||
|
video = await compressVideo(asset, {
|
||||||
|
onProgress: num => {
|
||||||
|
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const message = getCompressErrorMessage(e, _)
|
||||||
|
if (message !== null) {
|
||||||
|
dispatch({
|
||||||
|
type: 'to_error',
|
||||||
|
error: message,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: 'compressing_to_uploading',
|
||||||
|
video,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
||||||
|
try {
|
||||||
|
uploadResponse = await uploadVideo({
|
||||||
|
video,
|
||||||
|
agent,
|
||||||
|
did,
|
||||||
|
signal,
|
||||||
|
_,
|
||||||
|
setProgress: p => {
|
||||||
|
dispatch({type: 'update_progress', progress: p, signal})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const message = getUploadErrorMessage(e, _)
|
||||||
|
if (message !== null) {
|
||||||
|
dispatch({
|
||||||
|
type: 'to_error',
|
||||||
|
error: message,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = uploadResponse.jobId
|
||||||
|
dispatch({
|
||||||
|
type: 'uploading_to_processing',
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
logger.error('Error processing video', {safeMessage: e})
|
||||||
if (error) {
|
dispatch({
|
||||||
onError(error)
|
type: 'to_error',
|
||||||
setEnabled(false)
|
error: _(msg`Video failed to process`),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return // Exit async loop
|
||||||
}
|
}
|
||||||
}, [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 (!asset.mimeType) {
|
if (e instanceof VideoTooLargeError) {
|
||||||
throw new Error('Could not determine mime type')
|
return _(msg`The selected video is larger than 50MB.`)
|
||||||
}
|
}
|
||||||
return asset.mimeType
|
logger.error('Error compressing video', {safeMessage: e})
|
||||||
|
return _(msg`An error occurred while compressing the video.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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! We’re gradually giving access to video, and you’re 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.`)
|
||||||
}
|
}
|
||||||
|
|||||||
+344
-296
@@ -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,19 +435,18 @@ 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,
|
? {
|
||||||
altText: videoAltText,
|
blobRef: videoUploadState.pendingPublish.blobRef,
|
||||||
captions: captions,
|
altText: videoAltText,
|
||||||
aspectRatio: videoUploadState.asset
|
captions: captions,
|
||||||
? {
|
aspectRatio: {
|
||||||
width: videoUploadState.asset?.width,
|
width: videoUploadState.asset.width,
|
||||||
height: videoUploadState.asset?.height,
|
height: videoUploadState.asset.height,
|
||||||
}
|
},
|
||||||
: undefined,
|
}
|
||||||
}
|
: undefined,
|
||||||
: undefined,
|
|
||||||
})
|
})
|
||||||
).uri
|
).uri
|
||||||
try {
|
try {
|
||||||
@@ -598,272 +632,285 @@ export const ComposePost = ({
|
|||||||
const keyboardVerticalOffset = useKeyboardVerticalOffset()
|
const keyboardVerticalOffset = useKeyboardVerticalOffset()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<Portal.Provider>
|
||||||
testID="composePostView"
|
<KeyboardAvoidingView
|
||||||
behavior={isIOS ? 'padding' : 'height'}
|
testID="composePostView"
|
||||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
behavior={isIOS ? 'padding' : 'height'}
|
||||||
style={a.flex_1}>
|
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||||
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
|
style={a.flex_1}>
|
||||||
<Animated.View
|
<View
|
||||||
style={topBarAnimatedStyle}
|
style={[a.flex_1, viewStyles]}
|
||||||
layout={native(LinearTransition)}>
|
aria-modal
|
||||||
<View style={styles.topbarInner}>
|
accessibilityViewIsModal>
|
||||||
<Button
|
<Animated.View
|
||||||
label={_(msg`Cancel`)}
|
style={topBarAnimatedStyle}
|
||||||
variant="ghost"
|
layout={native(LinearTransition)}>
|
||||||
color="primary"
|
<View style={styles.topbarInner}>
|
||||||
shape="default"
|
<Button
|
||||||
size="small"
|
label={_(msg`Cancel`)}
|
||||||
style={[
|
variant="ghost"
|
||||||
a.rounded_full,
|
color="primary"
|
||||||
a.py_sm,
|
shape="default"
|
||||||
{paddingLeft: 7, paddingRight: 7},
|
size="small"
|
||||||
]}
|
style={[
|
||||||
onPress={onPressCancel}
|
a.rounded_full,
|
||||||
accessibilityHint={_(
|
a.py_sm,
|
||||||
msg`Closes post composer and discards post draft`,
|
{paddingLeft: 7, paddingRight: 7},
|
||||||
)}>
|
]}
|
||||||
<ButtonText style={[a.text_md]}>
|
onPress={onPressCancel}
|
||||||
<Trans>Cancel</Trans>
|
accessibilityHint={_(
|
||||||
</ButtonText>
|
msg`Closes post composer and discards post draft`,
|
||||||
</Button>
|
)}>
|
||||||
<View style={a.flex_1} />
|
<ButtonText style={[a.text_md]}>
|
||||||
{isProcessing ? (
|
<Trans>Cancel</Trans>
|
||||||
<>
|
</ButtonText>
|
||||||
<Text style={pal.textLight}>{processingState}</Text>
|
</Button>
|
||||||
<View style={styles.postBtn}>
|
<View style={a.flex_1} />
|
||||||
<ActivityIndicator />
|
{isProcessing ? (
|
||||||
</View>
|
<>
|
||||||
</>
|
<Text style={pal.textLight}>{processingState}</Text>
|
||||||
) : (
|
<View style={styles.postBtn}>
|
||||||
<View style={[styles.postBtnWrapper]}>
|
<ActivityIndicator />
|
||||||
<LabelsBtn
|
|
||||||
labels={labels}
|
|
||||||
onChange={setLabels}
|
|
||||||
hasMedia={hasMedia}
|
|
||||||
/>
|
|
||||||
{canPost ? (
|
|
||||||
<Button
|
|
||||||
testID="composerPublishBtn"
|
|
||||||
label={
|
|
||||||
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
|
|
||||||
}
|
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
|
||||||
shape="default"
|
|
||||||
size="small"
|
|
||||||
style={[a.rounded_full, a.py_sm]}
|
|
||||||
onPress={() => onPressPublish()}
|
|
||||||
disabled={
|
|
||||||
videoUploadState.status !== 'idle' && publishOnUpload
|
|
||||||
}>
|
|
||||||
<ButtonText style={[a.text_md]}>
|
|
||||||
{replyTo ? (
|
|
||||||
<Trans context="action">Reply</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans context="action">Post</Trans>
|
|
||||||
)}
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.postBtn, pal.btn]}>
|
|
||||||
<Text style={[pal.textLight, s.f16, s.bold]}>
|
|
||||||
<Trans context="action">Post</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.postBtnWrapper]}>
|
||||||
|
<LabelsBtn
|
||||||
|
labels={labels}
|
||||||
|
onChange={setLabels}
|
||||||
|
hasMedia={hasMedia}
|
||||||
|
/>
|
||||||
|
{canPost ? (
|
||||||
|
<Button
|
||||||
|
testID="composerPublishBtn"
|
||||||
|
label={
|
||||||
|
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
|
||||||
|
}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
shape="default"
|
||||||
|
size="small"
|
||||||
|
style={[a.rounded_full, a.py_sm]}
|
||||||
|
onPress={() => onPressPublish()}
|
||||||
|
disabled={
|
||||||
|
videoUploadState.status !== 'idle' && publishOnUpload
|
||||||
|
}>
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
{replyTo ? (
|
||||||
|
<Trans context="action">Reply</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans context="action">Post</Trans>
|
||||||
|
)}
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.postBtn, pal.btn]}>
|
||||||
|
<Text style={[pal.textLight, s.f16, s.bold]}>
|
||||||
|
<Trans context="action">Post</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isAltTextRequiredAndMissing && (
|
||||||
|
<View style={[styles.reminderLine, pal.viewLight]}>
|
||||||
|
<View style={styles.errorIcon}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="exclamation"
|
||||||
|
style={{color: colors.red4}}
|
||||||
|
size={10}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[pal.text, a.flex_1]}>
|
||||||
|
<Trans>One or more images is missing alt text.</Trans>
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
<ErrorBanner
|
||||||
|
error={error}
|
||||||
|
videoUploadState={videoUploadState}
|
||||||
|
clearError={() => setError('')}
|
||||||
|
clearVideo={clearVideo}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.ScrollView
|
||||||
|
layout={native(LinearTransition)}
|
||||||
|
onScroll={scrollHandler}
|
||||||
|
style={styles.scrollView}
|
||||||
|
keyboardShouldPersistTaps="always"
|
||||||
|
onContentSizeChange={onScrollViewContentSizeChange}
|
||||||
|
onLayout={onScrollViewLayout}>
|
||||||
|
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
||||||
|
|
||||||
{isAltTextRequiredAndMissing && (
|
<View
|
||||||
<View style={[styles.reminderLine, pal.viewLight]}>
|
style={[
|
||||||
<View style={styles.errorIcon}>
|
styles.textInputLayout,
|
||||||
<FontAwesomeIcon
|
isNative && styles.textInputLayoutMobile,
|
||||||
icon="exclamation"
|
]}>
|
||||||
style={{color: colors.red4}}
|
<UserAvatar
|
||||||
size={10}
|
avatar={currentProfile?.avatar}
|
||||||
|
size={50}
|
||||||
|
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
ref={textInput}
|
||||||
|
richtext={richtext}
|
||||||
|
placeholder={selectTextInputPlaceholder}
|
||||||
|
autoFocus
|
||||||
|
setRichText={setRichText}
|
||||||
|
onPhotoPasted={onPhotoPasted}
|
||||||
|
onPressPublish={() => onPressPublish()}
|
||||||
|
onNewLink={onNewLink}
|
||||||
|
onError={setError}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityLabel={_(msg`Write post`)}
|
||||||
|
accessibilityHint={_(
|
||||||
|
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Gallery
|
||||||
|
images={images}
|
||||||
|
dispatch={dispatch}
|
||||||
|
Portal={Portal.Portal}
|
||||||
|
/>
|
||||||
|
{images.length === 0 && extLink && (
|
||||||
|
<View style={a.relative}>
|
||||||
|
<ExternalEmbed
|
||||||
|
link={extLink}
|
||||||
|
gif={extGif}
|
||||||
|
onRemove={() => {
|
||||||
|
setExtLink(undefined)
|
||||||
|
setExtGif(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<GifAltText
|
||||||
|
link={extLink}
|
||||||
|
gif={extGif}
|
||||||
|
onSubmit={handleChangeGifAltText}
|
||||||
|
Portal={Portal.Portal}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[pal.text, a.flex_1]}>
|
)}
|
||||||
<Trans>One or more images is missing alt text.</Trans>
|
<LayoutAnimationConfig skipExiting>
|
||||||
</Text>
|
{hasVideo && (
|
||||||
|
<Animated.View
|
||||||
|
style={[a.w_full, a.mt_lg]}
|
||||||
|
entering={native(ZoomIn)}
|
||||||
|
exiting={native(ZoomOut)}>
|
||||||
|
{videoUploadState.asset &&
|
||||||
|
(videoUploadState.status === 'compressing' ? (
|
||||||
|
<VideoTranscodeProgress
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
progress={videoUploadState.progress}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : videoUploadState.video ? (
|
||||||
|
<VideoPreview
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
video={videoUploadState.video}
|
||||||
|
setDimensions={updateVideoDimensions}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : null)}
|
||||||
|
<SubtitleDialogBtn
|
||||||
|
defaultAltText={videoAltText}
|
||||||
|
saveAltText={setVideoAltText}
|
||||||
|
captions={captions}
|
||||||
|
setCaptions={setCaptions}
|
||||||
|
Portal={Portal.Portal}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
<View style={!hasVideo ? [a.mt_md] : []}>
|
||||||
|
{quote ? (
|
||||||
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
|
<View style={{pointerEvents: 'none'}}>
|
||||||
|
<QuoteEmbed quote={quote} />
|
||||||
|
</View>
|
||||||
|
{quote.uri !== initQuote?.uri && (
|
||||||
|
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)}
|
</Animated.ScrollView>
|
||||||
<ErrorBanner
|
<SuggestedLanguage text={richtext.text} />
|
||||||
error={error}
|
|
||||||
videoUploadState={videoUploadState}
|
|
||||||
clearError={() => setError('')}
|
|
||||||
videoUploadDispatch={videoUploadDispatch}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
<Animated.ScrollView
|
|
||||||
layout={native(LinearTransition)}
|
|
||||||
onScroll={scrollHandler}
|
|
||||||
style={styles.scrollView}
|
|
||||||
keyboardShouldPersistTaps="always"
|
|
||||||
onContentSizeChange={onScrollViewContentSizeChange}
|
|
||||||
onLayout={onScrollViewLayout}>
|
|
||||||
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
|
||||||
|
|
||||||
|
{replyTo ? null : (
|
||||||
|
<ThreadgateBtn
|
||||||
|
postgate={postgate}
|
||||||
|
onChangePostgate={setPostgate}
|
||||||
|
threadgateAllowUISettings={threadgateAllowUISettings}
|
||||||
|
onChangeThreadgateAllowUISettings={
|
||||||
|
onChangeThreadgateAllowUISettings
|
||||||
|
}
|
||||||
|
style={bottomBarAnimatedStyle}
|
||||||
|
Portal={Portal.Portal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.textInputLayout,
|
t.atoms.bg,
|
||||||
isNative && styles.textInputLayoutMobile,
|
t.atoms.border_contrast_medium,
|
||||||
|
styles.bottomBar,
|
||||||
]}>
|
]}>
|
||||||
<UserAvatar
|
{videoUploadState.status !== 'idle' &&
|
||||||
avatar={currentProfile?.avatar}
|
videoUploadState.status !== 'done' ? (
|
||||||
size={50}
|
<VideoUploadToolbar state={videoUploadState} />
|
||||||
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
|
) : (
|
||||||
/>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<TextInput
|
<SelectPhotoBtn
|
||||||
ref={textInput}
|
size={images.length}
|
||||||
richtext={richtext}
|
disabled={!canSelectImages}
|
||||||
placeholder={selectTextInputPlaceholder}
|
onAdd={onImageAdd}
|
||||||
autoFocus
|
|
||||||
setRichText={setRichText}
|
|
||||||
onPhotoPasted={onPhotoPasted}
|
|
||||||
onPressPublish={() => onPressPublish()}
|
|
||||||
onNewLink={onNewLink}
|
|
||||||
onError={setError}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={_(msg`Write post`)}
|
|
||||||
accessibilityHint={_(
|
|
||||||
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Gallery images={images} dispatch={dispatch} />
|
|
||||||
{images.length === 0 && extLink && (
|
|
||||||
<View style={a.relative}>
|
|
||||||
<ExternalEmbed
|
|
||||||
link={extLink}
|
|
||||||
gif={extGif}
|
|
||||||
onRemove={() => {
|
|
||||||
setExtLink(undefined)
|
|
||||||
setExtGif(undefined)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<GifAltText
|
|
||||||
link={extLink}
|
|
||||||
gif={extGif}
|
|
||||||
onSubmit={handleChangeGifAltText}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<LayoutAnimationConfig skipExiting>
|
|
||||||
{hasVideo && (
|
|
||||||
<Animated.View
|
|
||||||
style={[a.w_full, a.mt_lg]}
|
|
||||||
entering={native(ZoomIn)}
|
|
||||||
exiting={native(ZoomOut)}>
|
|
||||||
{videoUploadState.asset &&
|
|
||||||
(videoUploadState.status === 'compressing' ? (
|
|
||||||
<VideoTranscodeProgress
|
|
||||||
asset={videoUploadState.asset}
|
|
||||||
progress={videoUploadState.progress}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
) : videoUploadState.video ? (
|
|
||||||
<VideoPreview
|
|
||||||
asset={videoUploadState.asset}
|
|
||||||
video={videoUploadState.video}
|
|
||||||
setDimensions={updateVideoDimensions}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
) : null)}
|
|
||||||
<SubtitleDialogBtn
|
|
||||||
defaultAltText={videoAltText}
|
|
||||||
saveAltText={setVideoAltText}
|
|
||||||
captions={captions}
|
|
||||||
setCaptions={setCaptions}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
<SelectVideoBtn
|
||||||
|
onSelectVideo={selectVideo}
|
||||||
|
disabled={!canSelectImages}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
|
||||||
|
<SelectGifBtn
|
||||||
|
onClose={focusTextInput}
|
||||||
|
onSelectGif={onSelectGif}
|
||||||
|
disabled={hasMedia}
|
||||||
|
/>
|
||||||
|
{!isMobile ? (
|
||||||
|
<Button
|
||||||
|
onPress={onEmojiButtonPress}
|
||||||
|
style={a.p_sm}
|
||||||
|
label={_(msg`Open emoji picker`)}
|
||||||
|
accessibilityHint={_(msg`Open emoji picker`)}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="primary">
|
||||||
|
<EmojiSmile size="lg" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</ToolbarWrapper>
|
||||||
)}
|
)}
|
||||||
</LayoutAnimationConfig>
|
<View style={a.flex_1} />
|
||||||
<View style={!hasVideo ? [a.mt_md] : []}>
|
<SelectLangBtn />
|
||||||
{quote ? (
|
<CharProgress count={graphemeLength} />
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
|
||||||
<View style={{pointerEvents: 'none'}}>
|
|
||||||
<QuoteEmbed quote={quote} />
|
|
||||||
</View>
|
|
||||||
{quote.uri !== initQuote?.uri && (
|
|
||||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
|
||||||
<SuggestedLanguage text={richtext.text} />
|
|
||||||
|
|
||||||
{replyTo ? null : (
|
|
||||||
<ThreadgateBtn
|
|
||||||
postgate={postgate}
|
|
||||||
onChangePostgate={setPostgate}
|
|
||||||
threadgateAllowUISettings={threadgateAllowUISettings}
|
|
||||||
onChangeThreadgateAllowUISettings={
|
|
||||||
onChangeThreadgateAllowUISettings
|
|
||||||
}
|
|
||||||
style={bottomBarAnimatedStyle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
t.atoms.bg,
|
|
||||||
t.atoms.border_contrast_medium,
|
|
||||||
styles.bottomBar,
|
|
||||||
]}>
|
|
||||||
{videoUploadState.status !== 'idle' &&
|
|
||||||
videoUploadState.status !== 'done' ? (
|
|
||||||
<VideoUploadToolbar state={videoUploadState} />
|
|
||||||
) : (
|
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
|
||||||
<SelectPhotoBtn
|
|
||||||
size={images.length}
|
|
||||||
disabled={!canSelectImages}
|
|
||||||
onAdd={onImageAdd}
|
|
||||||
/>
|
|
||||||
<SelectVideoBtn
|
|
||||||
onSelectVideo={selectVideo}
|
|
||||||
disabled={!canSelectImages}
|
|
||||||
setError={setError}
|
|
||||||
/>
|
|
||||||
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
|
|
||||||
<SelectGifBtn
|
|
||||||
onClose={focusTextInput}
|
|
||||||
onSelectGif={onSelectGif}
|
|
||||||
disabled={hasMedia}
|
|
||||||
/>
|
|
||||||
{!isMobile ? (
|
|
||||||
<Button
|
|
||||||
onPress={onEmojiButtonPress}
|
|
||||||
style={a.p_sm}
|
|
||||||
label={_(msg`Open emoji picker`)}
|
|
||||||
accessibilityHint={_(msg`Open emoji picker`)}
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
color="primary">
|
|
||||||
<EmojiSmile size="lg" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</ToolbarWrapper>
|
|
||||||
)}
|
|
||||||
<View style={a.flex_1} />
|
|
||||||
<SelectLangBtn />
|
|
||||||
<CharProgress count={graphemeLength} />
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<Prompt.Basic
|
||||||
<Prompt.Basic
|
control={discardPromptControl}
|
||||||
control={discardPromptControl}
|
title={_(msg`Discard draft?`)}
|
||||||
title={_(msg`Discard draft?`)}
|
description={_(msg`Are you sure you'd like to discard this draft?`)}
|
||||||
description={_(msg`Are you sure you'd like to discard this draft?`)}
|
onConfirm={onClose}
|
||||||
onConfirm={onClose}
|
confirmButtonCta={_(msg`Discard`)}
|
||||||
confirmButtonCta={_(msg`Discard`)}
|
confirmButtonColor="negative"
|
||||||
confirmButtonColor="negative"
|
Portal={Portal.Portal}
|
||||||
withoutPortal={true}
|
/>
|
||||||
/>
|
</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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user