diff --git a/.prettierignore b/.prettierignore index 8ccbae2148..e107e129a4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,6 @@ android ios src/locale/locales lib/react-compiler-runtime +bskyweb/static +coverage +web-build diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 0f49ce5c3a..54c5f075ac 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -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) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 53e8274d54..323f668b79 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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' diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 0c8eb330d7..2625beda27 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -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 diff --git a/src/components/dms/MessagesNUX.tsx b/src/components/dms/MessagesNUX.tsx deleted file mode 100644 index 5c21ee05be..0000000000 --- a/src/components/dms/MessagesNUX.tsx +++ /dev/null @@ -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 ( - - - - - ) -} - -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 ( - - - - - - Direct messages are here! - - - Privately chat with other users. - - - - - - Who can message you? - - - You can change this at any time. - - - - - - - - Everyone - - - - - - Users I follow - - - - - - No one - - - - - - - - - - - - ) -} diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx index e8c63af82f..c78aabb6d4 100644 --- a/src/components/intents/VerifyEmailIntentDialog.tsx +++ b/src/components/intents/VerifyEmailIntentDialog.tsx @@ -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 ( - - + {status === 'loading' ? ( - + ) : status === 'success' ? ( - <> - + + Email Verified - + - Thanks, you have successfully verified your email address. + Thanks, you have successfully verified your email address. You + can close this dialog. - + ) : status === 'failure' ? ( - <> - + + Invalid Verification Code - + The verification code you have provided is invalid. Please make sure that you have used the correct verification link or request a new one. - + ) : ( - <> - + + Email Resent - + We have sent another verification email to{' '} @@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) { . - + )} - {status !== 'loading' ? ( - + + {status === 'failure' && ( + <> + - {status === 'failure' ? ( - - ) : null} - - ) : null} + + )} + + ) } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 51bf51ffff..8b79250042 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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' diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index dec9032a34..c2d1470c63 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -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 diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts new file mode 100644 index 0000000000..8c217eadcf --- /dev/null +++ b/src/lib/media/video/upload.shared.ts @@ -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.`, + ), + ) + } + } +} diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts new file mode 100644 index 0000000000..3330370b3e --- /dev/null +++ b/src/lib/media/video/upload.ts @@ -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 +} diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts new file mode 100644 index 0000000000..ec65f96c97 --- /dev/null +++ b/src/lib/media/video/upload.web.ts @@ -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( + (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 +} diff --git a/src/state/queries/video/util.ts b/src/lib/media/video/util.ts similarity index 86% rename from src/state/queries/video/util.ts rename to src/lib/media/video/util.ts index 2c1298ab63..87b422c2c9 100644 --- a/src/state/queries/video/util.ts +++ b/src/lib/media/video/util.ts @@ -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 & {})) { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index c9bc8fefb2..9a306ee4f4 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -145,6 +145,8 @@ export type LogEvents = { } 'post:mute': {} 'post:unmute': {} + 'post:pin': {} + 'post:unpin': {} 'profile:follow:sampled': { didBecomeMutual: boolean | undefined followeeClout: number | undefined diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/ChatList.tsx similarity index 98% rename from src/screens/Messages/List/index.tsx rename to src/screens/Messages/ChatList.tsx index efd717f0b4..9912456e13 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -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 @@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) { if (conversations.length < 1) { return ( - - {gtMobile ? ( - {!gtMobile && ( ( return ( - - - - - + + + + + + + ) }, diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts deleted file mode 100644 index cefbf94066..0000000000 --- a/src/state/queries/video/compress-video.ts +++ /dev/null @@ -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 -} diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts deleted file mode 100644 index 6b633bf213..0000000000 --- a/src/state/queries/video/video-upload.shared.ts +++ /dev/null @@ -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]) -} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts deleted file mode 100644 index 170b538901..0000000000 --- a/src/state/queries/video/video-upload.ts +++ /dev/null @@ -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, - }) -} diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts deleted file mode 100644 index c93e206030..0000000000 --- a/src/state/queries/video/video-upload.web.ts +++ /dev/null @@ -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( - (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, - }) -} diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts deleted file mode 100644 index 0d77935da9..0000000000 --- a/src/state/queries/video/video.ts +++ /dev/null @@ -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() - - 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 -} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f354f0f0dc..f4e290ca8d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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}> {replyTo ? ( Reply @@ -692,9 +721,9 @@ export const ComposePost = ({ )} setError('')} - videoUploadDispatch={videoUploadDispatch} + clearVideo={clearVideo} /> - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( + {videoState.asset && + (videoState.status === 'compressing' ? ( - ) : videoUploadState.video ? ( + ) : videoState.video ? ( @@ -814,9 +843,8 @@ export const ComposePost = ({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - {videoUploadState.status !== 'idle' && - videoUploadState.status !== 'done' ? ( - + {videoState.status !== 'idle' && videoState.status !== 'done' ? ( + ) : ( 0} setError={setError} /> @@ -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({ - {videoError && videoUploadState.jobStatus?.jobId && ( + {videoError && videoState.jobId && ( - Job ID: {videoUploadState.jobStatus.jobId} + Job ID: {videoState.jobId} )} @@ -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 ( @@ -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} /> diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 5692f3d2c9..5ff7042bc1 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -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' diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state/composer.ts similarity index 63% rename from src/view/com/composer/state.ts rename to src/view/com/composer/state/composer.ts index 5588de1aa3..a23a5d8c86 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state/composer.ts @@ -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, diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts new file mode 100644 index 0000000000..2695056579 --- /dev/null +++ b/src/view/com/composer/state/video.ts @@ -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.`) +} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 2f2b4c3e7a..bbb3d95f2b 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -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 +} diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 67981618e1..5ad4c256dd 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -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}> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index fe6efc02fa..33287564a7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -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,