Merge remote-tracking branch 'origin/main' into fix-prof-head-buttons
* origin/main: 🪵📌 (#5594) Rename some files and variables (#5587) Manage video reducer from composer reducer (#5573) Refactor video uploads (#5570) Delete messages NUX (#5574) move files around (#5576) Make reply prompt more subtle on desktop (#5569) Ignore built files (#5572) import order (#5571) Add suspense boundary in onboarding (#5556) Tweak verify email dialog (#5555) Add intent/verify-email to go server (#5554)
This commit is contained in:
@@ -15,3 +15,6 @@ android
|
||||
ios
|
||||
src/locale/locales
|
||||
lib/react-compiler-runtime
|
||||
bskyweb/static
|
||||
coverage
|
||||
web-build
|
||||
|
||||
@@ -255,6 +255,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/support/community-guidelines", server.WebGeneric)
|
||||
e.GET("/support/copyright", server.WebGeneric)
|
||||
e.GET("/intent/compose", server.WebGeneric)
|
||||
e.GET("/intent/verify-email", server.WebGeneric)
|
||||
e.GET("/messages", server.WebGeneric)
|
||||
e.GET("/messages/:conversation", server.WebGeneric)
|
||||
|
||||
|
||||
+1
-1
@@ -78,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesScreen} from '#/screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||
|
||||
@@ -901,4 +901,32 @@ export const atoms = {
|
||||
hidden: {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
/*
|
||||
* Transition
|
||||
*/
|
||||
transition_none: web({
|
||||
transitionProperty: 'none',
|
||||
}),
|
||||
transition_all: web({
|
||||
transitionProperty: 'all',
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||
transitionDuration: '100ms',
|
||||
}),
|
||||
transition_color: web({
|
||||
transitionProperty:
|
||||
'color, background-color, border-color, text-decoration-color, fill, stroke',
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||
transitionDuration: '100ms',
|
||||
}),
|
||||
transition_opacity: web({
|
||||
transitionProperty: 'opacity',
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||
transitionDuration: '100ms',
|
||||
}),
|
||||
transition_transform: web({
|
||||
transitionProperty: 'transform',
|
||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
|
||||
transitionDuration: '100ms',
|
||||
}),
|
||||
} as const
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import React, {useCallback, useEffect} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyActorDeclaration} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessagesNUX() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && typeof profile.associated?.chat === 'undefined') {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [profile, control])
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner chatDeclation={profile.associated?.chat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
chatDeclation,
|
||||
}: {
|
||||
chatDeclation?: ChatBskyActorDeclaration.Record
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const [initialized, setInitialzed] = React.useState(false)
|
||||
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Failed to update settings`), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
const onSelectItem = useCallback(
|
||||
(keys: string[]) => {
|
||||
const key = keys[0]
|
||||
if (!key) return
|
||||
updateDeclaration(key as 'all' | 'none' | 'following')
|
||||
},
|
||||
[updateDeclaration],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatDeclation && !initialized) {
|
||||
updateDeclaration('following')
|
||||
setInitialzed(true)
|
||||
}
|
||||
}, [chatDeclation, updateDeclaration, initialized])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing Direct Messages`)}
|
||||
style={web({maxWidth: 440})}>
|
||||
<View style={a.gap_xl}>
|
||||
<View style={[a.align_center, a.pt_sm, a.pb_xs]}>
|
||||
<Message_Stroke2_Corner0_Rounded width={64} />
|
||||
<Text style={[a.text_2xl, a.font_bold, a.text_center, a.mt_md]}>
|
||||
<Trans>Direct messages are here!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.text_center, a.mt_sm]}>
|
||||
<Trans>Privately chat with other users.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.gap_xs,
|
||||
a.border,
|
||||
a.overflow_hidden,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.p_md,
|
||||
a.border_b,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.font_bold]}>
|
||||
<Trans>Who can message you?</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.mt_xs,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>You can change this at any time.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.px_md, a.py_xs]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Who can message you?`)}
|
||||
type="radio"
|
||||
values={[chatDeclation?.allowIncoming ?? 'following']}
|
||||
onChange={onSelectItem}>
|
||||
<View>
|
||||
<Toggle.Item
|
||||
name="all"
|
||||
label={_(msg`Everyone`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Everyone</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="following"
|
||||
label={_(msg`Users I follow`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Users I follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="none"
|
||||
label={_(msg`No one`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>No one</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Start chatting`)}
|
||||
accessibilityHint={_(msg`Close modal`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Get started</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,14 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAgent, useSession} from 'state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -23,7 +26,9 @@ export function VerifyEmailIntentDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({control}: {control: DialogControlProps}) {
|
||||
function Inner({}: {control: DialogControlProps}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const {verifyEmailState: state} = useIntentDialogs()
|
||||
const [status, setStatus] = React.useState<
|
||||
@@ -58,43 +63,47 @@ function Inner({control}: {control: DialogControlProps}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
|
||||
<Dialog.Close />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<View style={[a.gap_xl]}>
|
||||
{status === 'loading' ? (
|
||||
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
<Loader size="xl" fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
) : status === 'success' ? (
|
||||
<>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<View style={[a.gap_sm, isNative && a.pb_xl]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Email Verified</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Thanks, you have successfully verified your email address.
|
||||
Thanks, you have successfully verified your email address. You
|
||||
can close this dialog.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
) : status === 'failure' ? (
|
||||
<>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Invalid Verification Code</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
The verification code you have provided is invalid. Please make
|
||||
sure that you have used the correct verification link or request
|
||||
a new one.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<View style={[a.gap_sm, isNative && a.pb_xl]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Email Resent</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
We have sent another verification email to{' '}
|
||||
<Text style={[a.text_md, a.font_bold]}>
|
||||
@@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) {
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
{status !== 'loading' ? (
|
||||
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
|
||||
|
||||
{status === 'failure' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Resend Verification Email`)}
|
||||
onPress={onPressResendEmail}
|
||||
variant="solid"
|
||||
color={status === 'failure' ? 'secondary' : 'primary'}
|
||||
color="secondary_inverted"
|
||||
size="large"
|
||||
style={{marginLeft: 'auto'}}>
|
||||
disabled={sending}>
|
||||
<ButtonIcon icon={sending ? Loader : Resend} position="left" />
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
<Trans>Resend Email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{status === 'failure' ? (
|
||||
<Button
|
||||
label={_(msg`Resend Verification Email`)}
|
||||
onPress={onPressResendEmail}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={sending}>
|
||||
<ButtonText>
|
||||
<Trans>Resend Email</Trans>
|
||||
</ButtonText>
|
||||
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
threadgateAllowUISettingToAllowRecordValue,
|
||||
writeThreadgateRecord,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {ComposerState} from '#/view/com/composer/state'
|
||||
import {ComposerState} from '#/view/com/composer/state/composer'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {uploadBlob} from './upload-blob'
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||
import {extToMime} from '#/state/queries/video/util'
|
||||
import {CompressedVideo} from './types'
|
||||
import {extToMime} from './util'
|
||||
|
||||
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
||||
import {UploadLimitError} from '#/lib/media/video/errors'
|
||||
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
||||
import {createVideoAgent} from './util'
|
||||
|
||||
export async function getServiceAuthToken({
|
||||
agent,
|
||||
aud,
|
||||
lxm,
|
||||
exp,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
aud?: string
|
||||
lxm: string
|
||||
exp?: number
|
||||
}) {
|
||||
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||
if (!pdsAud) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
||||
aud: aud ?? pdsAud,
|
||||
lxm,
|
||||
exp,
|
||||
})
|
||||
return serviceAuth.token
|
||||
}
|
||||
|
||||
export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) {
|
||||
const token = await getServiceAuthToken({
|
||||
agent,
|
||||
lxm: 'app.bsky.video.getUploadLimits',
|
||||
aud: VIDEO_SERVICE_DID,
|
||||
})
|
||||
const videoAgent = createVideoAgent()
|
||||
const {data: limits} = await videoAgent.app.bsky.video
|
||||
.getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
|
||||
.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 {
|
||||
throw new UploadLimitError(
|
||||
_(
|
||||
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {AppBskyVideoDefs, BskyAgent} from '@atproto/api'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from './util'
|
||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||
|
||||
export async function uploadVideo({
|
||||
video,
|
||||
agent,
|
||||
did,
|
||||
setProgress,
|
||||
signal,
|
||||
_,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
agent: BskyAgent
|
||||
did: string
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
_: I18n['_']
|
||||
}) {
|
||||
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',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
})
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'content-type': video.mimeType,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||
},
|
||||
p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
const res = await uploadTask.uploadAsync()
|
||||
|
||||
if (!res?.body) {
|
||||
throw new Error('No response')
|
||||
}
|
||||
|
||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||
|
||||
if (!responseBody.jobId) {
|
||||
throw new ServerError(responseBody.error || _(msg`Failed to upload video`))
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
return responseBody
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from './util'
|
||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||
|
||||
export async function uploadVideo({
|
||||
video,
|
||||
agent,
|
||||
did,
|
||||
setProgress,
|
||||
signal,
|
||||
_,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
agent: BskyAgent
|
||||
did: string
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
_: I18n['_']
|
||||
}) {
|
||||
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',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
})
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
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 (signal.aborted) {
|
||||
reject(new AbortError())
|
||||
} 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 = () => {
|
||||
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`))
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
throw new AbortError()
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import {useMemo} from 'react'
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
|
||||
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
||||
@@ -17,12 +16,10 @@ export const createVideoEndpointUrl = (
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function useVideoAgent() {
|
||||
return useMemo(() => {
|
||||
return new AtpAgent({
|
||||
service: VIDEO_SERVICE,
|
||||
})
|
||||
}, [])
|
||||
export function createVideoAgent() {
|
||||
return new AtpAgent({
|
||||
service: VIDEO_SERVICE,
|
||||
})
|
||||
}
|
||||
|
||||
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
||||
@@ -145,6 +145,8 @@ export type LogEvents = {
|
||||
}
|
||||
'post:mute': {}
|
||||
'post:unmute': {}
|
||||
'post:pin': {}
|
||||
'post:unpin': {}
|
||||
'profile:follow:sampled': {
|
||||
didBecomeMutual: boolean | undefined
|
||||
followeeClout: number | undefined
|
||||
|
||||
@@ -22,7 +22,6 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
||||
import {MessagesNUX} from '#/components/dms/MessagesNUX'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
@@ -33,7 +32,7 @@ import {Link} from '#/components/Link'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChatListItem} from './ChatListItem'
|
||||
import {ChatListItem} from './components/ChatListItem'
|
||||
|
||||
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
|
||||
|
||||
@@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
if (conversations.length < 1) {
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
<MessagesNUX />
|
||||
|
||||
<CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}>
|
||||
{gtMobile ? (
|
||||
<DesktopHeader
|
||||
@@ -240,7 +237,6 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
<MessagesNUX />
|
||||
{!gtMobile && (
|
||||
<ViewHeader
|
||||
title={_(msg`Messages`)}
|
||||
@@ -8,16 +8,16 @@ import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo'
|
||||
import {ConvoStatus} from 'state/messages/convo/types'
|
||||
import {useSetMinimalShellMode} from 'state/shell'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
|
||||
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
|
||||
+1
-1
@@ -18,11 +18,11 @@ import Graphemer from 'graphemer'
|
||||
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
useMessageDraft,
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
+2
-2
@@ -5,13 +5,13 @@ import {useLingui} from '@lingui/react'
|
||||
import Graphemer from 'graphemer'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
|
||||
import {isSafari, isTouchDevice} from '#/lib/browser'
|
||||
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {
|
||||
useMessageDraft,
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {isSafari, isTouchDevice} from 'lib/browser'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||
import {
|
||||
Emoji,
|
||||
+7
-7
@@ -15,6 +15,8 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
@@ -22,21 +24,19 @@ import {
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
||||
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {clamp} from 'lib/numbers'
|
||||
import {ScrollProvider} from 'lib/ScrollContext'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerState,
|
||||
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||
import {List} from 'view/com/util/List'
|
||||
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
||||
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
@@ -46,29 +46,31 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LazyViewShot
|
||||
// @ts-ignore this library doesn't have types
|
||||
ref={viewshotRef}
|
||||
options={{
|
||||
fileName: 'placeholderAvatar',
|
||||
format: 'jpg',
|
||||
quality: 0.8,
|
||||
height: 150 * SIZE_MULTIPLIER,
|
||||
width: 150 * SIZE_MULTIPLIER,
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
{backgroundColor: avatar.backgroundColor},
|
||||
]}
|
||||
collapsable={false}>
|
||||
<Icon
|
||||
height={85 * SIZE_MULTIPLIER}
|
||||
width={85 * SIZE_MULTIPLIER}
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
</View>
|
||||
</LazyViewShot>
|
||||
<React.Suspense fallback={null}>
|
||||
<LazyViewShot
|
||||
// @ts-ignore this library doesn't have types
|
||||
ref={viewshotRef}
|
||||
options={{
|
||||
fileName: 'placeholderAvatar',
|
||||
format: 'jpg',
|
||||
quality: 0.8,
|
||||
height: 150 * SIZE_MULTIPLIER,
|
||||
width: 150 * SIZE_MULTIPLIER,
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
{backgroundColor: avatar.backgroundColor},
|
||||
]}
|
||||
collapsable={false}>
|
||||
<Icon
|
||||
height={85 * SIZE_MULTIPLIER}
|
||||
width={85 * SIZE_MULTIPLIER}
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
</View>
|
||||
</LazyViewShot>
|
||||
</React.Suspense>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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,73 +0,0 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
||||
import {UploadLimitError} from '#/lib/media/video/errors'
|
||||
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useVideoAgent} from './util'
|
||||
|
||||
export function useServiceAuthToken({
|
||||
aud,
|
||||
lxm,
|
||||
exp,
|
||||
}: {
|
||||
aud?: string
|
||||
lxm: string
|
||||
exp?: number
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useCallback(async () => {
|
||||
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||
|
||||
if (!pdsAud) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
||||
aud: aud ?? pdsAud,
|
||||
lxm,
|
||||
exp,
|
||||
})
|
||||
|
||||
return serviceAuth.token
|
||||
}, [agent, aud, lxm, exp])
|
||||
}
|
||||
|
||||
export function useVideoUploadLimits() {
|
||||
const agent = useVideoAgent()
|
||||
const getToken = useServiceAuthToken({
|
||||
lxm: 'app.bsky.video.getUploadLimits',
|
||||
aud: VIDEO_SERVICE_DID,
|
||||
})
|
||||
const {_} = useLingui()
|
||||
|
||||
return useCallback(async () => {
|
||||
const {data: limits} = await agent.app.bsky.video
|
||||
.getUploadLimits(
|
||||
{},
|
||||
{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 {
|
||||
throw new UploadLimitError(
|
||||
_(
|
||||
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [agent, _, getToken])
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
signal,
|
||||
}: {
|
||||
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const getToken = useServiceAuthToken({
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
})
|
||||
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', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
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) {
|
||||
throw new Error('No response')
|
||||
}
|
||||
|
||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||
|
||||
if (!responseBody.jobId) {
|
||||
throw new ServerError(
|
||||
responseBody.error || _(msg`Failed to upload video`),
|
||||
)
|
||||
}
|
||||
|
||||
return responseBody
|
||||
}, signal),
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {ServerError} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
signal,
|
||||
}: {
|
||||
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const getToken = useServiceAuthToken({
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||
})
|
||||
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', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||
})
|
||||
|
||||
let bytes = video.bytes
|
||||
if (!bytes) {
|
||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
}
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
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`))
|
||||
}
|
||||
|
||||
return res
|
||||
}, signal),
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
import React, {useCallback, useEffect} from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
||||
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 {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||
import {
|
||||
ServerError,
|
||||
UploadLimitError,
|
||||
VideoTooLargeError,
|
||||
} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useCompressVideoMutation} from '#/state/queries/video/compress-video'
|
||||
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: 'SetStatus'; status: Status}
|
||||
| {type: 'SetProgress'; progress: number}
|
||||
| {type: 'SetError'; error: string | undefined}
|
||||
| {type: 'Reset'}
|
||||
| {type: 'SetAsset'; asset: ImagePickerAsset}
|
||||
| {type: 'SetDimensions'; width: number; height: number}
|
||||
| {type: 'SetVideo'; video: CompressedVideo}
|
||||
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
||||
| {type: 'SetComplete'; blobRef: BlobRef}
|
||||
|
||||
export interface State {
|
||||
status: Status
|
||||
progress: number
|
||||
asset?: ImagePickerAsset
|
||||
video: CompressedVideo | null
|
||||
jobStatus?: AppBskyVideoDefs.JobStatus
|
||||
blobRef?: BlobRef
|
||||
error?: string
|
||||
abortController: AbortController
|
||||
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
|
||||
}
|
||||
|
||||
export type VideoUploadDispatch = (action: Action) => void
|
||||
|
||||
function reducer(queryClient: QueryClient) {
|
||||
return (state: State, action: Action): State => {
|
||||
let updatedState = state
|
||||
if (action.type === 'SetStatus') {
|
||||
updatedState = {...state, status: action.status}
|
||||
} else if (action.type === 'SetProgress') {
|
||||
updatedState = {...state, progress: action.progress}
|
||||
} else if (action.type === 'SetError') {
|
||||
updatedState = {...state, error: action.error}
|
||||
} else if (action.type === 'Reset') {
|
||||
state.abortController.abort()
|
||||
queryClient.cancelQueries({
|
||||
queryKey: ['video'],
|
||||
})
|
||||
updatedState = {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
blobRef: undefined,
|
||||
abortController: new AbortController(),
|
||||
}
|
||||
} else if (action.type === 'SetAsset') {
|
||||
updatedState = {
|
||||
...state,
|
||||
asset: action.asset,
|
||||
status: 'compressing',
|
||||
error: undefined,
|
||||
}
|
||||
} else if (action.type === 'SetDimensions') {
|
||||
updatedState = {
|
||||
...state,
|
||||
asset: state.asset
|
||||
? {...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 === 'SetJobStatus') {
|
||||
updatedState = {...state, jobStatus: action.jobStatus}
|
||||
} else if (action.type === 'SetComplete') {
|
||||
updatedState = {
|
||||
...state,
|
||||
pendingPublish: {
|
||||
blobRef: action.blobRef,
|
||||
mutableProcessed: false,
|
||||
},
|
||||
status: 'done',
|
||||
}
|
||||
}
|
||||
return updatedState
|
||||
}
|
||||
}
|
||||
|
||||
export function useUploadVideo({
|
||||
setStatus,
|
||||
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({
|
||||
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],
|
||||
)
|
||||
|
||||
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 = ({
|
||||
onStatusChange,
|
||||
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({
|
||||
queryKey: ['video', 'upload status', jobId],
|
||||
queryFn: async () => {
|
||||
if (!jobId) return // this won't happen, can ignore
|
||||
|
||||
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
|
||||
const status = data.jobStatus
|
||||
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||
setEnabled(false)
|
||||
if (!status.blob)
|
||||
throw new Error('Job completed, but did not return a blob')
|
||||
onSuccess(status.blob)
|
||||
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||
throw new Error(status.error ?? 'Job failed to process')
|
||||
}
|
||||
onStatusChange(status)
|
||||
return status
|
||||
},
|
||||
enabled: Boolean(jobId && enabled),
|
||||
refetchInterval: 1500,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onError(error)
|
||||
setEnabled(false)
|
||||
}
|
||||
}, [error, onError])
|
||||
|
||||
return {
|
||||
setJobId: (_jobId: string) => {
|
||||
setJobId(_jobId)
|
||||
setEnabled(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import Animated, {
|
||||
ZoomOut,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
@@ -81,11 +82,6 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
|
||||
import {
|
||||
State as VideoUploadState,
|
||||
useUploadVideo,
|
||||
VideoUploadDispatch,
|
||||
} from '#/state/queries/video/video'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {ComposerOpts} from '#/state/shell/composer'
|
||||
@@ -120,7 +116,8 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text as NewText} from '#/components/Typography'
|
||||
import {composerReducer, createComposerState} from './state'
|
||||
import {composerReducer, createComposerState} from './state/composer'
|
||||
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
|
||||
|
||||
const MAX_IMAGES = 4
|
||||
|
||||
@@ -147,7 +144,8 @@ export const ComposePost = ({
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
const currentDid = currentAccount!.did
|
||||
const {data: currentProfile} = useProfileQuery({did: currentDid})
|
||||
const {isModalActive} = useModals()
|
||||
const {closeComposer} = useComposerControls()
|
||||
const pal = usePalette('default')
|
||||
@@ -189,22 +187,62 @@ export const ComposePost = ({
|
||||
const [videoAltText, setVideoAltText] = useState('')
|
||||
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
|
||||
|
||||
const {
|
||||
selectVideo,
|
||||
clearVideo,
|
||||
state: videoUploadState,
|
||||
updateVideoDimensions,
|
||||
dispatch: videoUploadDispatch,
|
||||
} = useUploadVideo({
|
||||
setStatus: setProcessingState,
|
||||
onSuccess: () => {
|
||||
if (publishOnUpload) {
|
||||
onPressPublish(true)
|
||||
}
|
||||
// TODO: Move more state here.
|
||||
const [composerState, dispatch] = useReducer(
|
||||
composerReducer,
|
||||
{initImageUris},
|
||||
createComposerState,
|
||||
)
|
||||
|
||||
let videoState: VideoState | NoVideoState = NO_VIDEO
|
||||
if (composerState.embed.media?.type === 'video') {
|
||||
videoState = composerState.embed.media.video
|
||||
}
|
||||
|
||||
const selectVideo = React.useCallback(
|
||||
(asset: ImagePickerAsset) => {
|
||||
const abortController = new AbortController()
|
||||
dispatch({type: 'embed_add_video', asset, abortController})
|
||||
processVideo(
|
||||
asset,
|
||||
videoAction => dispatch({type: 'embed_update_video', videoAction}),
|
||||
agent,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
_,
|
||||
)
|
||||
},
|
||||
initialVideoUri: initVideoUri,
|
||||
})
|
||||
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
|
||||
[_, 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(() => {
|
||||
videoState.abortController.abort()
|
||||
dispatch({type: 'embed_remove_video'})
|
||||
}, [videoState.abortController, dispatch])
|
||||
|
||||
const updateVideoDimensions = useCallback(
|
||||
(width: number, height: number) => {
|
||||
dispatch({
|
||||
type: 'embed_update_video',
|
||||
videoAction: {
|
||||
type: 'update_dimensions',
|
||||
width,
|
||||
height,
|
||||
signal: videoState.abortController.signal,
|
||||
},
|
||||
})
|
||||
},
|
||||
[videoState.abortController],
|
||||
)
|
||||
|
||||
const hasVideo = Boolean(videoState.asset || videoState.video)
|
||||
|
||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||
|
||||
@@ -217,12 +255,6 @@ export const ComposePost = ({
|
||||
)
|
||||
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
|
||||
|
||||
// TODO: Move more state here.
|
||||
const [composerState, dispatch] = useReducer(
|
||||
composerReducer,
|
||||
{initImageUris},
|
||||
createComposerState,
|
||||
)
|
||||
let images = NO_IMAGES
|
||||
if (composerState.embed.media?.type === 'images') {
|
||||
images = composerState.embed.media.images
|
||||
@@ -247,7 +279,7 @@ export const ComposePost = ({
|
||||
graphemeLength > 0 ||
|
||||
images.length !== 0 ||
|
||||
extGif ||
|
||||
videoUploadState.status !== 'idle'
|
||||
videoState.status !== 'idle'
|
||||
) {
|
||||
closeAllDialogs()
|
||||
Keyboard.dismiss()
|
||||
@@ -262,7 +294,7 @@ export const ComposePost = ({
|
||||
closeAllDialogs,
|
||||
discardPromptControl,
|
||||
onClose,
|
||||
videoUploadState.status,
|
||||
videoState.status,
|
||||
])
|
||||
|
||||
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
||||
@@ -359,8 +391,8 @@ export const ComposePost = ({
|
||||
|
||||
if (
|
||||
!finishedUploading &&
|
||||
videoUploadState.asset &&
|
||||
videoUploadState.status !== 'done'
|
||||
videoState.asset &&
|
||||
videoState.status !== 'done'
|
||||
) {
|
||||
setPublishOnUpload(true)
|
||||
return
|
||||
@@ -373,7 +405,7 @@ export const ComposePost = ({
|
||||
images.length === 0 &&
|
||||
!extLink &&
|
||||
!quote &&
|
||||
videoUploadState.status === 'idle'
|
||||
videoState.status === 'idle'
|
||||
) {
|
||||
setError(_(msg`Did you want to say anything?`))
|
||||
return
|
||||
@@ -400,19 +432,18 @@ export const ComposePost = ({
|
||||
postgate,
|
||||
onStateChange: setProcessingState,
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
video: videoUploadState.pendingPublish?.blobRef
|
||||
? {
|
||||
blobRef: videoUploadState.pendingPublish.blobRef,
|
||||
altText: videoAltText,
|
||||
captions: captions,
|
||||
aspectRatio: videoUploadState.asset
|
||||
? {
|
||||
width: videoUploadState.asset?.width,
|
||||
height: videoUploadState.asset?.height,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
video:
|
||||
videoState.status === 'done'
|
||||
? {
|
||||
blobRef: videoState.pendingPublish.blobRef,
|
||||
altText: videoAltText,
|
||||
captions: captions,
|
||||
aspectRatio: {
|
||||
width: videoState.asset.width,
|
||||
height: videoState.asset.height,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
).uri
|
||||
try {
|
||||
@@ -510,20 +541,20 @@ export const ComposePost = ({
|
||||
setLangPrefs,
|
||||
threadgateAllowUISettings,
|
||||
videoAltText,
|
||||
videoUploadState.asset,
|
||||
videoUploadState.pendingPublish,
|
||||
videoUploadState.status,
|
||||
videoState.asset,
|
||||
videoState.pendingPublish,
|
||||
videoState.status,
|
||||
],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (videoUploadState.pendingPublish && publishOnUpload) {
|
||||
if (!videoUploadState.pendingPublish.mutableProcessed) {
|
||||
videoUploadState.pendingPublish.mutableProcessed = true
|
||||
if (videoState.pendingPublish && publishOnUpload) {
|
||||
if (!videoState.pendingPublish.mutableProcessed) {
|
||||
videoState.pendingPublish.mutableProcessed = true
|
||||
onPressPublish(true)
|
||||
}
|
||||
}
|
||||
}, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
|
||||
}, [onPressPublish, publishOnUpload, videoState.pendingPublish])
|
||||
|
||||
const canPost = useMemo(
|
||||
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
|
||||
@@ -536,10 +567,10 @@ export const ComposePost = ({
|
||||
const canSelectImages =
|
||||
images.length < MAX_IMAGES &&
|
||||
!extLink &&
|
||||
videoUploadState.status === 'idle' &&
|
||||
!videoUploadState.video
|
||||
videoState.status === 'idle' &&
|
||||
!videoState.video
|
||||
const hasMedia =
|
||||
images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||
images.length > 0 || Boolean(extLink) || Boolean(videoState.video)
|
||||
|
||||
const onEmojiButtonPress = useCallback(() => {
|
||||
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||
@@ -654,9 +685,7 @@ export const ComposePost = ({
|
||||
size="small"
|
||||
style={[a.rounded_full, a.py_sm]}
|
||||
onPress={() => onPressPublish()}
|
||||
disabled={
|
||||
videoUploadState.status !== 'idle' && publishOnUpload
|
||||
}>
|
||||
disabled={videoState.status !== 'idle' && publishOnUpload}>
|
||||
<ButtonText style={[a.text_md]}>
|
||||
{replyTo ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
@@ -692,9 +721,9 @@ export const ComposePost = ({
|
||||
)}
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
videoUploadState={videoUploadState}
|
||||
videoState={videoState}
|
||||
clearError={() => setError('')}
|
||||
videoUploadDispatch={videoUploadDispatch}
|
||||
clearVideo={clearVideo}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.ScrollView
|
||||
@@ -758,17 +787,17 @@ export const ComposePost = ({
|
||||
style={[a.w_full, a.mt_lg]}
|
||||
entering={native(ZoomIn)}
|
||||
exiting={native(ZoomOut)}>
|
||||
{videoUploadState.asset &&
|
||||
(videoUploadState.status === 'compressing' ? (
|
||||
{videoState.asset &&
|
||||
(videoState.status === 'compressing' ? (
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
asset={videoState.asset}
|
||||
progress={videoState.progress}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
) : videoState.video ? (
|
||||
<VideoPreview
|
||||
asset={videoUploadState.asset}
|
||||
video={videoUploadState.video}
|
||||
asset={videoState.asset}
|
||||
video={videoState.video}
|
||||
setDimensions={updateVideoDimensions}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
@@ -814,9 +843,8 @@ export const ComposePost = ({
|
||||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
{videoUploadState.status !== 'idle' &&
|
||||
videoUploadState.status !== 'done' ? (
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
{videoState.status !== 'idle' && videoState.status !== 'done' ? (
|
||||
<VideoUploadToolbar state={videoState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn
|
||||
@@ -826,7 +854,7 @@ export const ComposePost = ({
|
||||
/>
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
disabled={!canSelectImages || images?.length > 0}
|
||||
setError={setError}
|
||||
/>
|
||||
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
|
||||
@@ -1081,27 +1109,27 @@ const styles = StyleSheet.create({
|
||||
|
||||
function ErrorBanner({
|
||||
error: standardError,
|
||||
videoUploadState,
|
||||
videoState,
|
||||
clearError,
|
||||
videoUploadDispatch,
|
||||
clearVideo,
|
||||
}: {
|
||||
error: string
|
||||
videoUploadState: VideoUploadState
|
||||
videoState: VideoState | NoVideoState
|
||||
clearError: () => void
|
||||
videoUploadDispatch: VideoUploadDispatch
|
||||
clearVideo: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const videoError =
|
||||
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
|
||||
videoState.status === 'error' ? videoState.error : undefined
|
||||
const error = standardError || videoError
|
||||
|
||||
const onClearError = () => {
|
||||
if (standardError) {
|
||||
clearError()
|
||||
} else {
|
||||
videoUploadDispatch({type: 'Reset'})
|
||||
clearVideo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,7 +1164,7 @@ function ErrorBanner({
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
{videoError && videoUploadState.jobStatus?.jobId && (
|
||||
{videoError && videoState.jobId && (
|
||||
<NewText
|
||||
style={[
|
||||
{paddingLeft: 28},
|
||||
@@ -1145,7 +1173,7 @@ function ErrorBanner({
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
|
||||
<Trans>Job ID: {videoState.jobId}</Trans>
|
||||
</NewText>
|
||||
)}
|
||||
</View>
|
||||
@@ -1171,12 +1199,10 @@ function ToolbarWrapper({
|
||||
)
|
||||
}
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
function VideoUploadToolbar({state}: {state: VideoState}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const progress = state.jobStatus?.progress
|
||||
? state.jobStatus.progress / 100
|
||||
: state.progress
|
||||
const progress = state.progress
|
||||
const shouldRotate =
|
||||
state.status === 'processing' && (progress === 0 || progress === 1)
|
||||
let wheelProgress = shouldRotate ? 0.33 : progress
|
||||
@@ -1212,16 +1238,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
case 'processing':
|
||||
text = _('Processing video...')
|
||||
break
|
||||
case 'error':
|
||||
text = _('Error')
|
||||
wheelProgress = 100
|
||||
break
|
||||
case 'done':
|
||||
text = _('Video uploaded')
|
||||
break
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
text = _('Error')
|
||||
wheelProgress = 100
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<Animated.View style={[animatedStyle]}>
|
||||
@@ -1229,7 +1254,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
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}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {ComposerAction} from '../state'
|
||||
import {ComposerAction} from '../state/composer'
|
||||
import {EditImageDialog} from './EditImageDialog'
|
||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {ComposerImage, createInitialImages} from '#/state/gallery'
|
||||
import {ComposerOpts} from '#/state/shell/composer'
|
||||
import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
|
||||
|
||||
type PostRecord = {
|
||||
uri: string
|
||||
@@ -11,11 +14,16 @@ type ImagesMedia = {
|
||||
labels: string[]
|
||||
}
|
||||
|
||||
type VideoMedia = {
|
||||
type: 'video'
|
||||
video: VideoState
|
||||
}
|
||||
|
||||
type ComposerEmbed = {
|
||||
// TODO: Other record types.
|
||||
record: PostRecord | undefined
|
||||
// TODO: Other media types.
|
||||
media: ImagesMedia | undefined
|
||||
media: ImagesMedia | VideoMedia | undefined
|
||||
}
|
||||
|
||||
export type ComposerState = {
|
||||
@@ -27,6 +35,13 @@ export type ComposerAction =
|
||||
| {type: 'embed_add_images'; images: ComposerImage[]}
|
||||
| {type: 'embed_update_image'; image: ComposerImage}
|
||||
| {type: 'embed_remove_image'; image: ComposerImage}
|
||||
| {
|
||||
type: 'embed_add_video'
|
||||
asset: ImagePickerAsset
|
||||
abortController: AbortController
|
||||
}
|
||||
| {type: 'embed_remove_video'}
|
||||
| {type: 'embed_update_video'; videoAction: VideoAction}
|
||||
|
||||
const MAX_IMAGES = 4
|
||||
|
||||
@@ -36,6 +51,9 @@ export function composerReducer(
|
||||
): ComposerState {
|
||||
switch (action.type) {
|
||||
case 'embed_add_images': {
|
||||
if (action.images.length === 0) {
|
||||
return state
|
||||
}
|
||||
const prevMedia = state.embed.media
|
||||
let nextMedia = prevMedia
|
||||
if (!prevMedia) {
|
||||
@@ -104,6 +122,55 @@ export function composerReducer(
|
||||
}
|
||||
return state
|
||||
}
|
||||
case 'embed_add_video': {
|
||||
const prevMedia = state.embed.media
|
||||
let nextMedia = prevMedia
|
||||
if (!prevMedia) {
|
||||
nextMedia = {
|
||||
type: 'video',
|
||||
video: createVideoState(action.asset, action.abortController),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
embed: {
|
||||
...state.embed,
|
||||
media: nextMedia,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'embed_update_video': {
|
||||
const videoAction = action.videoAction
|
||||
const prevMedia = state.embed.media
|
||||
let nextMedia = prevMedia
|
||||
if (prevMedia?.type === 'video') {
|
||||
nextMedia = {
|
||||
...prevMedia,
|
||||
video: videoReducer(prevMedia.video, videoAction),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
embed: {
|
||||
...state.embed,
|
||||
media: nextMedia,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'embed_remove_video': {
|
||||
const prevMedia = state.embed.media
|
||||
let nextMedia = prevMedia
|
||||
if (prevMedia?.type === 'video') {
|
||||
nextMedia = undefined
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
embed: {
|
||||
...state.embed,
|
||||
media: nextMedia,
|
||||
},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
@@ -122,6 +189,7 @@ export function createComposerState({
|
||||
labels: [],
|
||||
}
|
||||
}
|
||||
// TODO: initial video.
|
||||
return {
|
||||
embed: {
|
||||
record: undefined,
|
||||
@@ -0,0 +1,406 @@
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
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 {createVideoAgent} from '#/lib/media/video/util'
|
||||
import {uploadVideo} from '#/lib/media/video/upload'
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {compressVideo} from '#/lib/media/video/compress'
|
||||
import {
|
||||
ServerError,
|
||||
UploadLimitError,
|
||||
VideoTooLargeError,
|
||||
} from '#/lib/media/video/errors'
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export type VideoAction =
|
||||
| {
|
||||
type: 'compressing_to_uploading'
|
||||
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
|
||||
}
|
||||
|
||||
const noopController = new AbortController()
|
||||
noopController.abort()
|
||||
|
||||
export const NO_VIDEO = Object.freeze({
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
abortController: noopController,
|
||||
asset: undefined,
|
||||
video: undefined,
|
||||
jobId: undefined,
|
||||
pendingPublish: undefined,
|
||||
})
|
||||
|
||||
export type NoVideoState = typeof NO_VIDEO
|
||||
|
||||
type ErrorState = {
|
||||
status: 'error'
|
||||
progress: 100
|
||||
abortController: AbortController
|
||||
asset: ImagePickerAsset | null
|
||||
video: CompressedVideo | null
|
||||
jobId: string | null
|
||||
error: string
|
||||
pendingPublish?: undefined
|
||||
}
|
||||
|
||||
type CompressingState = {
|
||||
status: 'compressing'
|
||||
progress: number
|
||||
abortController: AbortController
|
||||
asset: ImagePickerAsset
|
||||
video?: undefined
|
||||
jobId?: undefined
|
||||
pendingPublish?: undefined
|
||||
}
|
||||
|
||||
type UploadingState = {
|
||||
status: 'uploading'
|
||||
progress: number
|
||||
abortController: AbortController
|
||||
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 VideoState =
|
||||
| ErrorState
|
||||
| CompressingState
|
||||
| UploadingState
|
||||
| ProcessingState
|
||||
| DoneState
|
||||
|
||||
export function createVideoState(
|
||||
asset: ImagePickerAsset,
|
||||
abortController: AbortController,
|
||||
): CompressingState {
|
||||
return {
|
||||
status: 'compressing',
|
||||
progress: 0,
|
||||
abortController,
|
||||
asset,
|
||||
}
|
||||
}
|
||||
|
||||
export function videoReducer(
|
||||
state: VideoState,
|
||||
action: VideoAction,
|
||||
): VideoState {
|
||||
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,
|
||||
progress: action.progress,
|
||||
}
|
||||
}
|
||||
} else if (action.type === 'update_dimensions') {
|
||||
if (state.asset) {
|
||||
return {
|
||||
...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: {
|
||||
blobRef: action.blobRef,
|
||||
mutableProcessed: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
'Unexpected video action (' +
|
||||
action.type +
|
||||
') while in ' +
|
||||
state.status +
|
||||
' state',
|
||||
)
|
||||
return state
|
||||
}
|
||||
|
||||
function trunc2dp(num: number) {
|
||||
return Math.trunc(num * 100) / 100
|
||||
}
|
||||
|
||||
export async function processVideo(
|
||||
asset: ImagePickerAsset,
|
||||
dispatch: (action: VideoAction) => void,
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
_: I18n['_'],
|
||||
) {
|
||||
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
|
||||
|
||||
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||
blob = status.blob
|
||||
if (!blob) {
|
||||
throw new Error('Job completed, but did not return a blob')
|
||||
}
|
||||
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||
throw new Error(status.error ?? 'Job failed to process')
|
||||
}
|
||||
} catch (e) {
|
||||
if (!status) {
|
||||
pollFailures++
|
||||
if (pollFailures < 50) {
|
||||
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
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
dispatch({
|
||||
type: 'to_done',
|
||||
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 getCompressErrorMessage(e: unknown, _: I18n['_']): string | null {
|
||||
if (e instanceof AbortError) {
|
||||
return null
|
||||
}
|
||||
if (e instanceof VideoTooLargeError) {
|
||||
return _(msg`The selected video is larger than 50MB.`)
|
||||
}
|
||||
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.`)
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
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 {getHostnameFromUrl} from '#/lib/strings/url-helpers'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
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 {Button} from '#/components/Button'
|
||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||
@@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
})
|
||||
if (response.assets && response.assets.length > 0) {
|
||||
if (isNative) {
|
||||
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
|
||||
}
|
||||
}
|
||||
const asset = response.assets[0]
|
||||
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) {
|
||||
if (err instanceof Error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {useHapticsDisabled} from '#/state/preferences'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostThreadComposePrompt({
|
||||
@@ -21,10 +21,15 @@ export function PostThreadComposePrompt({
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const {_} = useLingui()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
const playHaptics = useHaptics()
|
||||
const isHapticsDisabled = useHapticsDisabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
|
||||
const onPress = () => {
|
||||
playHaptics('Light')
|
||||
@@ -42,13 +47,15 @@ export function PostThreadComposePrompt({
|
||||
accessibilityLabel={_(msg`Compose reply`)}
|
||||
accessibilityHint={_(msg`Opens composer`)}
|
||||
style={[
|
||||
{paddingTop: 8, paddingBottom: isTabletOrDesktop ? 8 : 11},
|
||||
a.px_sm,
|
||||
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
|
||||
gtMobile ? {paddingLeft: 6, paddingRight: 6} : a.px_sm,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
]}
|
||||
onPress={onPress}>
|
||||
onPress={onPress}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
@@ -56,10 +63,11 @@ export function PostThreadComposePrompt({
|
||||
a.p_sm,
|
||||
a.gap_sm,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
(!gtMobile || hovered) && t.atoms.bg_contrast_25,
|
||||
a.transition_color,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={22}
|
||||
size={gtMobile ? 24 : 22}
|
||||
avatar={profile?.avatar}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
@@ -350,6 +351,7 @@ let PostDropdownBtn = ({
|
||||
])
|
||||
|
||||
const onPressPin = useCallback(() => {
|
||||
logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
|
||||
pinPostMutate({
|
||||
postUri,
|
||||
postCid,
|
||||
|
||||
Reference in New Issue
Block a user