From f251a43dd6418bbd99fbcef3d405798b5308ab89 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 2 Oct 2024 10:54:25 -0500 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 +} From 03704e2b48e6cdc348ce7277f2bcae0c61519d1e Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 14:26:38 +0900 Subject: [PATCH 10/12] Manage video reducer from composer reducer (#5573) * Move video state into composer state * Represent video as embed This is slightly broken. In particular, we can't remove video yet because there's no action that results in video embed being removed. * Properly represent video as embed This aligns the video state lifetime with the embed lifetime. Video can now be properly added and removed. * Disable Add Video when we have images * Ignore empty image pick --- src/state/queries/video/video.ts | 67 +++++++++++--------------- src/view/com/composer/Composer.tsx | 49 +++++++++++-------- src/view/com/composer/state.ts | 75 +++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 62 deletions(-) diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index fabee6ad1a..dbbb6c2026 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -16,13 +16,7 @@ import {logger} from '#/logger' import {createVideoAgent} from '#/state/queries/video/util' import {uploadVideo} from '#/state/queries/video/video-upload' -type Action = - | {type: 'to_idle'; nextController: AbortController} - | { - type: 'idle_to_compressing' - asset: ImagePickerAsset - signal: AbortSignal - } +export type VideoAction = | { type: 'compressing_to_uploading' video: CompressedVideo @@ -52,15 +46,20 @@ type Action = signal: AbortSignal } -type IdleState = { - status: 'idle' - progress: 0 - abortController: AbortController - asset?: undefined - video?: undefined - jobId?: undefined - pendingPublish?: undefined -} +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' @@ -114,8 +113,7 @@ type DoneState = { pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} } -export type State = - | IdleState +export type VideoState = | ErrorState | CompressingState | UploadingState @@ -123,19 +121,21 @@ export type State = | DoneState export function createVideoState( - abortController: AbortController = new AbortController(), -): IdleState { + asset: ImagePickerAsset, + abortController: AbortController, +): CompressingState { return { - status: 'idle', + status: 'compressing', progress: 0, abortController, + asset, } } -export function videoReducer(state: State, action: Action): State { - if (action.type === 'to_idle') { - return createVideoState(action.nextController) - } +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 @@ -157,15 +157,6 @@ export function videoReducer(state: State, action: Action): State { progress: action.progress, } } - } else if (action.type === 'idle_to_compressing') { - if (state.status === 'idle') { - return { - status: 'compressing', - progress: 0, - abortController: state.abortController, - asset: action.asset, - } - } } else if (action.type === 'update_dimensions') { if (state.asset) { return { @@ -238,18 +229,12 @@ function trunc2dp(num: number) { export async function processVideo( asset: ImagePickerAsset, - dispatch: (action: Action) => void, + dispatch: (action: VideoAction) => void, agent: BskyAgent, did: string, signal: AbortSignal, _: I18n['_'], ) { - dispatch({ - type: 'idle_to_compressing', - asset, - signal, - }) - let video: CompressedVideo | undefined try { video = await compressVideo(asset, { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 185a57fc35..59aae29516 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,11 +82,12 @@ 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 {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' import { - createVideoState, processVideo, - State as VideoUploadState, - videoReducer, + VideoAction, + VideoState, + VideoState as VideoUploadState, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -192,24 +193,38 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - const [videoUploadState, videoDispatch] = useReducer( - videoReducer, - undefined, - createVideoState, + // TODO: Move more state here. + const [composerState, dispatch] = useReducer( + composerReducer, + {initImageUris}, + createComposerState, + ) + + let videoUploadState: VideoState | NoVideoState = NO_VIDEO + if (composerState.embed.media?.type === 'video') { + videoUploadState = composerState.embed.media.video + } + const videoDispatch = useCallback( + (videoAction: VideoAction) => { + dispatch({type: 'embed_update_video', videoAction}) + }, + [dispatch], ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { + const abortController = new AbortController() + dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, videoDispatch, agent, currentDid, - videoUploadState.abortController.signal, + abortController.signal, _, ) }, - [_, videoUploadState.abortController, videoDispatch, agent, currentDid], + [_, videoDispatch, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -221,8 +236,8 @@ export const ComposePost = ({ const clearVideo = React.useCallback(() => { videoUploadState.abortController.abort() - videoDispatch({type: 'to_idle', nextController: new AbortController()}) - }, [videoUploadState.abortController, videoDispatch]) + dispatch({type: 'embed_remove_video'}) + }, [videoUploadState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { @@ -233,7 +248,7 @@ export const ComposePost = ({ signal: videoUploadState.abortController.signal, }) }, - [videoUploadState.abortController], + [videoUploadState.abortController, videoDispatch], ) const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) @@ -249,12 +264,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 @@ -857,7 +866,7 @@ export const ComposePost = ({ /> 0} setError={setError} /> @@ -1117,7 +1126,7 @@ function ErrorBanner({ clearVideo, }: { error: string - videoUploadState: VideoUploadState + videoUploadState: VideoUploadState | NoVideoState clearError: () => void clearVideo: () => void }) { diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state.ts index 5588de1aa3..8e974ad7a5 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state.ts @@ -1,4 +1,12 @@ +import {ImagePickerAsset} from 'expo-image-picker' + import {ComposerImage, createInitialImages} from '#/state/gallery' +import { + createVideoState, + VideoAction, + videoReducer, + VideoState, +} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' type PostRecord = { @@ -11,11 +19,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 +40,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 +56,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 +127,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 +194,7 @@ export function createComposerState({ labels: [], } } + // TODO: initial video. return { embed: { record: undefined, From 2aa365b5b6fed920d17d307252a7af7c52b95855 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 14:57:48 +0900 Subject: [PATCH 11/12] Rename some files and variables (#5587) * Move composer reducers together * videoUploadState -> videoState * Inline videoDispatch --- src/lib/api/index.ts | 2 +- src/lib/media/video/compress.ts | 2 +- .../media/video/upload.shared.ts} | 0 .../media/video/upload.ts} | 4 +- .../media/video/upload.web.ts} | 4 +- .../queries => lib/media}/video/util.ts | 0 src/view/com/composer/Composer.tsx | 120 ++++++++---------- src/view/com/composer/photos/Gallery.tsx | 2 +- .../composer/{state.ts => state/composer.ts} | 7 +- .../com/composer/state}/video.ts | 4 +- 10 files changed, 64 insertions(+), 81 deletions(-) rename src/{state/queries/video/video-upload.shared.ts => lib/media/video/upload.shared.ts} (100%) rename src/{state/queries/video/video-upload.ts => lib/media/video/upload.ts} (92%) rename src/{state/queries/video/video-upload.web.ts => lib/media/video/upload.web.ts} (93%) rename src/{state/queries => lib/media}/video/util.ts (100%) rename src/view/com/composer/{state.ts => state/composer.ts} (97%) rename src/{state/queries/video => view/com/composer/state}/video.ts (98%) 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/state/queries/video/video-upload.shared.ts b/src/lib/media/video/upload.shared.ts similarity index 100% rename from src/state/queries/video/video-upload.shared.ts rename to src/lib/media/video/upload.shared.ts diff --git a/src/state/queries/video/video-upload.ts b/src/lib/media/video/upload.ts similarity index 92% rename from src/state/queries/video/video-upload.ts rename to src/lib/media/video/upload.ts index 46f24a58b1..3330370b3e 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/lib/media/video/upload.ts @@ -7,8 +7,8 @@ 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 '#/state/queries/video/util' -import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' export async function uploadVideo({ video, diff --git a/src/state/queries/video/video-upload.web.ts b/src/lib/media/video/upload.web.ts similarity index 93% rename from src/state/queries/video/video-upload.web.ts rename to src/lib/media/video/upload.web.ts index bbae641999..ec65f96c97 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -7,8 +7,8 @@ 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 '#/state/queries/video/util' -import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' export async function uploadVideo({ video, diff --git a/src/state/queries/video/util.ts b/src/lib/media/video/util.ts similarity index 100% rename from src/state/queries/video/util.ts rename to src/lib/media/video/util.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 59aae29516..f4e290ca8d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,13 +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 {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' -import { - processVideo, - VideoAction, - VideoState, - VideoState as VideoUploadState, -} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer' @@ -123,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 @@ -200,16 +194,10 @@ export const ComposePost = ({ createComposerState, ) - let videoUploadState: VideoState | NoVideoState = NO_VIDEO + let videoState: VideoState | NoVideoState = NO_VIDEO if (composerState.embed.media?.type === 'video') { - videoUploadState = composerState.embed.media.video + videoState = composerState.embed.media.video } - const videoDispatch = useCallback( - (videoAction: VideoAction) => { - dispatch({type: 'embed_update_video', videoAction}) - }, - [dispatch], - ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { @@ -217,14 +205,14 @@ export const ComposePost = ({ dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, - videoDispatch, + videoAction => dispatch({type: 'embed_update_video', videoAction}), agent, currentDid, abortController.signal, _, ) }, - [_, videoDispatch, agent, currentDid], + [_, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -235,23 +223,26 @@ export const ComposePost = ({ }, [initVideoUri, selectVideo]) const clearVideo = React.useCallback(() => { - videoUploadState.abortController.abort() + videoState.abortController.abort() dispatch({type: 'embed_remove_video'}) - }, [videoUploadState.abortController, dispatch]) + }, [videoState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { - videoDispatch({ - type: 'update_dimensions', - width, - height, - signal: videoUploadState.abortController.signal, + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_dimensions', + width, + height, + signal: videoState.abortController.signal, + }, }) }, - [videoUploadState.abortController, videoDispatch], + [videoState.abortController], ) - const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) + const hasVideo = Boolean(videoState.asset || videoState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -288,7 +279,7 @@ export const ComposePost = ({ graphemeLength > 0 || images.length !== 0 || extGif || - videoUploadState.status !== 'idle' + videoState.status !== 'idle' ) { closeAllDialogs() Keyboard.dismiss() @@ -303,7 +294,7 @@ export const ComposePost = ({ closeAllDialogs, discardPromptControl, onClose, - videoUploadState.status, + videoState.status, ]) useImperativeHandle(cancelRef, () => ({onPressCancel})) @@ -400,8 +391,8 @@ export const ComposePost = ({ if ( !finishedUploading && - videoUploadState.asset && - videoUploadState.status !== 'done' + videoState.asset && + videoState.status !== 'done' ) { setPublishOnUpload(true) return @@ -414,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 @@ -442,14 +433,14 @@ export const ComposePost = ({ onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), video: - videoUploadState.status === 'done' + videoState.status === 'done' ? { - blobRef: videoUploadState.pendingPublish.blobRef, + blobRef: videoState.pendingPublish.blobRef, altText: videoAltText, captions: captions, aspectRatio: { - width: videoUploadState.asset.width, - height: videoUploadState.asset.height, + width: videoState.asset.width, + height: videoState.asset.height, }, } : undefined, @@ -550,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, @@ -576,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()) @@ -694,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 @@ -732,7 +721,7 @@ export const ComposePost = ({ )} setError('')} clearVideo={clearVideo} /> @@ -798,17 +787,17 @@ export const ComposePost = ({ style={[a.w_full, a.mt_lg]} entering={native(ZoomIn)} exiting={native(ZoomOut)}> - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( + {videoState.asset && + (videoState.status === 'compressing' ? ( - ) : videoUploadState.video ? ( + ) : videoState.video ? ( @@ -854,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' ? ( + ) : ( void clearVideo: () => void }) { @@ -1134,7 +1122,7 @@ function ErrorBanner({ const {_} = useLingui() const videoError = - videoUploadState.status === 'error' ? videoUploadState.error : undefined + videoState.status === 'error' ? videoState.error : undefined const error = standardError || videoError const onClearError = () => { @@ -1176,7 +1164,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobId && ( + {videoError && videoState.jobId && ( - Job ID: {videoUploadState.jobId} + Job ID: {videoState.jobId} )} @@ -1211,7 +1199,7 @@ function ToolbarWrapper({ ) } -function VideoUploadToolbar({state}: {state: VideoUploadState}) { +function VideoUploadToolbar({state}: {state: VideoState}) { const t = useTheme() const {_} = useLingui() const progress = state.progress 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 97% rename from src/view/com/composer/state.ts rename to src/view/com/composer/state/composer.ts index 8e974ad7a5..a23a5d8c86 100644 --- a/src/view/com/composer/state.ts +++ b/src/view/com/composer/state/composer.ts @@ -1,13 +1,8 @@ import {ImagePickerAsset} from 'expo-image-picker' import {ComposerImage, createInitialImages} from '#/state/gallery' -import { - createVideoState, - VideoAction, - videoReducer, - VideoState, -} from '#/state/queries/video/video' import {ComposerOpts} from '#/state/shell/composer' +import {createVideoState, VideoAction, videoReducer, VideoState} from './video' type PostRecord = { uri: string diff --git a/src/state/queries/video/video.ts b/src/view/com/composer/state/video.ts similarity index 98% rename from src/state/queries/video/video.ts rename to src/view/com/composer/state/video.ts index dbbb6c2026..2695056579 100644 --- a/src/state/queries/video/video.ts +++ b/src/view/com/composer/state/video.ts @@ -4,6 +4,8 @@ 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 { @@ -13,8 +15,6 @@ import { } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {logger} from '#/logger' -import {createVideoAgent} from '#/state/queries/video/util' -import {uploadVideo} from '#/state/queries/video/video-upload' export type VideoAction = | { From 523a9a48c21ac1aade3d703703f0e59ffeac482e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 3 Oct 2024 17:28:13 +0300 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=AA=B5=F0=9F=93=8C=20(#5594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/statsig/events.ts | 2 ++ src/view/com/util/forms/PostDropdownBtn.tsx | 2 ++ 2 files changed, 4 insertions(+) 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/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,