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:
Eric Bailey
2024-10-03 09:44:05 -05:00
35 changed files with 1011 additions and 1009 deletions
+3
View File
@@ -15,3 +15,6 @@ android
ios ios
src/locale/locales src/locale/locales
lib/react-compiler-runtime lib/react-compiler-runtime
bskyweb/static
coverage
web-build
+1
View File
@@ -255,6 +255,7 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric) e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric) e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric) e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/messages", server.WebGeneric) e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric) e.GET("/messages/:conversation", server.WebGeneric)
+1 -1
View File
@@ -78,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation' import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
+28
View File
@@ -901,4 +901,32 @@ export const atoms = {
hidden: { hidden: {
display: 'none', 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 } as const
-175
View File
@@ -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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgent, useSession} from 'state/session' import {isNative} from '#/platform/detection'
import {atoms as a} from '#/alf' import {useAgent, useSession} from '#/state/session'
import {Button, ButtonText} from '#/components/Button' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {DialogControlProps} 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 {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' 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 {_} = useLingui()
const {verifyEmailState: state} = useIntentDialogs() const {verifyEmailState: state} = useIntentDialogs()
const [status, setStatus] = React.useState< const [status, setStatus] = React.useState<
@@ -58,43 +63,47 @@ function Inner({control}: {control: DialogControlProps}) {
} }
return ( return (
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}> <Dialog.ScrollableInner
<Dialog.Close /> label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<View style={[a.gap_xl]}> <View style={[a.gap_xl]}>
{status === 'loading' ? ( {status === 'loading' ? (
<View style={[a.py_2xl, a.align_center, a.justify_center]}> <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> </View>
) : status === 'success' ? ( ) : status === 'success' ? (
<> <View style={[a.gap_sm, isNative && a.pb_xl]}>
<Text style={[a.font_bold, a.text_2xl]}> <Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Email Verified</Trans> <Trans>Email Verified</Trans>
</Text> </Text>
<Text style={[a.text_md, a.leading_tight]}> <Text style={[a.text_md, a.leading_snug]}>
<Trans> <Trans>
Thanks, you have successfully verified your email address. Thanks, you have successfully verified your email address. You
can close this dialog.
</Trans> </Trans>
</Text> </Text>
</> </View>
) : status === 'failure' ? ( ) : status === 'failure' ? (
<> <View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_2xl]}> <Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Invalid Verification Code</Trans> <Trans>Invalid Verification Code</Trans>
</Text> </Text>
<Text style={[a.text_md, a.leading_tight]}> <Text style={[a.text_md, a.leading_snug]}>
<Trans> <Trans>
The verification code you have provided is invalid. Please make The verification code you have provided is invalid. Please make
sure that you have used the correct verification link or request sure that you have used the correct verification link or request
a new one. a new one.
</Trans> </Trans>
</Text> </Text>
</> </View>
) : ( ) : (
<> <View style={[a.gap_sm, isNative && a.pb_xl]}>
<Text style={[a.font_bold, a.text_2xl]}> <Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Email Resent</Trans> <Trans>Email Resent</Trans>
</Text> </Text>
<Text style={[a.text_md, a.leading_tight]}> <Text style={[a.text_md, a.leading_snug]}>
<Trans> <Trans>
We have sent another verification email to{' '} We have sent another verification email to{' '}
<Text style={[a.text_md, a.font_bold]}> <Text style={[a.text_md, a.font_bold]}>
@@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) {
. .
</Trans> </Trans>
</Text> </Text>
</> </View>
)} )}
{status !== 'loading' ? (
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}> {status === 'failure' && (
<Button <>
label={_(msg`Close`)} <Divider />
onPress={() => control.close()}
variant="solid"
color={status === 'failure' ? 'secondary' : 'primary'}
size="large"
style={{marginLeft: 'auto'}}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
{status === 'failure' ? (
<Button <Button
label={_(msg`Resend Verification Email`)} label={_(msg`Resend Verification Email`)}
onPress={onPressResendEmail} onPress={onPressResendEmail}
variant="solid" variant="solid"
color="primary" color="secondary_inverted"
size="large" size="large"
disabled={sending}> disabled={sending}>
<ButtonIcon icon={sending ? Loader : Resend} position="left" />
<ButtonText> <ButtonText>
<Trans>Resend Email</Trans> <Trans>Resend Email</Trans>
</ButtonText> </ButtonText>
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
</Button> </Button>
) : null} </>
</View> )}
) : null}
</View> </View>
<Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }
+1 -1
View File
@@ -24,7 +24,7 @@ import {
threadgateAllowUISettingToAllowRecordValue, threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord, writeThreadgateRecord,
} from '#/state/queries/threadgate' } 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 {LinkMeta} from '../link-meta/link-meta'
import {uploadBlob} from './upload-blob' import {uploadBlob} from './upload-blob'
+1 -1
View File
@@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {extToMime} from '#/state/queries/video/util'
import {CompressedVideo} from './types' import {CompressedVideo} from './types'
import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
+61
View File
@@ -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.`,
),
)
}
}
}
+79
View File
@@ -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
}
+95
View File
@@ -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 {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 & {})) {
+2
View File
@@ -145,6 +145,8 @@ export type LogEvents = {
} }
'post:mute': {} 'post:mute': {}
'post:unmute': {} 'post:unmute': {}
'post:pin': {}
'post:unpin': {}
'profile:follow:sampled': { 'profile:follow:sampled': {
didBecomeMutual: boolean | undefined didBecomeMutual: boolean | undefined
followeeClout: number | 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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DialogControlProps, useDialogControl} from '#/components/Dialog' import {DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog' import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {MessagesNUX} from '#/components/dms/MessagesNUX'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 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 {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ChatListItem} from './ChatListItem' import {ChatListItem} from './components/ChatListItem'
type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
@@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) {
if (conversations.length < 1) { if (conversations.length < 1) {
return ( return (
<View style={a.flex_1}> <View style={a.flex_1}>
<MessagesNUX />
<CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}> <CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}>
{gtMobile ? ( {gtMobile ? (
<DesktopHeader <DesktopHeader
@@ -240,7 +237,6 @@ export function MessagesScreen({navigation, route}: Props) {
return ( return (
<View style={a.flex_1}> <View style={a.flex_1}>
<MessagesNUX />
{!gtMobile && ( {!gtMobile && (
<ViewHeader <ViewHeader
title={_(msg`Messages`)} title={_(msg`Messages`)}
@@ -8,16 +8,16 @@ import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types' 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 {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {isWeb} from 'platform/detection' import {useSetMinimalShellMode} from '#/state/shell'
import {useProfileShadow} from 'state/cache/profile-shadow' import {CenteredView} from '#/view/com/util/Views'
import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo' import {MessagesList} from '#/screens/Messages/components/MessagesList'
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 {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader' import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
@@ -18,11 +18,11 @@ import Graphemer from 'graphemer'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {isIOS} from '#/platform/detection'
import { import {
useMessageDraft, useMessageDraft,
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection'
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -5,13 +5,13 @@ import {useLingui} from '@lingui/react'
import Graphemer from 'graphemer' import Graphemer from 'graphemer'
import TextareaAutosize from 'react-textarea-autosize' import TextareaAutosize from 'react-textarea-autosize'
import {isSafari, isTouchDevice} from '#/lib/browser'
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import { import {
useMessageDraft, useMessageDraft,
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } 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 {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import { import {
Emoji, Emoji,
@@ -15,6 +15,8 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' 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 {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
@@ -22,21 +24,19 @@ import {
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {isWeb} from '#/platform/detection'
import {isConvoActive, useConvoActive} from '#/state/messages/convo' import {isConvoActive, useConvoActive} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
import { import {
EmojiPicker, EmojiPicker,
EmojiPickerState, EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker.web' } from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from 'view/com/util/List' import {List} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
@@ -46,6 +46,7 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
return ( return (
<View style={styles.container}> <View style={styles.container}>
<React.Suspense fallback={null}>
<LazyViewShot <LazyViewShot
// @ts-ignore this library doesn't have types // @ts-ignore this library doesn't have types
ref={viewshotRef} ref={viewshotRef}
@@ -69,6 +70,7 @@ export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
/> />
</View> </View>
</LazyViewShot> </LazyViewShot>
</React.Suspense>
</View> </View>
) )
}, },
-39
View File
@@ -1,39 +0,0 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query'
import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/types'
import {compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({
onProgress,
onSuccess,
onError,
signal,
}: {
onProgress: (progress: number) => void
onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void
signal: AbortSignal
}) {
return useMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
signal,
),
onError,
onSuccess,
onMutate: () => {
onProgress(0)
},
})
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
@@ -1,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])
}
-76
View File
@@ -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,
})
}
-351
View File
@@ -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! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
break
case "Failed to get user's upload stats":
message = _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
break
case 'User has exceeded daily upload bytes limit':
message = _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
break
case 'User has exceeded daily upload videos limit':
message = _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
break
case 'Account is not old enough to upload videos':
message = _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
break
default:
message = e.message
break
}
dispatch({
type: 'SetError',
error: message,
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
}
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
signal: state.abortController.signal,
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
onVideoCompressed(video)
},
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 50MB.`),
})
} else {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while compressing the video.`),
})
logger.error('Error compressing video', {safeMessage: e})
}
},
signal: state.abortController.signal,
})
const selectVideo = React.useCallback(
(asset: ImagePickerAsset) => {
// compression step on native converts to mp4, so no need to check there
if (isWeb) {
const mimeType = getMimeType(asset)
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
throw new Error(_(msg`Unsupported video type: ${mimeType}`))
}
}
dispatch({
type: 'SetAsset',
asset,
})
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
}
+112 -83
View File
@@ -36,6 +36,7 @@ import Animated, {
ZoomOut, ZoomOut,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ImagePickerAsset} from 'expo-image-picker'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedGetPostThread,
@@ -81,11 +82,6 @@ import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor' 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 {
State as VideoUploadState,
useUploadVideo,
VideoUploadDispatch,
} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {ComposerOpts} 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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
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/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
const MAX_IMAGES = 4 const MAX_IMAGES = 4
@@ -147,7 +144,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,22 +187,62 @@ 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 { // TODO: Move more state here.
selectVideo, const [composerState, dispatch] = useReducer(
clearVideo, composerReducer,
state: videoUploadState, {initImageUris},
updateVideoDimensions, createComposerState,
dispatch: videoUploadDispatch, )
} = useUploadVideo({
setStatus: setProcessingState, let videoState: VideoState | NoVideoState = NO_VIDEO
onSuccess: () => { if (composerState.embed.media?.type === 'video') {
if (publishOnUpload) { videoState = composerState.embed.media.video
onPressPublish(true)
} }
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,
_,
)
},
[_, 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,
}, },
initialVideoUri: initVideoUri,
}) })
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) },
[videoState.abortController],
)
const hasVideo = Boolean(videoState.asset || videoState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false) const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -217,12 +255,6 @@ export const ComposePost = ({
) )
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
// TODO: Move more state here.
const [composerState, dispatch] = useReducer(
composerReducer,
{initImageUris},
createComposerState,
)
let images = NO_IMAGES let images = NO_IMAGES
if (composerState.embed.media?.type === 'images') { if (composerState.embed.media?.type === 'images') {
images = composerState.embed.media.images images = composerState.embed.media.images
@@ -247,7 +279,7 @@ export const ComposePost = ({
graphemeLength > 0 || graphemeLength > 0 ||
images.length !== 0 || images.length !== 0 ||
extGif || extGif ||
videoUploadState.status !== 'idle' videoState.status !== 'idle'
) { ) {
closeAllDialogs() closeAllDialogs()
Keyboard.dismiss() Keyboard.dismiss()
@@ -262,7 +294,7 @@ export const ComposePost = ({
closeAllDialogs, closeAllDialogs,
discardPromptControl, discardPromptControl,
onClose, onClose,
videoUploadState.status, videoState.status,
]) ])
useImperativeHandle(cancelRef, () => ({onPressCancel})) useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -359,8 +391,8 @@ export const ComposePost = ({
if ( if (
!finishedUploading && !finishedUploading &&
videoUploadState.asset && videoState.asset &&
videoUploadState.status !== 'done' videoState.status !== 'done'
) { ) {
setPublishOnUpload(true) setPublishOnUpload(true)
return return
@@ -373,7 +405,7 @@ export const ComposePost = ({
images.length === 0 && images.length === 0 &&
!extLink && !extLink &&
!quote && !quote &&
videoUploadState.status === 'idle' videoState.status === 'idle'
) { ) {
setError(_(msg`Did you want to say anything?`)) setError(_(msg`Did you want to say anything?`))
return return
@@ -400,17 +432,16 @@ export const ComposePost = ({
postgate, postgate,
onStateChange: setProcessingState, onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage), langs: toPostLanguages(langPrefs.postLanguage),
video: videoUploadState.pendingPublish?.blobRef video:
videoState.status === 'done'
? { ? {
blobRef: videoUploadState.pendingPublish.blobRef, blobRef: videoState.pendingPublish.blobRef,
altText: videoAltText, altText: videoAltText,
captions: captions, captions: captions,
aspectRatio: videoUploadState.asset aspectRatio: {
? { width: videoState.asset.width,
width: videoUploadState.asset?.width, height: videoState.asset.height,
height: videoUploadState.asset?.height, },
}
: undefined,
} }
: undefined, : undefined,
}) })
@@ -510,20 +541,20 @@ export const ComposePost = ({
setLangPrefs, setLangPrefs,
threadgateAllowUISettings, threadgateAllowUISettings,
videoAltText, videoAltText,
videoUploadState.asset, videoState.asset,
videoUploadState.pendingPublish, videoState.pendingPublish,
videoUploadState.status, videoState.status,
], ],
) )
React.useEffect(() => { React.useEffect(() => {
if (videoUploadState.pendingPublish && publishOnUpload) { if (videoState.pendingPublish && publishOnUpload) {
if (!videoUploadState.pendingPublish.mutableProcessed) { if (!videoState.pendingPublish.mutableProcessed) {
videoUploadState.pendingPublish.mutableProcessed = true videoState.pendingPublish.mutableProcessed = true
onPressPublish(true) onPressPublish(true)
} }
} }
}, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish]) }, [onPressPublish, publishOnUpload, videoState.pendingPublish])
const canPost = useMemo( const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
@@ -536,10 +567,10 @@ export const ComposePost = ({
const canSelectImages = const canSelectImages =
images.length < MAX_IMAGES && images.length < MAX_IMAGES &&
!extLink && !extLink &&
videoUploadState.status === 'idle' && videoState.status === 'idle' &&
!videoUploadState.video !videoState.video
const hasMedia = const hasMedia =
images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) images.length > 0 || Boolean(extLink) || Boolean(videoState.video)
const onEmojiButtonPress = useCallback(() => { const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition()) openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -654,9 +685,7 @@ export const ComposePost = ({
size="small" size="small"
style={[a.rounded_full, a.py_sm]} style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()} onPress={() => onPressPublish()}
disabled={ disabled={videoState.status !== 'idle' && publishOnUpload}>
videoUploadState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}> <ButtonText style={[a.text_md]}>
{replyTo ? ( {replyTo ? (
<Trans context="action">Reply</Trans> <Trans context="action">Reply</Trans>
@@ -692,9 +721,9 @@ export const ComposePost = ({
)} )}
<ErrorBanner <ErrorBanner
error={error} error={error}
videoUploadState={videoUploadState} videoState={videoState}
clearError={() => setError('')} clearError={() => setError('')}
videoUploadDispatch={videoUploadDispatch} clearVideo={clearVideo}
/> />
</Animated.View> </Animated.View>
<Animated.ScrollView <Animated.ScrollView
@@ -758,17 +787,17 @@ export const ComposePost = ({
style={[a.w_full, a.mt_lg]} style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)} entering={native(ZoomIn)}
exiting={native(ZoomOut)}> exiting={native(ZoomOut)}>
{videoUploadState.asset && {videoState.asset &&
(videoUploadState.status === 'compressing' ? ( (videoState.status === 'compressing' ? (
<VideoTranscodeProgress <VideoTranscodeProgress
asset={videoUploadState.asset} asset={videoState.asset}
progress={videoUploadState.progress} progress={videoState.progress}
clear={clearVideo} clear={clearVideo}
/> />
) : videoUploadState.video ? ( ) : videoState.video ? (
<VideoPreview <VideoPreview
asset={videoUploadState.asset} asset={videoState.asset}
video={videoUploadState.video} video={videoState.video}
setDimensions={updateVideoDimensions} setDimensions={updateVideoDimensions}
clear={clearVideo} clear={clearVideo}
/> />
@@ -814,9 +843,8 @@ export const ComposePost = ({
t.atoms.border_contrast_medium, t.atoms.border_contrast_medium,
styles.bottomBar, styles.bottomBar,
]}> ]}>
{videoUploadState.status !== 'idle' && {videoState.status !== 'idle' && videoState.status !== 'done' ? (
videoUploadState.status !== 'done' ? ( <VideoUploadToolbar state={videoState} />
<VideoUploadToolbar state={videoUploadState} />
) : ( ) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn <SelectPhotoBtn
@@ -826,7 +854,7 @@ export const ComposePost = ({
/> />
<SelectVideoBtn <SelectVideoBtn
onSelectVideo={selectVideo} onSelectVideo={selectVideo}
disabled={!canSelectImages} disabled={!canSelectImages || images?.length > 0}
setError={setError} setError={setError}
/> />
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} /> <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
@@ -1081,27 +1109,27 @@ const styles = StyleSheet.create({
function ErrorBanner({ function ErrorBanner({
error: standardError, error: standardError,
videoUploadState, videoState,
clearError, clearError,
videoUploadDispatch, clearVideo,
}: { }: {
error: string error: string
videoUploadState: VideoUploadState videoState: VideoState | NoVideoState
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 videoState.status === 'error' ? videoState.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()
} }
} }
@@ -1136,7 +1164,7 @@ function ErrorBanner({
<ButtonIcon icon={X} /> <ButtonIcon icon={X} />
</Button> </Button>
</View> </View>
{videoError && videoUploadState.jobStatus?.jobId && ( {videoError && videoState.jobId && (
<NewText <NewText
style={[ style={[
{paddingLeft: 28}, {paddingLeft: 28},
@@ -1145,7 +1173,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: {videoState.jobId}</Trans>
</NewText> </NewText>
)} )}
</View> </View>
@@ -1171,12 +1199,10 @@ function ToolbarWrapper({
) )
} }
function VideoUploadToolbar({state}: {state: VideoUploadState}) { function VideoUploadToolbar({state}: {state: VideoState}) {
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
@@ -1212,16 +1238,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]}>
@@ -1229,7 +1254,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>
+1 -1
View File
@@ -21,7 +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 {ComposerAction} from '../state' import {ComposerAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog' import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog' import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -1,5 +1,8 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {ComposerImage, createInitialImages} from '#/state/gallery' import {ComposerImage, createInitialImages} from '#/state/gallery'
import {ComposerOpts} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer'
import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
type PostRecord = { type PostRecord = {
uri: string uri: string
@@ -11,11 +14,16 @@ type ImagesMedia = {
labels: string[] labels: string[]
} }
type VideoMedia = {
type: 'video'
video: VideoState
}
type ComposerEmbed = { type ComposerEmbed = {
// TODO: Other record types. // TODO: Other record types.
record: PostRecord | undefined record: PostRecord | undefined
// TODO: Other media types. // TODO: Other media types.
media: ImagesMedia | undefined media: ImagesMedia | VideoMedia | undefined
} }
export type ComposerState = { export type ComposerState = {
@@ -27,6 +35,13 @@ export type ComposerAction =
| {type: 'embed_add_images'; images: ComposerImage[]} | {type: 'embed_add_images'; images: ComposerImage[]}
| {type: 'embed_update_image'; image: ComposerImage} | {type: 'embed_update_image'; image: ComposerImage}
| {type: 'embed_remove_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 const MAX_IMAGES = 4
@@ -36,6 +51,9 @@ export function composerReducer(
): ComposerState { ): ComposerState {
switch (action.type) { switch (action.type) {
case 'embed_add_images': { case 'embed_add_images': {
if (action.images.length === 0) {
return state
}
const prevMedia = state.embed.media const prevMedia = state.embed.media
let nextMedia = prevMedia let nextMedia = prevMedia
if (!prevMedia) { if (!prevMedia) {
@@ -104,6 +122,55 @@ export function composerReducer(
} }
return state 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: default:
return state return state
} }
@@ -122,6 +189,7 @@ export function createComposerState({
labels: [], labels: [],
} }
} }
// TODO: initial video.
return { return {
embed: { embed: {
record: undefined, record: undefined,
+406
View File
@@ -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! Were gradually giving access to video, and youre still waiting in line. Check back soon!`,
)
case "Failed to get user's upload stats":
return _(
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
)
case 'User has exceeded daily upload bytes limit':
return _(
msg`You've reached your daily limit for video uploads (too many bytes)`,
)
case 'User has exceeded daily upload videos limit':
return _(
msg`You've reached your daily limit for video uploads (too many videos)`,
)
case 'Account is not old enough to upload videos':
return _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
default:
return e.message
}
}
return _(msg`An error occurred while uploading the video.`)
}
+36 -11
View File
@@ -9,12 +9,14 @@ import {
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {BSKY_SERVICE} from '#/lib/constants'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {getHostnameFromUrl} from '#/lib/strings/url-helpers'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {BSKY_SERVICE} from 'lib/constants'
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
@@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
UIImagePickerPreferredAssetRepresentationMode.Current, UIImagePickerPreferredAssetRepresentationMode.Current,
}) })
if (response.assets && response.assets.length > 0) { if (response.assets && response.assets.length > 0) {
if (isNative) { const asset = response.assets[0]
if (typeof response.assets[0].duration !== 'number')
throw Error('Asset is not a video')
if (response.assets[0].duration > VIDEO_MAX_DURATION) {
setError(_(msg`Videos must be less than 60 seconds long`))
return
}
}
try { try {
onSelectVideo(response.assets[0]) if (isWeb) {
// compression step on native converts to mp4, so no need to check there
const mimeType = getMimeType(asset)
if (
!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
) {
throw Error(_(msg`Unsupported video type: ${mimeType}`))
}
} else {
if (typeof asset.duration !== 'number') {
throw Error('Asset is not a video')
}
if (asset.duration > VIDEO_MAX_DURATION) {
throw Error(_(msg`Videos must be less than 60 seconds long`))
}
}
onSelectVideo(asset)
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message) setError(err.message)
@@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
/> />
) )
} }
function getMimeType(asset: ImagePickerAsset) {
if (isWeb) {
const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
if (!mimeType) {
throw new Error('Could not determine mime type')
}
return mimeType
}
if (!asset.mimeType) {
throw new Error('Could not determine mime type')
}
return asset.mimeType
}
@@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale' import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useHapticsDisabled} from '#/state/preferences' import {useHapticsDisabled} from '#/state/preferences'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar' 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' import {Text} from '#/components/Typography'
export function PostThreadComposePrompt({ export function PostThreadComposePrompt({
@@ -21,10 +21,15 @@ export function PostThreadComposePrompt({
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {_} = useLingui() const {_} = useLingui()
const {isTabletOrDesktop} = useWebMediaQueries() const {gtMobile} = useBreakpoints()
const t = useTheme() const t = useTheme()
const playHaptics = useHaptics() const playHaptics = useHaptics()
const isHapticsDisabled = useHapticsDisabled() const isHapticsDisabled = useHapticsDisabled()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const onPress = () => { const onPress = () => {
playHaptics('Light') playHaptics('Light')
@@ -42,13 +47,15 @@ export function PostThreadComposePrompt({
accessibilityLabel={_(msg`Compose reply`)} accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)} accessibilityHint={_(msg`Opens composer`)}
style={[ style={[
{paddingTop: 8, paddingBottom: isTabletOrDesktop ? 8 : 11}, gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
a.px_sm, gtMobile ? {paddingLeft: 6, paddingRight: 6} : a.px_sm,
a.border_t, a.border_t,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
t.atoms.bg, t.atoms.bg,
]} ]}
onPress={onPress}> onPress={onPress}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
<View <View
style={[ style={[
a.flex_row, a.flex_row,
@@ -56,10 +63,11 @@ export function PostThreadComposePrompt({
a.p_sm, a.p_sm,
a.gap_sm, a.gap_sm,
a.rounded_full, a.rounded_full,
t.atoms.bg_contrast_25, (!gtMobile || hovered) && t.atoms.bg_contrast_25,
a.transition_color,
]}> ]}>
<UserAvatar <UserAvatar
size={22} size={gtMobile ? 24 : 22}
avatar={profile?.avatar} avatar={profile?.avatar}
type={profile?.associated?.labeler ? 'labeler' : 'user'} type={profile?.associated?.labeler ? 'labeler' : 'user'}
/> />
@@ -22,6 +22,7 @@ import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {useTheme} from '#/lib/ThemeContext' import {useTheme} from '#/lib/ThemeContext'
@@ -350,6 +351,7 @@ let PostDropdownBtn = ({
]) ])
const onPressPin = useCallback(() => { const onPressPin = useCallback(() => {
logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({ pinPostMutate({
postUri, postUri,
postCid, postCid,