From 5a98ea684d90526e80b8b3ef7554452c4f8db53b Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 1 Oct 2024 15:57:49 -0500 Subject: [PATCH 01/10] Include ghost hook to statically export fonts (#5553) --- src/alf/fonts.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts index ae2aaa97a0..54fe7a34e0 100644 --- a/src/alf/fonts.ts +++ b/src/alf/fonts.ts @@ -1,3 +1,5 @@ +import {useFonts} from 'expo-font' + import {isWeb} from '#/platform/detection' import {Device, device} from '#/storage' @@ -63,3 +65,15 @@ export function applyFonts( */ style.fontVariant = ['no-contextual'] } + +/* + * IMPORTANT: This is unused. Expo statically extracts these fonts. + * + * All used fonts MUST be configured here. Unused fonts can be commented out. + */ +export function DO_NOT_USE() { + return useFonts({ + InterVariable: require('../../assets/fonts/inter/InterVariable.ttf'), + 'InterVariable-Italic': require('../../assets/fonts/inter/InterVariable-Italic.ttf'), + }) +} From f251a43dd6418bbd99fbcef3d405798b5308ab89 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 2 Oct 2024 10:54:25 -0500 Subject: [PATCH 02/10] Add intent/verify-email to go server (#5554) --- bskyweb/cmd/bskyweb/server.go | 1 + 1 file changed, 1 insertion(+) 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) From 35698731232ec43571818c61e17ed76765a55ee5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 2 Oct 2024 10:54:51 -0500 Subject: [PATCH 03/10] Tweak verify email dialog (#5555) * Add intent/verify-email to go server * Tweak verify intent dialog --- .../intents/VerifyEmailIntentDialog.tsx | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx index e8c63af82f..90fb2debe9 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} + + )} + + ) } From 1a7885b9215ec48f064de990e9a97070206996b8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 2 Oct 2024 10:55:06 -0500 Subject: [PATCH 04/10] Add suspense boundary in onboarding (#5556) --- .../StepProfile/PlaceholderCanvas.tsx | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx index eaad2113f8..acc0893350 100644 --- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -46,29 +46,31 @@ export const PlaceholderCanvas = React.forwardRef( return ( - - - - - + + + + + + + ) }, From a444fc53a0ec4e575987d7d689df030878a24021 Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 2 Oct 2024 09:09:54 -0700 Subject: [PATCH 05/10] import order (#5571) --- src/components/intents/VerifyEmailIntentDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx index 90fb2debe9..c78aabb6d4 100644 --- a/src/components/intents/VerifyEmailIntentDialog.tsx +++ b/src/components/intents/VerifyEmailIntentDialog.tsx @@ -5,8 +5,8 @@ import {useLingui} from '@lingui/react' 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 {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' From 3972d3d4c3697ab89c054a82b1d03aff96cfce07 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 2 Oct 2024 11:18:15 -0500 Subject: [PATCH 06/10] Ignore built files (#5572) --- .prettierignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 405966830ccdbee6152037eebb76c4815ff5526c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Oct 2024 19:40:18 +0300 Subject: [PATCH 07/10] Make reply prompt more subtle on desktop (#5569) * make reply prompt more subtle on desktop * fix alignment * Tweak transition timing --------- Co-authored-by: Eric Bailey --- src/alf/atoms.ts | 28 +++++++++++++++++++ .../post-thread/PostThreadComposePrompt.tsx | 24 ++++++++++------ 2 files changed, 44 insertions(+), 8 deletions(-) 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/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}> From 13c9c79aeec77edc33b1a926843b005c14acccc7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Oct 2024 22:21:59 +0300 Subject: [PATCH 08/10] move files around (#5576) --- src/Navigation.tsx | 2 +- .../Messages/{List/index.tsx => ChatList.tsx} | 2 +- .../{Conversation/index.tsx => Conversation.tsx} | 14 +++++++------- .../{Conversation => components}/ChatDisabled.tsx | 0 .../Messages/{List => components}/ChatListItem.tsx | 0 .../{Conversation => components}/MessageInput.tsx | 2 +- .../MessageInput.web.tsx | 4 ++-- .../MessageInputEmbed.tsx | 0 .../MessageListError.tsx | 0 .../{Conversation => components}/MessagesList.tsx | 14 +++++++------- 10 files changed, 19 insertions(+), 19 deletions(-) rename src/screens/Messages/{List/index.tsx => ChatList.tsx} (99%) rename src/screens/Messages/{Conversation/index.tsx => Conversation.tsx} (93%) rename src/screens/Messages/{Conversation => components}/ChatDisabled.tsx (100%) rename src/screens/Messages/{List => components}/ChatListItem.tsx (100%) rename src/screens/Messages/{Conversation => components}/MessageInput.tsx (99%) rename src/screens/Messages/{Conversation => components}/MessageInput.web.tsx (98%) rename src/screens/Messages/{Conversation => components}/MessageInputEmbed.tsx (100%) rename src/screens/Messages/{Conversation => components}/MessageListError.tsx (100%) rename src/screens/Messages/{Conversation => components}/MessagesList.tsx (97%) 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/screens/Messages/List/index.tsx b/src/screens/Messages/ChatList.tsx similarity index 99% rename from src/screens/Messages/List/index.tsx rename to src/screens/Messages/ChatList.tsx index efd717f0b4..6cf561d117 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -33,7 +33,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 diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation.tsx similarity index 93% rename from src/screens/Messages/Conversation/index.tsx rename to src/screens/Messages/Conversation.tsx index d14ed160ae..21fdfe0ea9 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -8,16 +8,16 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from '#/lib/routes/types' +import {isWeb} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' +import {ConvoStatus} from '#/state/messages/convo/types' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfileQuery} from '#/state/queries/profile' -import {isWeb} from 'platform/detection' -import {useProfileShadow} from 'state/cache/profile-shadow' -import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo' -import {ConvoStatus} from 'state/messages/convo/types' -import {useSetMinimalShellMode} from 'state/shell' -import {CenteredView} from 'view/com/util/Views' -import {MessagesList} from '#/screens/Messages/Conversation/MessagesList' +import {useSetMinimalShellMode} from '#/state/shell' +import {CenteredView} from '#/view/com/util/Views' +import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' import {MessagesListHeader} from '#/components/dms/MessagesListHeader' diff --git a/src/screens/Messages/Conversation/ChatDisabled.tsx b/src/screens/Messages/components/ChatDisabled.tsx similarity index 100% rename from src/screens/Messages/Conversation/ChatDisabled.tsx rename to src/screens/Messages/components/ChatDisabled.tsx diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx similarity index 100% rename from src/screens/Messages/List/ChatListItem.tsx rename to src/screens/Messages/components/ChatListItem.tsx diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx similarity index 99% rename from src/screens/Messages/Conversation/MessageInput.tsx rename to src/screens/Messages/components/MessageInput.tsx index 674edc41eb..21d6e574ea 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -18,11 +18,11 @@ import Graphemer from 'graphemer' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' +import {isIOS} from '#/platform/detection' import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {isIOS} from 'platform/detection' import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx similarity index 98% rename from src/screens/Messages/Conversation/MessageInput.web.tsx rename to src/screens/Messages/components/MessageInput.web.tsx index 0b7e479209..b15cd24921 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -5,13 +5,13 @@ import {useLingui} from '@lingui/react' import Graphemer from 'graphemer' import TextareaAutosize from 'react-textarea-autosize' +import {isSafari, isTouchDevice} from '#/lib/browser' import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {isSafari, isTouchDevice} from 'lib/browser' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import { Emoji, diff --git a/src/screens/Messages/Conversation/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx similarity index 100% rename from src/screens/Messages/Conversation/MessageInputEmbed.tsx rename to src/screens/Messages/components/MessageInputEmbed.tsx diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/components/MessageListError.tsx similarity index 100% rename from src/screens/Messages/Conversation/MessageListError.tsx rename to src/screens/Messages/components/MessageListError.tsx diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx similarity index 97% rename from src/screens/Messages/Conversation/MessagesList.tsx rename to src/screens/Messages/components/MessagesList.tsx index 3034f02905..b659e98d6e 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -15,6 +15,8 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean import {useSafeAreaInsets} from 'react-native-safe-area-context' import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api' +import {clamp} from '#/lib/numbers' +import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import { convertBskyAppUrlIfNeeded, @@ -22,21 +24,19 @@ import { } from '#/lib/strings/url-helpers' import {logger} from '#/logger' import {isNative} from '#/platform/detection' +import {isWeb} from '#/platform/detection' import {isConvoActive, useConvoActive} from '#/state/messages/convo' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' -import {clamp} from 'lib/numbers' -import {ScrollProvider} from 'lib/ScrollContext' -import {isWeb} from 'platform/detection' import { EmojiPicker, EmojiPickerState, } from '#/view/com/composer/text-input/web/EmojiPicker.web' -import {List} from 'view/com/util/List' -import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled' -import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' -import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' +import {List} from '#/view/com/util/List' +import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' +import {MessageInput} from '#/screens/Messages/components/MessageInput' +import {MessageListError} from '#/screens/Messages/components/MessageListError' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' From c2dac855cc61e05d21b7dbb81f1fef26f1a9e1e7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 2 Oct 2024 22:23:11 +0300 Subject: [PATCH 09/10] Delete messages NUX (#5574) --- src/components/dms/MessagesNUX.tsx | 175 ----------------------------- src/screens/Messages/ChatList.tsx | 4 - 2 files changed, 179 deletions(-) delete mode 100644 src/components/dms/MessagesNUX.tsx 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/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 6cf561d117..9912456e13 100644 --- a/src/screens/Messages/ChatList.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' @@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) { if (conversations.length < 1) { return ( - - {gtMobile ? ( - {!gtMobile && ( Date: Thu, 3 Oct 2024 11:41:23 +0900 Subject: [PATCH 10/10] Refactor video uploads (#5570) * Remove unused video field * Stop exposing video dispatch * Move cancellation out of the reducer * Make useUploadStatusQuery controlled by jobId * Rename SetStatus to SetProcessing This action only has one callsite and it's always passing "processing". * Move jobId into video reducer state * Make cancellation scoped * Inline useCompressVideoMutation * Move processVideo down the file * Extract getErrorMessage * useServiceAuthToken -> getServiceAuthToken * useVideoAgent -> createVideoAgent * useVideoUploadLimits -> getVideoUploadLimits * useUploadVideoMutation -> uploadVideo * Use async/await in processVideo * Inline onVideoCompressed into processVideo * Use async/await for uploadVideo * Factor out error messages * Guard dispatch with signal This lets us remove the scattered signal checks around dispatch. * Move job polling out of RQ * Handle poll failures * Remove unnecessary guards * Slightly more accurate condition * Move initVideoUri handling out of the hook * Remove dead argument It wasn't being used before either. * Remove unused detailed status This isn't being used because we're only respecting that state variable when isProcessing=true, but isProcessing is always false during video upload. If we want to re-add this later, it should really just be derived from the reducer state. * Harden the video reducer * Tie all spawned work to a signal * Preserve asset/media for nicer error state * Rename actions to match states * Inline useUploadVideo This abstraction is getting in the way of some future work. * Move MIME check to the only place that handles it --- src/state/queries/video/compress-video.ts | 39 - src/state/queries/video/util.ts | 11 +- .../queries/video/video-upload.shared.ts | 88 +-- src/state/queries/video/video-upload.ts | 111 +-- src/state/queries/video/video-upload.web.ts | 137 ++-- src/state/queries/video/video.ts | 672 ++++++++++-------- src/view/com/composer/Composer.tsx | 124 ++-- .../com/composer/videos/SelectVideoBtn.tsx | 47 +- 8 files changed, 657 insertions(+), 572 deletions(-) delete mode 100644 src/state/queries/video/compress-video.ts 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/util.ts b/src/state/queries/video/util.ts index 2c1298ab63..87b422c2c9 100644 --- a/src/state/queries/video/util.ts +++ b/src/state/queries/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/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts index 6b633bf213..8c217eadcf 100644 --- a/src/state/queries/video/video-upload.shared.ts +++ b/src/state/queries/video/video-upload.shared.ts @@ -1,73 +1,61 @@ -import {useCallback} from 'react' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' 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' +import {createVideoAgent} from './util' -export function useServiceAuthToken({ +export async function getServiceAuthToken({ + agent, aud, lxm, exp, }: { + agent: BskyAgent 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]) + 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 function useVideoUploadLimits() { - const agent = useVideoAgent() - const getToken = useServiceAuthToken({ +export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { + const token = await getServiceAuthToken({ + agent, 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) + 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 new UploadLimitError( - _( - msg`You have temporarily reached the limit for video uploads. Please try again later.`, - ), - ) + 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 index 170b538901..46f24a58b1 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -1,76 +1,79 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system' -import {AppBskyVideoDefs} from '@atproto/api' +import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {cancelable} from '#/lib/async/cancelable' +import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {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' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' -export const useUploadVideoMutation = ({ - onSuccess, - onError, +export async function uploadVideo({ + video, + agent, + did, setProgress, signal, + _, }: { - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void - onError: (e: any) => void + video: CompressedVideo + agent: BskyAgent + did: string setProgress: (progress: number) => void signal: AbortSignal -}) => { - const {currentAccount} = useSession() - const getToken = useServiceAuthToken({ + _: 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 checkLimits = useVideoUploadLimits() - const {_} = useLingui() + 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), + ) - return useMutation({ - mutationKey: ['video', 'upload'], - mutationFn: cancelable(async (video: CompressedVideo) => { - await checkLimits() + if (signal.aborted) { + throw new AbortError() + } + const res = await uploadTask.uploadAsync() - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did: currentAccount!.did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, - }) + if (!res?.body) { + throw new Error('No response') + } - 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() + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus - if (!res?.body) { - throw new Error('No response') - } + if (!responseBody.jobId) { + throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + } - 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, - }) + if (signal.aborted) { + throw new AbortError() + } + return responseBody } diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index c93e206030..bbae641999 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -1,86 +1,95 @@ import {AppBskyVideoDefs} from '@atproto/api' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' 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 {AbortError} 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' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' -export const useUploadVideoMutation = ({ - onSuccess, - onError, +export async function uploadVideo({ + video, + agent, + did, setProgress, signal, + _, }: { - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void - onError: (e: any) => void + video: CompressedVideo + agent: BskyAgent + did: string setProgress: (progress: number) => void signal: AbortSignal -}) => { - const {currentAccount} = useSession() - const getToken = useServiceAuthToken({ + _: 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 }) - 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)}`, + 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) }) - - let bytes = video.bytes - if (!bytes) { - bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + 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`))) + } } - - 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`)) + 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) + }, + ) - return res - }, signal), - onError, - onSuccess, - }) + 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/video.ts b/src/state/queries/video/video.ts index 0d77935da9..fabee6ad1a 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,12 +1,11 @@ -import React, {useCallback, useEffect} from 'react' import {ImagePickerAsset} from 'expo-image-picker' -import {AppBskyVideoDefs, BlobRef} from '@atproto/api' +import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api' +import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {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 {compressVideo} from '#/lib/media/video/compress' import { ServerError, UploadLimitError, @@ -14,338 +13,409 @@ import { } 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' +import {createVideoAgent} from '#/state/queries/video/util' +import {uploadVideo} from '#/state/queries/video/video-upload' 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} + | {type: 'to_idle'; nextController: AbortController} + | { + type: 'idle_to_compressing' + asset: ImagePickerAsset + signal: AbortSignal + } + | { + 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 + } -export interface State { - status: Status - progress: number - asset?: ImagePickerAsset - video: CompressedVideo | null - jobStatus?: AppBskyVideoDefs.JobStatus - blobRef?: BlobRef - error?: string +type IdleState = { + status: 'idle' + progress: 0 abortController: AbortController - pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} + asset?: undefined + video?: undefined + jobId?: undefined + pendingPublish?: undefined } -export type VideoUploadDispatch = (action: Action) => void +type ErrorState = { + status: 'error' + progress: 100 + abortController: AbortController + asset: ImagePickerAsset | null + video: CompressedVideo | null + jobId: string | null + error: string + pendingPublish?: undefined +} -function reducer(queryClient: QueryClient) { - 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 = { +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 State = + | IdleState + | ErrorState + | CompressingState + | UploadingState + | ProcessingState + | DoneState + +export function createVideoState( + abortController: AbortController = new AbortController(), +): IdleState { + return { + status: 'idle', + progress: 0, + abortController, + } +} + +export function videoReducer(state: State, action: Action): State { + if (action.type === 'to_idle') { + return createVideoState(action.nextController) + } + if (action.signal.aborted || action.signal !== state.abortController.signal) { + // This action is stale and the process that spawned it is no longer relevant. + return state + } + if (action.type === 'to_error') { + return { + status: 'error', + progress: 100, + abortController: state.abortController, + error: action.error, + asset: state.asset ?? null, + video: state.video ?? null, + jobId: state.jobId ?? null, + } + } else if (action.type === 'update_progress') { + if (state.status === 'compressing' || state.status === 'uploading') { + return { ...state, - asset: action.asset, + progress: action.progress, + } + } + } else if (action.type === 'idle_to_compressing') { + if (state.status === 'idle') { + return { status: 'compressing', - error: undefined, + progress: 0, + abortController: state.abortController, + asset: action.asset, } - } else if (action.type === 'SetDimensions') { - updatedState = { + } + } else if (action.type === 'update_dimensions') { + if (state.asset) { + return { ...state, - asset: state.asset - ? {...state.asset, width: action.width, height: action.height} - : undefined, + asset: {...state.asset, width: action.width, height: action.height}, } - } 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 = { + } + } 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, }, - 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], + console.error( + 'Unexpected video action (' + + action.type + + ') while in ' + + state.status + + ' state', ) - - const clearVideo = () => { - dispatch({type: 'Reset'}) - } - - const updateVideoDimensions = useCallback((width: number, height: number) => { - dispatch({ - type: 'SetDimensions', - width, - height, - }) - }, []) - - // Whenever we receive an initial video uri, we should immediately run compression if necessary - useEffect(() => { - if (initialVideoUri) { - selectVideo({uri: initialVideoUri} as ImagePickerAsset) - } - }, [initialVideoUri, selectVideo]) - - return { - state, - dispatch, - selectVideo, - clearVideo, - updateVideoDimensions, - } + return state } -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() +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} - const {error} = useQuery({ - queryKey: ['video', 'upload status', jobId], - queryFn: async () => { - if (!jobId) return // this won't happen, can ignore +export async function processVideo( + asset: ImagePickerAsset, + dispatch: (action: Action) => void, + agent: BskyAgent, + did: string, + signal: AbortSignal, + _: I18n['_'], +) { + dispatch({ + type: 'idle_to_compressing', + asset, + signal, + }) + + let video: CompressedVideo | undefined + try { + video = await compressVideo(asset, { + onProgress: num => { + dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) + }, + signal, + }) + } catch (e) { + const message = getCompressErrorMessage(e, _) + if (message !== null) { + dispatch({ + type: 'to_error', + error: message, + signal, + }) + } + return + } + dispatch({ + type: 'compressing_to_uploading', + video, + signal, + }) + + let uploadResponse: AppBskyVideoDefs.JobStatus | undefined + try { + uploadResponse = await uploadVideo({ + video, + agent, + did, + signal, + _, + setProgress: p => { + dispatch({type: 'update_progress', progress: p, signal}) + }, + }) + } catch (e) { + const message = getUploadErrorMessage(e, _) + if (message !== null) { + dispatch({ + type: 'to_error', + error: message, + signal, + }) + } + return + } + + const jobId = uploadResponse.jobId + dispatch({ + type: 'uploading_to_processing', + jobId, + signal, + }) + + let pollFailures = 0 + while (true) { + if (signal.aborted) { + return // Exit async loop + } + + const videoAgent = createVideoAgent() + let status: JobStatus | undefined + let blob: BlobRef | undefined + try { + const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) + status = response.data.jobStatus + pollFailures = 0 - const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) - const status = data.jobStatus if (status.state === 'JOB_STATE_COMPLETED') { - setEnabled(false) - if (!status.blob) + blob = status.blob + if (!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, - }) + } catch (e) { + if (!status) { + pollFailures++ + if (pollFailures < 50) { + await new Promise(resolve => setTimeout(resolve, 5000)) + continue // Continue async loop + } + } - useEffect(() => { - if (error) { - onError(error) - setEnabled(false) + logger.error('Error processing video', {safeMessage: e}) + dispatch({ + type: 'to_error', + error: _(msg`Video failed to process`), + signal, + }) + return // Exit async loop } - }, [error, onError]) - return { - setJobId: (_jobId: string) => { - setJobId(_jobId) - setEnabled(true) - }, + 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 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 +function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') + if (e instanceof VideoTooLargeError) { + return _(msg`The selected video is larger than 50MB.`) } - return asset.mimeType + logger.error('Error compressing video', {safeMessage: e}) + return _(msg`An error occurred while compressing the video.`) +} + +function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null + } + logger.error('Error uploading video', {safeMessage: e}) + if (e instanceof ServerError || e instanceof UploadLimitError) { + // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 + switch (e.message) { + case 'User is not allowed to upload videos': + return _(msg`You are not allowed to upload videos.`) + case 'Uploading is disabled at the moment': + return _( + msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, + ) + case "Failed to get user's upload stats": + return _( + msg`We were unable to determine if you are allowed to upload videos. Please try again.`, + ) + case 'User has exceeded daily upload bytes limit': + return _( + msg`You've reached your daily limit for video uploads (too many bytes)`, + ) + case 'User has exceeded daily upload videos limit': + return _( + msg`You've reached your daily limit for video uploads (too many videos)`, + ) + case 'Account is not old enough to upload videos': + return _( + msg`Your account is not yet old enough to upload videos. Please try again later.`, + ) + default: + return e.message + } + } + return _(msg`An error occurred while uploading the video.`) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f354f0f0dc..185a57fc35 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, @@ -82,9 +83,10 @@ import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' import { + createVideoState, + processVideo, State as VideoUploadState, - useUploadVideo, - VideoUploadDispatch, + videoReducer, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -147,7 +149,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,21 +192,50 @@ 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) - } + const [videoUploadState, videoDispatch] = useReducer( + videoReducer, + undefined, + createVideoState, + ) + + const selectVideo = React.useCallback( + (asset: ImagePickerAsset) => { + processVideo( + asset, + videoDispatch, + agent, + currentDid, + videoUploadState.abortController.signal, + _, + ) }, - initialVideoUri: initVideoUri, - }) + [_, videoUploadState.abortController, videoDispatch, agent, currentDid], + ) + + // Whenever we receive an initial video uri, we should immediately run compression if necessary + useEffect(() => { + if (initVideoUri) { + selectVideo({uri: initVideoUri} as ImagePickerAsset) + } + }, [initVideoUri, selectVideo]) + + const clearVideo = React.useCallback(() => { + videoUploadState.abortController.abort() + videoDispatch({type: 'to_idle', nextController: new AbortController()}) + }, [videoUploadState.abortController, videoDispatch]) + + const updateVideoDimensions = useCallback( + (width: number, height: number) => { + videoDispatch({ + type: 'update_dimensions', + width, + height, + signal: videoUploadState.abortController.signal, + }) + }, + [videoUploadState.abortController], + ) + const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -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: + videoUploadState.status === 'done' + ? { + blobRef: videoUploadState.pendingPublish.blobRef, + altText: videoAltText, + captions: captions, + aspectRatio: { + width: videoUploadState.asset.width, + height: videoUploadState.asset.height, + }, + } + : undefined, }) ).uri try { @@ -694,7 +725,7 @@ export const ComposePost = ({ error={error} videoUploadState={videoUploadState} clearError={() => setError('')} - videoUploadDispatch={videoUploadDispatch} + clearVideo={clearVideo} /> void - videoUploadDispatch: VideoUploadDispatch + clearVideo: () => void }) { const t = useTheme() const {_} = useLingui() const videoError = - videoUploadState.status !== 'idle' ? videoUploadState.error : undefined + videoUploadState.status === 'error' ? videoUploadState.error : undefined const error = standardError || videoError const onClearError = () => { if (standardError) { clearError() } else { - videoUploadDispatch({type: 'Reset'}) + clearVideo() } } @@ -1136,7 +1167,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobStatus?.jobId && ( + {videoError && videoUploadState.jobId && ( - Job ID: {videoUploadState.jobStatus.jobId} + Job ID: {videoUploadState.jobId} )} @@ -1174,9 +1205,7 @@ function ToolbarWrapper({ function VideoUploadToolbar({state}: {state: VideoUploadState}) { 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 +1241,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 +1257,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/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 +}