From aded49f65b09fc06ad07831df527da017e75bcec Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 May 2024 12:01:26 +0100 Subject: [PATCH 01/12] Implement prefersReducedMotion on native (#4039) * implement prefersReducedMotion on native * just take the function from the reanimated source * use patch-package to export internal function --- patches/react-native-reanimated+3.6.0.patch | 30 +++++++++++++++++++++ src/platform/detection.ts | 6 ++--- 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 patches/react-native-reanimated+3.6.0.patch diff --git a/patches/react-native-reanimated+3.6.0.patch b/patches/react-native-reanimated+3.6.0.patch new file mode 100644 index 0000000000..093d83e411 --- /dev/null +++ b/patches/react-native-reanimated+3.6.0.patch @@ -0,0 +1,30 @@ +diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js +index 91e49f4..c10d3fc 100644 +--- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js ++++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js +@@ -45,4 +45,5 @@ export { getUseOfValueInStyleWarning } from './pluginUtils'; + export { withReanimatedTimer, advanceAnimationByTime, advanceAnimationByFrame, setUpTests, getAnimatedStyle } from './jestUtils'; + export { LayoutAnimationConfig } from './component/LayoutAnimationConfig'; + export { startMapper, stopMapper } from './mappers'; ++export { isReducedMotion } from './PlatformChecker'; + //# sourceMappingURL=index.js.map +\ No newline at end of file +diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts +index 96bd913..ad63a09 100644 +--- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts ++++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts +@@ -33,3 +33,4 @@ export type { Adaptable, AdaptTransforms, AnimateProps, AnimatedProps, AnimatedT + export type { AnimatedScrollViewProps } from './component/ScrollView'; + export type { FlatListPropsWithLayout } from './component/FlatList'; + export { startMapper, stopMapper } from './mappers'; ++export { isReducedMotion } from './PlatformChecker'; +diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts +index 096dc05..38fc01d 100644 +--- a/node_modules/react-native-reanimated/src/reanimated2/index.ts ++++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts +@@ -271,3 +271,4 @@ export type { + export type { AnimatedScrollViewProps } from './component/ScrollView'; + export type { FlatListPropsWithLayout } from './component/FlatList'; + export { startMapper, stopMapper } from './mappers'; ++export { isReducedMotion } from './PlatformChecker'; +\ No newline at end of file diff --git a/src/platform/detection.ts b/src/platform/detection.ts index de4dfc07b9..fffde1be2d 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -1,4 +1,5 @@ import {Platform} from 'react-native' +import {isReducedMotion} from 'react-native-reanimated' import {getLocales} from 'expo-localization' import {dedupArray} from 'lib/functions' @@ -20,7 +21,4 @@ export const deviceLocales = dedupArray( .filter(code => typeof code === 'string'), ) as string[] -export const prefersReducedMotion = - isWeb && - // @ts-ignore we know window exists -prf - !global.window.matchMedia('(prefers-reduced-motion: no-preference)')?.matches +export const prefersReducedMotion = isReducedMotion() From 9499d4343db3107ef97243894d7310e3de3daa60 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 16 May 2024 08:09:14 -0700 Subject: [PATCH 02/12] =?UTF-8?q?[=F0=9F=90=B4]=20A=20few=20tweaks=20to=20?= =?UTF-8?q?message=20input=20on=20native=20(#4035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tweaks * only apply the padding on ios * use a hitslop of 10 instead * version bump --- package.json | 2 +- src/screens/Messages/Conversation/MessageInput.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0a52aa97b6..9f1444d9d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.82.0", + "version": "1.83.0", "private": true, "engines": { "node": ">=18" diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 2b3ee45df6..632544723c 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -19,6 +19,7 @@ import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' +import {isIOS} from 'platform/detection' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' @@ -97,13 +98,20 @@ export function MessageInput({ value={message} multiline={true} onChangeText={setMessage} - style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]} + style={[ + a.flex_1, + a.text_md, + a.px_sm, + t.atoms.text, + {maxHeight, paddingBottom: isIOS ? 5 : 0}, + ]} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} scrollEnabled={isInputScrollable} blurOnSubmit={false} onFocus={scrollToEnd} onContentSizeChange={onInputLayout} ref={inputRef} + hitSlop={HITSLOP_10} /> Date: Thu, 16 May 2024 08:17:34 -0700 Subject: [PATCH 03/12] =?UTF-8?q?[=F0=9F=90=B4]=20Disable=20hover=20card?= =?UTF-8?q?=20when=20blocked=20or=20blocking=20(#4041)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProfileHoverCard/index.web.tsx | 6 +++++- src/components/ProfileHoverCard/types.ts | 1 + src/screens/Messages/Conversation/index.tsx | 1 + src/view/com/util/UserAvatar.tsx | 6 ++++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 09a4b397bb..60b83e110d 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -46,7 +46,11 @@ const floatingMiddlewares = [ const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 export function ProfileHoverCard(props: ProfileHoverCardProps) { - return isTouchDevice ? props.children : + if (props.disable || isTouchDevice) { + return props.children + } else { + return + } } type State = diff --git a/src/components/ProfileHoverCard/types.ts b/src/components/ProfileHoverCard/types.ts index a62279c96c..2fa0643838 100644 --- a/src/components/ProfileHoverCard/types.ts +++ b/src/components/ProfileHoverCard/types.ts @@ -4,4 +4,5 @@ export type ProfileHoverCardProps = { children: React.ReactElement did: string inline?: boolean + disable?: boolean } diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index a176d8129d..29019eff56 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -277,6 +277,7 @@ function HeaderReady({ size={32} profile={profile} moderation={moderation.ui('avatar')} + disable={moderation.blocked} /> void profile: AppBskyActorDefs.ProfileViewBasic + disable?: boolean + onBeforePress?: () => void } const BLUR_AMOUNT = isWeb ? 5 : 100 @@ -383,6 +384,7 @@ export {EditableUserAvatar} let PreviewableUserAvatar = ({ moderation, profile, + disable, onBeforePress, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { @@ -395,7 +397,7 @@ let PreviewableUserAvatar = ({ }, [profile, queryClient, onBeforePress]) return ( - + Date: Thu, 16 May 2024 08:37:04 -0700 Subject: [PATCH 04/12] =?UTF-8?q?[=F0=9F=90=B4]=20Copy=20tweaks=20(#4042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `notifications` -> `conversation` * `users` -> `people` * `other participants` -> `the other participant` * rename --- src/components/dms/ConvoMenu.tsx | 8 ++++---- src/components/dms/MessageMenu.tsx | 2 +- src/screens/Messages/Conversation/index.tsx | 2 +- src/screens/Messages/Settings.tsx | 2 +- src/view/com/util/UserAvatar.tsx | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 16e8d98c6c..cf1dbc171e 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -171,13 +171,13 @@ let ConvoMenu = ({ muteConvo({mute: !convo?.muted})}> {convo?.muted ? ( - Unmute notifications + Unmute conversation ) : ( - Mute notifications + Mute conversation )} @@ -222,7 +222,7 @@ let ConvoMenu = ({ control={leaveConvoControl} title={_(msg`Leave conversation`)} description={_( - msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`, + msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`, )} confirmButtonCta={_(msg`Leave`)} confirmButtonColor="negative" diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx index f4645f286b..553e1be538 100644 --- a/src/components/dms/MessageMenu.tsx +++ b/src/components/dms/MessageMenu.tsx @@ -119,7 +119,7 @@ export let MessageMenu = ({ control={deleteControl} title={_(msg`Delete message`)} description={_( - msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`, + msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`, )} confirmButtonCta={_(msg`Delete`)} confirmButtonColor="negative" diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 29019eff56..4a7c4ce9bc 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -277,7 +277,7 @@ function HeaderReady({ size={32} profile={profile} moderation={moderation.ui('avatar')} - disable={moderation.blocked} + disableHoverCard={moderation.blocked} /> void } @@ -384,7 +384,7 @@ export {EditableUserAvatar} let PreviewableUserAvatar = ({ moderation, profile, - disable, + disableHoverCard, onBeforePress, ...rest }: PreviewableUserAvatarProps): React.ReactNode => { @@ -397,7 +397,7 @@ let PreviewableUserAvatar = ({ }, [profile, queryClient, onBeforePress]) return ( - + Date: Thu, 16 May 2024 10:46:20 -0500 Subject: [PATCH 05/12] Handle failed resumeSession case when switching accounts (#4047) --- src/lib/hooks/useAccountSwitcher.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 33d56eb85e..09ff30277f 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -53,6 +53,11 @@ export function useAccountSwitcher() { logger.error(`switch account: selectAccount failed`, { message: e.message, }) + requestSwitchToAccount({requestedAccount: account.did}) + Toast.show( + _(msg`Please sign in as @${account.handle}`), + 'circle-exclamation', + ) } finally { setPendingDid(null) } From d639c40e17fabbe9cb0f578eca838cf60c6463bb Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 16 May 2024 08:52:53 -0700 Subject: [PATCH 06/12] =?UTF-8?q?[=F0=9F=90=B4]=20Tweak=20padding=20of=20w?= =?UTF-8?q?eb=20input=20(#4033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tweak more * nit --- .../Messages/Conversation/MessageInput.web.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx index b9506b5bbf..335322fac1 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -63,7 +63,6 @@ export function MessageInput({ From da2bdf5d6f84ed726fb131f00fac5ef458be6f7e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 16 May 2024 16:57:07 +0100 Subject: [PATCH 07/12] =?UTF-8?q?[=F0=9F=90=B4]=20use=20Toggle=20component?= =?UTF-8?q?=20in=20settings=20screen=20(#4048)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use Toggle component * nits + notifs sounds native only --- src/components/RadioGroup.tsx | 76 -------------------------- src/screens/Messages/Settings.tsx | 88 ++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 102 deletions(-) delete mode 100644 src/components/RadioGroup.tsx diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx deleted file mode 100644 index 010f65bc31..0000000000 --- a/src/components/RadioGroup.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {View, ViewProps} from 'react-native' - -import {atoms as a, useTheme} from '#/alf' -import {Button} from './Button' -import {Text} from './Typography' - -export function RadioGroup({ - value, - onSelect, - items, - ...props -}: ViewProps & { - value: T - onSelect: (value: T) => void - items: Array<{label: string; value: T}> -}) { - return ( - - {items.map(item => ( - - ))} - - ) -} - -function RadioIcon({selected}: {selected: boolean}) { - const t = useTheme() - return ( - - {selected && ( - - )} - - ) -} diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index 84b804b426..a9c35dba79 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -8,6 +8,7 @@ import {UseQueryResult} from '@tanstack/react-query' import {CommonNavigatorParams} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' +import {isNative} from '#/platform/detection' import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -15,8 +16,8 @@ import * as Toast from '#/view/com/util/Toast' import {ViewHeader} from '#/view/com/util/ViewHeader' import {CenteredView} from '#/view/com/util/Views' import {atoms as a} from '#/alf' +import {Divider} from '#/components/Divider' import * as Toggle from '#/components/forms/Toggle' -import {RadioGroup} from '#/components/RadioGroup' import {Text} from '#/components/Typography' import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {ClipClopGate} from './gate' @@ -39,7 +40,9 @@ export function MessagesSettingsScreen({}: Props) { }) const onSelectItem = useCallback( - (key: string) => { + (keys: string[]) => { + const key = keys[0] + if (!key) return updateDeclaration(key as AllowIncoming) }, [updateDeclaration], @@ -48,37 +51,70 @@ export function MessagesSettingsScreen({}: Props) { const gate = useGate() if (!gate('dms')) return + console.log(profile?.associated?.chat?.allowIncoming) + return ( - - + + Allow messages from - - value={ + - - - { - setPref('playSoundChat', !preferences.playSoundChat) - }}> - - Notification Sounds - + onChange={onSelectItem}> + + + + Everyone + + + + + + Users I follow + + + + + + No one + + + + + + {isNative && ( + <> + + { + setPref('playSoundChat', !preferences.playSoundChat) + }}> + + + Play notification sounds + + + + )} ) From b15b49a48f2d8242e31ba5fdde52123fa5e7ff64 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 16 May 2024 09:32:10 -0700 Subject: [PATCH 08/12] =?UTF-8?q?[=F0=9F=90=B4]=20Remove=20keyboard=20cont?= =?UTF-8?q?roller=20lib=20(#4038)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove library * implement using just reanimated * always return false for `keyboardIsOpening` on web * undo comment * handle input focus scroll more elegantly * add back minimal shell toggle on mobile web * adjust initialnumtorender * oops * nit --- package.json | 1 - .../Messages/Conversation/MessageInput.tsx | 3 +- .../Messages/Conversation/MessagesList.tsx | 80 ++++++++++++++----- src/screens/Messages/Conversation/index.tsx | 74 +++++++---------- yarn.lock | 5 -- 5 files changed, 89 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index 9f1444d9d8..6cb83a3e59 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,6 @@ "react-native-get-random-values": "~1.11.0", "react-native-image-crop-picker": "^0.38.1", "react-native-ios-context-menu": "^1.15.3", - "react-native-keyboard-controller": "^1.11.7", "react-native-pager-view": "6.2.3", "react-native-picker-select": "^8.1.0", "react-native-progress": "bluesky-social/react-native-progress", diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 632544723c..d937cc3e1a 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -65,7 +65,7 @@ export function MessageInput({ const keyboardHeight = Keyboard.metrics()?.height ?? 0 const windowHeight = Dimensions.get('window').height - const max = windowHeight - keyboardHeight - topInset - 100 + const max = windowHeight - keyboardHeight - topInset - 150 const availableSpace = max - e.nativeEvent.contentSize.height setMaxHeight(max) @@ -108,7 +108,6 @@ export function MessageInput({ keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} scrollEnabled={isInputScrollable} blurOnSubmit={false} - onFocus={scrollToEnd} onContentSizeChange={onInputLayout} ref={inputRef} hitSlop={HITSLOP_10} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 9c7774e578..ca5d448775 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,12 +1,17 @@ import React, {useCallback, useRef} from 'react' import {FlatList, View} from 'react-native' -import {useKeyboardHandler} from 'react-native-keyboard-controller' -import {runOnJS, useSharedValue} from 'react-native-reanimated' +import Animated, { + useAnimatedKeyboard, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {shortenLinks} from '#/lib/strings/rich-text-manip' -import {isNative} from '#/platform/detection' +import {isIOS, isNative} from '#/platform/detection' import {useConvoActive} from '#/state/messages/convo' import {ConvoItem} from '#/state/messages/convo/types' import {useAgent} from '#/state/session' @@ -15,7 +20,7 @@ import {isWeb} from 'platform/detection' import {List} from 'view/com/util/List' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' -import {atoms as a} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {MessageItem} from '#/components/dms/MessageItem' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -55,6 +60,7 @@ function onScrollToIndexFailed() { } export function MessagesList() { + const t = useTheme() const convo = useConvoActive() const {getAgent} = useAgent() const flatListRef = useRef(null) @@ -74,8 +80,8 @@ export function MessagesList() { // We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not. const isMomentumScrolling = useSharedValue(false) - const hasInitiallyScrolled = useSharedValue(false) + const keyboardIsOpening = useSharedValue(false) // Every time the content size changes, that means one of two things is happening: // 1. New messages are being added from the log or from a message you have sent @@ -101,22 +107,23 @@ export function MessagesList() { contentHeight.value = height // This number _must_ be the height of the MaybeLoader component - if (height <= 50 || !isAtBottom.value) { + if (height <= 50 || (!isAtBottom.value && !keyboardIsOpening.value)) { return } flatListRef.current?.scrollToOffset({ - animated: hasInitiallyScrolled.value, + animated: hasInitiallyScrolled.value && !keyboardIsOpening.value, offset: height, }) isMomentumScrolling.value = true }, [ contentHeight, - hasInitiallyScrolled, + hasInitiallyScrolled.value, isAtBottom.value, isAtTop.value, isMomentumScrolling, + keyboardIsOpening.value, ], ) @@ -187,17 +194,46 @@ export function MessagesList() { }) }, [isMomentumScrolling]) - // This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly. - const scrollToEndNow = React.useCallback(() => { - flatListRef.current?.scrollToEnd({animated: false}) - }, []) + // -- Keyboard animation handling + const animatedKeyboard = useAnimatedKeyboard() + const {gtMobile} = useBreakpoints() + const {bottom: bottomInset} = useSafeAreaInsets() + const nativeBottomBarHeight = isIOS ? 42 : 60 + const bottomOffset = + isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight - useKeyboardHandler({ - onMove: () => { - 'worklet' - runOnJS(scrollToEndNow)() + // We need to keep track of when the keyboard is animating and when it isn't, since we want our `onContentSizeChanged` + // callback to animate the scroll _only_ when the keyboard isn't animating. Any time the previous value of kb height + // is different, we know that it is animating. When it finally settles, now will be equal to prev. + useAnimatedReaction( + () => animatedKeyboard.height.value, + (now, prev) => { + // This never applies on web + if (isWeb) { + keyboardIsOpening.value = false + } else { + keyboardIsOpening.value = now !== prev + } }, - }) + ) + + // This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our + // `onContentSizeChange` function will handle scrolling to the appropriate offset. + const animatedFooterStyle = useAnimatedStyle(() => ({ + marginBottom: + animatedKeyboard.height.value > bottomOffset + ? animatedKeyboard.height.value + : bottomOffset, + })) + + // At a minimum we want the bottom to be whatever the height of our insets and bottom bar is. If the keyboard's height + // is greater than that however, we use that value. + const animatedInputStyle = useAnimatedStyle(() => ({ + bottom: + animatedKeyboard.height.value > bottomOffset + ? animatedKeyboard.height.value + : bottomOffset, + })) return ( <> @@ -211,8 +247,9 @@ export function MessagesList() { containWeb={true} contentContainerStyle={[a.px_md]} disableVirtualization={true} - initialNumToRender={isNative ? 30 : 60} - maxToRenderPerBatch={isWeb ? 30 : 60} + // The extra two items account for the header and the footer components + initialNumToRender={isNative ? 32 : 62} + maxToRenderPerBatch={isWeb ? 32 : 62} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" maintainVisibleContentPosition={{ @@ -227,9 +264,12 @@ export function MessagesList() { ListHeaderComponent={ } + ListFooterComponent={} /> - + + + ) } diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 4a7c4ce9bc..070175d478 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -1,8 +1,5 @@ import React, {useCallback} from 'react' import {TouchableOpacity, View} from 'react-native' -import {KeyboardProvider} from 'react-native-keyboard-controller' -import {KeyboardAvoidingView} from 'react-native-keyboard-controller' -import {useSafeAreaInsets} from 'react-native-safe-area-context' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' @@ -18,7 +15,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfileQuery} from '#/state/queries/profile' import {BACK_HITSLOP} from 'lib/constants' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {isIOS, isNative, isWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo' import {ConvoStatus} from 'state/messages/convo/types' import {useSetMinimalShellMode} from 'state/shell' @@ -39,8 +36,8 @@ type Props = NativeStackScreenProps< > export function MessagesConversationScreen({route}: Props) { const gate = useGate() - const setMinimalShellMode = useSetMinimalShellMode() const {gtMobile} = useBreakpoints() + const setMinimalShellMode = useSetMinimalShellMode() const convoId = route.params.conversation const {setCurrentConvoId} = useCurrentConvoId() @@ -57,7 +54,7 @@ export function MessagesConversationScreen({route}: Props) { setCurrentConvoId(undefined) setMinimalShellMode(false) } - }, [convoId, gtMobile, setCurrentConvoId, setMinimalShellMode]), + }, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]), ) if (!gate('dms')) return @@ -76,9 +73,6 @@ function Inner() { const [hasInitiallyRendered, setHasInitiallyRendered] = React.useState(false) - const {bottom: bottomInset, top: topInset} = useSafeAreaInsets() - const nativeBottomBarHeight = isIOS ? 42 : 60 - // HACK: Because we need to scroll to the bottom of the list once initial items are added to the list, we also have // to take into account that scrolling to the end of the list on native will happen asynchronously. This will cause // a little flicker when the items are first renedered at the top and immediately scrolled to the bottom. to prevent @@ -111,45 +105,33 @@ function Inner() { /* * Any other convo states (atm) are "ready" states */ - return ( - - - -
- - {isConvoActive(convoState) ? ( - - ) : ( - - )} - {!hasInitiallyRendered && ( - - - - - - )} + +
+ + {isConvoActive(convoState) ? ( + + ) : ( + + )} + {!hasInitiallyRendered && ( + + + + - - - + )} + + ) } diff --git a/yarn.lock b/yarn.lock index ca2ae379c2..1e7fd33bcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18496,11 +18496,6 @@ react-native-ios-context-menu@^1.15.3: dependencies: "@dominicstop/ts-event-emitter" "^1.1.0" -react-native-keyboard-controller@^1.11.7: - version "1.11.7" - resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.7.tgz#85640374e4c3627c3b667256a1d308698ff80393" - integrity sha512-K2zlqVyWX4QO7r+dHMQgZT41G2dSEWtDYgBdht1WVyTaMQmwTMalZcHCWBVOnzyGaJq/hMKhF1kSPqJP1xqSFA== - react-native-pager-view@6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775" From ef0ce951e7c95ce3374a3e49db16f72a344ef779 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 16 May 2024 10:40:12 -0700 Subject: [PATCH 09/12] =?UTF-8?q?[=F0=9F=90=B4]=20Only=20scroll=20down=20o?= =?UTF-8?q?ne=20"screen"=20in=20height=20when=20foregrounding=20(#4027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * maintain position after foreground * one possibility * don't overscroll when content size changes. * ignore the rule on 1 item * fix * [🐴] Pill for additional unreads when coming from background (#4043) * create a pill with some animatons * add some basic styles to the pill * make the animations reusable * bit better styling * rm logs --------- Co-authored-by: Samuel Newman * import --------- Co-authored-by: Samuel Newman --- src/components/dms/NewMessagesPill.tsx | 47 ++++++++++++++ src/lib/custom-animations/ScaleAndFade.ts | 39 ++++++++++++ .../Messages/Conversation/MessagesList.tsx | 62 +++++++++++++++---- 3 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 src/components/dms/NewMessagesPill.tsx create mode 100644 src/lib/custom-animations/ScaleAndFade.ts diff --git a/src/components/dms/NewMessagesPill.tsx b/src/components/dms/NewMessagesPill.tsx new file mode 100644 index 0000000000..4a0ba22c92 --- /dev/null +++ b/src/components/dms/NewMessagesPill.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import {View} from 'react-native' +import Animated from 'react-native-reanimated' +import {Trans} from '@lingui/macro' + +import { + ScaleAndFadeIn, + ScaleAndFadeOut, +} from 'lib/custom-animations/ScaleAndFade' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function NewMessagesPill() { + const t = useTheme() + + React.useEffect(() => {}, []) + + return ( + + + + New messages + + + + ) +} diff --git a/src/lib/custom-animations/ScaleAndFade.ts b/src/lib/custom-animations/ScaleAndFade.ts new file mode 100644 index 0000000000..ad2c15f8f6 --- /dev/null +++ b/src/lib/custom-animations/ScaleAndFade.ts @@ -0,0 +1,39 @@ +import {withTiming} from 'react-native-reanimated' + +export function ScaleAndFadeIn() { + 'worklet' + + const animations = { + opacity: withTiming(1), + transform: [{scale: withTiming(1)}], + } + + const initialValues = { + opacity: 0, + transform: [{scale: 0.7}], + } + + return { + animations, + initialValues, + } +} + +export function ScaleAndFadeOut() { + 'worklet' + + const animations = { + opacity: withTiming(0), + transform: [{scale: withTiming(0.7)}], + } + + const initialValues = { + opacity: 1, + transform: [{scale: 1}], + } + + return { + animations, + initialValues, + } +} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index ca5d448775..a8f9d344dc 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useRef} from 'react' import {FlatList, View} from 'react-native' import Animated, { + runOnJS, useAnimatedKeyboard, useAnimatedReaction, useAnimatedStyle, @@ -22,6 +23,7 @@ import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {MessageItem} from '#/components/dms/MessageItem' +import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' @@ -65,6 +67,8 @@ export function MessagesList() { const {getAgent} = useAgent() const flatListRef = useRef(null) + const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false) + // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to // the bottom. @@ -76,12 +80,14 @@ export function MessagesList() { // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing // onStartReached to fire. const contentHeight = useSharedValue(0) + const prevItemCount = useRef(0) // We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not. const isMomentumScrolling = useSharedValue(false) const hasInitiallyScrolled = useSharedValue(false) const keyboardIsOpening = useSharedValue(false) + const layoutHeight = useSharedValue(0) // Every time the content size changes, that means one of two things is happening: // 1. New messages are being added from the log or from a message you have sent @@ -96,7 +102,7 @@ export function MessagesList() { const onContentSizeChange = useCallback( (_: number, height: number) => { // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the - // previous offset whenever we add new content to the previous offset whenever we add new content to the list. + // previous off whenever we add new content to the previous offset whenever we add new content to the list. if (isWeb && isAtTop.value && hasInitiallyScrolled.value) { flatListRef.current?.scrollToOffset({ animated: false, @@ -104,18 +110,31 @@ export function MessagesList() { }) } - contentHeight.value = height - // This number _must_ be the height of the MaybeLoader component - if (height <= 50 || (!isAtBottom.value && !keyboardIsOpening.value)) { - return - } + if (height > 50 && (isAtBottom.value || keyboardIsOpening.value)) { + let newOffset = height - flatListRef.current?.scrollToOffset({ - animated: hasInitiallyScrolled.value && !keyboardIsOpening.value, - offset: height, - }) - isMomentumScrolling.value = true + // If the size of the content is changing by more than the height of the screen, then we should only + // scroll 1 screen down, and let the user scroll the rest. However, because a single message could be + // really large - and the normal chat behavior would be to still scroll to the end if it's only one + // message - we ignore this rule if there's only one additional message + if ( + hasInitiallyScrolled.value && + height - contentHeight.value > layoutHeight.value - 50 && + convo.items.length - prevItemCount.current > 1 + ) { + newOffset = contentHeight.value - 50 + setShowNewMessagesPill(true) + } + + flatListRef.current?.scrollToOffset({ + animated: hasInitiallyScrolled.value && !keyboardIsOpening.value, + offset: newOffset, + }) + isMomentumScrolling.value = true + } + contentHeight.value = height + prevItemCount.current = convo.items.length }, [ contentHeight, @@ -123,6 +142,8 @@ export function MessagesList() { isAtBottom.value, isAtTop.value, isMomentumScrolling, + layoutHeight.value, + convo.items.length, keyboardIsOpening.value, ], ) @@ -163,8 +184,17 @@ export function MessagesList() { const onScroll = React.useCallback( (e: ReanimatedScrollEvent) => { 'worklet' + layoutHeight.value = e.layoutMeasurement.height + const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height + if ( + showNewMessagesPill && + e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset + ) { + runOnJS(setShowNewMessagesPill)(false) + } + // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom // when a new message is added, hence the 100 pixel offset isAtBottom.value = e.contentSize.height - 100 < bottomOffset @@ -177,7 +207,14 @@ export function MessagesList() { hasInitiallyScrolled.value = true } }, - [contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop], + [ + layoutHeight, + showNewMessagesPill, + isAtBottom, + isAtTop, + contentHeight.value, + hasInitiallyScrolled, + ], ) const onMomentumEnd = React.useCallback(() => { @@ -267,6 +304,7 @@ export function MessagesList() { ListFooterComponent={} /> + {showNewMessagesPill && } From dff6bd7c6542b62f1ba8325d2c0520b1665d412b Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 16 May 2024 11:58:45 -0700 Subject: [PATCH 10/12] =?UTF-8?q?[=F0=9F=90=B4]=20infinite=20stale=20time?= =?UTF-8?q?=20(#4051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/state/queries/messages/conversation.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index bd5b746f16..baf69223a5 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -4,6 +4,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useOnMarkAsRead} from '#/state/queries/messages/list-converations' import {useAgent} from '#/state/session' +import {STALE} from 'state/queries' import {RQKEY as LIST_CONVOS_KEY} from './list-converations' const RQKEY_ROOT = 'convo' @@ -22,6 +23,7 @@ export function useConvoQuery(convo: ChatBskyConvoDefs.ConvoView) { return data.convo }, initialData: convo, + staleTime: STALE.INFINITY, }) } From 4bceabc21cacd865f5b10684142485faca2c9bb4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 16 May 2024 14:01:39 -0500 Subject: [PATCH 11/12] =?UTF-8?q?[=F0=9F=90=B4]=20Error=20recovery=20(#403?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Handle block state when sending messages * Handle different pending failures * Use existing profile data to handle blocks * Better cleanup, leave room for more * Attempt recover upon next send * Reset pending failure * Capture unexpected error * Gracefully handle network errors and recovery * Re-align error components and types * Include history fetching in recoverable states --- src/components/dms/MessageItem.tsx | 25 ++- .../Conversation/MessageListError.tsx | 78 ++++---- .../Messages/Conversation/MessagesList.tsx | 2 +- src/state/messages/convo/agent.ts | 180 +++++++++++++----- src/state/messages/convo/const.ts | 4 + src/state/messages/convo/index.tsx | 21 ++ src/state/messages/convo/types.ts | 19 +- 7 files changed, 216 insertions(+), 113 deletions(-) diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index cafd7ca5a5..f456fa4748 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -202,7 +202,7 @@ let MessageItemMetadata = ({ )} - {item.type === 'pending-message' && item.retry && ( + {item.type === 'pending-message' && item.failed && ( <> {' '} ·{' '} @@ -214,15 +214,20 @@ let MessageItemMetadata = ({ }, ]}> {_(msg`Failed to send`)} - {' '} - ·{' '} - - {_(msg`Retry`)} - + + {item.retry && ( + <> + {' '} + ·{' '} + + {_(msg`Retry`)} + + + )} )} diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx index c6e246a3fb..6a6ce5e693 100644 --- a/src/screens/Messages/Conversation/MessageListError.tsx +++ b/src/screens/Messages/Conversation/MessageListError.tsx @@ -5,27 +5,25 @@ import {useLingui} from '@lingui/react' import {ConvoItem, ConvoItemError} from '#/state/messages/convo/types' import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Refresh} from '#/components/icons/ArrowRotateCounterClockwise' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' -export function MessageListError({ - item, -}: { - item: ConvoItem & {type: 'error-recoverable'} -}) { +export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) { const t = useTheme() const {_} = useLingui() - const message = React.useMemo(() => { + const {description, help, cta} = React.useMemo(() => { return { - [ConvoItemError.Network]: _( - msg`There was an issue connecting to the chat.`, - ), - [ConvoItemError.FirehoseFailed]: _( - msg`This chat was disconnected due to a network error.`, - ), - [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), + [ConvoItemError.FirehoseFailed]: { + description: _(msg`This chat was disconnected`), + help: _(msg`Press to attempt reconnection`), + cta: _(msg`Reconnect`), + }, + [ConvoItemError.HistoryFailed]: { + description: _(msg`Failed to load past messages`), + help: _(msg`Press to retry`), + cta: _(msg`Retry`), + }, }[item.code] }, [_, item.code]) @@ -36,37 +34,31 @@ export function MessageListError({ a.flex_row, a.align_center, a.justify_between, - a.gap_lg, - a.py_md, - a.px_lg, - a.rounded_md, - t.atoms.bg_contrast_25, + a.gap_sm, + a.pb_lg, {maxWidth: 400}, ]}> - - - - {message} - - + - + + {description} ·{' '} + {item.retry && ( + { + e.preventDefault() + item.retry?.() + return false + }}> + {cta} + + )} + ) diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index a8f9d344dc..fd9368b493 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -46,7 +46,7 @@ function renderItem({item}: {item: ConvoItem}) { return } else if (item.type === 'deleted-message') { return Deleted message - } else if (item.type === 'error-recoverable') { + } else if (item.type === 'error') { return } diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 94bb8dda44..8673c70adf 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -5,6 +5,8 @@ import { ChatBskyConvoGetLog, ChatBskyConvoSendMessage, } from '@atproto/api' +import {XRPCError} from '@atproto/xrpc' +import EventEmitter from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' import {networkRetry} from '#/lib/async/retry' @@ -14,11 +16,14 @@ import { ACTIVE_POLL_INTERVAL, BACKGROUND_POLL_INTERVAL, INACTIVE_TIMEOUT, + NETWORK_FAILURE_STATUSES, } from '#/state/messages/convo/const' import { ConvoDispatch, ConvoDispatchEvent, + ConvoError, ConvoErrorCode, + ConvoEvent, ConvoItem, ConvoItemError, ConvoParams, @@ -51,13 +56,7 @@ export class Convo { private senderUserDid: string private status: ConvoStatus = ConvoStatus.Uninitialized - private error: - | { - code: ConvoErrorCode - exception?: Error - retry: () => void - } - | undefined + private error: ConvoError | undefined private oldestRev: string | undefined | null = undefined private isFetchingHistory = false private latestRev: string | undefined = undefined @@ -75,13 +74,13 @@ export class Convo { {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} > = new Map() private deletedMessages: Set = new Set() - private footerItems: Map = new Map() - private headerItems: Map = new Map() private isProcessingPendingMessages = false private lastActiveTimestamp: number | undefined + private emitter = new EventEmitter<{event: [ConvoEvent]}>() + convoId: string convo: ChatBskyConvoDefs.ConvoView | undefined sender: AppBskyActorDefs.ProfileViewBasic | undefined @@ -174,7 +173,7 @@ export class Convo { status: ConvoStatus.Error, items: [], convo: undefined, - error: this.error, + error: this.error!, sender: undefined, recipients: undefined, isFetchingHistory: false, @@ -282,6 +281,7 @@ export class Convo { if (this.convo) { this.status = ConvoStatus.Ready this.refreshConvo() + this.maybeRecoverFromNetworkError() } else { this.status = ConvoStatus.Initializing this.setup() @@ -379,12 +379,30 @@ export class Convo { this.newMessages = new Map() this.pendingMessages = new Map() this.deletedMessages = new Set() - this.footerItems = new Map() - this.headerItems = new Map() + + this.pendingMessageFailure = null + this.fetchMessageHistoryError = undefined + this.firehoseError = undefined this.dispatch({event: ConvoDispatchEvent.Init}) } + maybeRecoverFromNetworkError() { + if (this.firehoseError) { + this.firehoseError.retry() + this.firehoseError = undefined + this.commit() + } else { + this.batchRetryPendingMessages() + } + + if (this.fetchMessageHistoryError) { + this.fetchMessageHistoryError.retry() + this.fetchMessageHistoryError = undefined + this.commit() + } + } + private async setup() { try { const {convo, sender, recipients} = await this.fetchConvo() @@ -520,6 +538,11 @@ export class Convo { } } + private fetchMessageHistoryError: + | { + retry: () => void + } + | undefined async fetchMessageHistory() { logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo) @@ -537,7 +560,7 @@ export class Convo { * If we've rendered a retry state for history fetching, exit. Upon retry, * this will be removed and we'll try again. */ - if (this.headerItems.has(ConvoItemError.HistoryFailed)) return + if (this.fetchMessageHistoryError) return try { this.isFetchingHistory = true @@ -586,15 +609,11 @@ export class Convo { } catch (e: any) { logger.error('Convo: failed to fetch message history') - this.headerItems.set(ConvoItemError.HistoryFailed, { - type: 'error-recoverable', - key: ConvoItemError.HistoryFailed, - code: ConvoItemError.HistoryFailed, + this.fetchMessageHistoryError = { retry: () => { - this.headerItems.delete(ConvoItemError.HistoryFailed) this.fetchMessageHistory() }, - }) + } } finally { this.isFetchingHistory = false this.commit() @@ -628,22 +647,16 @@ export class Convo { ) } + private firehoseError: MessagesEventBusError | undefined + onFirehoseConnect() { - this.footerItems.delete(ConvoItemError.FirehoseFailed) + this.firehoseError = undefined + this.batchRetryPendingMessages() this.commit() } onFirehoseError(error?: MessagesEventBusError) { - this.footerItems.set(ConvoItemError.FirehoseFailed, { - type: 'error-recoverable', - key: ConvoItemError.FirehoseFailed, - code: ConvoItemError.FirehoseFailed, - retry: () => { - this.footerItems.delete(ConvoItemError.FirehoseFailed) - this.commit() - error?.retry() - }, - }) + this.firehoseError = error this.commit() } @@ -724,7 +737,7 @@ export class Convo { } } - private pendingFailed = false + private pendingMessageFailure: 'recoverable' | 'unrecoverable' | null = null async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { // Ignore empty messages for now since they have no other purpose atm @@ -734,13 +747,14 @@ export class Convo { const tempId = nanoid() + this.pendingMessageFailure = null this.pendingMessages.set(tempId, { id: tempId, message, }) this.commit() - if (!this.isProcessingPendingMessages && !this.pendingFailed) { + if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { this.processPendingMessages() } } @@ -765,7 +779,6 @@ export class Convo { try { this.isProcessingPendingMessages = true - // throw new Error('UNCOMMENT TO TEST RETRY') const {id, message} = pendingMessage const response = await networkRetry(2, () => { @@ -794,23 +807,65 @@ export class Convo { this.commit() } catch (e: any) { logger.error(e, {context: `Convo: failed to send message`}) - this.pendingFailed = true - this.commit() + this.handleSendMessageFailure(e) } finally { this.isProcessingPendingMessages = false } } + private handleSendMessageFailure(e: any) { + if (e instanceof XRPCError) { + if (NETWORK_FAILURE_STATUSES.includes(e.status)) { + this.pendingMessageFailure = 'recoverable' + } else { + switch (e.message) { + case 'block between recipient and sender': + this.pendingMessageFailure = 'unrecoverable' + this.emitter.emit('event', { + type: 'invalidate-block-state', + accountDids: [ + this.sender!.did, + ...this.recipients!.map(r => r.did), + ], + }) + break + default: + logger.warn( + `Convo handleSendMessageFailure could not handle error`, + { + status: e.status, + message: e.message, + }, + ) + break + } + } + } else { + logger.error(e, { + context: `Convo handleSendMessageFailure received unknown error`, + }) + } + + this.commit() + } + async batchRetryPendingMessages() { + if (this.pendingMessageFailure === null) return + + const messageArray = Array.from(this.pendingMessages.values()) + if (messageArray.length === 0) return + + this.pendingMessageFailure = null + this.commit() + logger.debug( - `Convo: retrying ${this.pendingMessages.size} pending messages`, + `Convo: batch retrying ${this.pendingMessages.size} pending messages`, {}, logger.DebugContext.convo, ) try { // throw new Error('UNCOMMENT TO TEST RETRY') - const messageArray = Array.from(this.pendingMessages.values()) const {data} = await networkRetry(2, () => { return this.agent.api.chat.bsky.convo.sendMessageBatch( { @@ -848,8 +903,7 @@ export class Convo { ) } catch (e: any) { logger.error(e, {context: `Convo: failed to batch retry messages`}) - this.pendingFailed = true - this.commit() + this.handleSendMessageFailure(e) } } @@ -877,6 +931,14 @@ export class Convo { } } + on(handler: (event: ConvoEvent) => void) { + this.emitter.on('event', handler) + + return () => { + this.emitter.off('event', handler) + } + } + /* * Items in reverse order, since FlatList inverts */ @@ -901,9 +963,16 @@ export class Convo { } }) - this.headerItems.forEach(item => { - items.unshift(item) - }) + if (this.fetchMessageHistoryError) { + items.unshift({ + type: 'error', + code: ConvoItemError.HistoryFailed, + key: ConvoItemError.HistoryFailed, + retry: () => { + this.maybeRecoverFromNetworkError() + }, + }) + } this.newMessages.forEach(m => { if (ChatBskyConvoDefs.isMessageView(m)) { @@ -940,19 +1009,26 @@ export class Convo { sender: this.sender!, }, nextMessage: null, - retry: this.pendingFailed - ? () => { - this.pendingFailed = false - this.commit() - this.batchRetryPendingMessages() - } - : undefined, + failed: this.pendingMessageFailure !== null, + retry: + this.pendingMessageFailure === 'recoverable' + ? () => { + this.maybeRecoverFromNetworkError() + } + : undefined, }) }) - this.footerItems.forEach(item => { - items.push(item) - }) + if (this.firehoseError) { + items.push({ + type: 'error', + code: ConvoItemError.FirehoseFailed, + key: ConvoItemError.FirehoseFailed, + retry: () => { + this.firehoseError?.retry() + }, + }) + } return items .filter(item => { diff --git a/src/state/messages/convo/const.ts b/src/state/messages/convo/const.ts index abea5205eb..6ce100d11e 100644 --- a/src/state/messages/convo/const.ts +++ b/src/state/messages/convo/const.ts @@ -1,3 +1,7 @@ export const ACTIVE_POLL_INTERVAL = 1e3 export const BACKGROUND_POLL_INTERVAL = 5e3 export const INACTIVE_TIMEOUT = 60e3 * 5 + +export const NETWORK_FAILURE_STATUSES = [ + 1, 408, 425, 429, 500, 502, 503, 504, 522, 524, +] diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index e955d41183..d6648f4800 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -1,6 +1,7 @@ import React, {useContext, useState, useSyncExternalStore} from 'react' import {AppState} from 'react-native' import {useFocusEffect, useIsFocused} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' import {Convo} from '#/state/messages/convo/agent' import { @@ -13,6 +14,8 @@ import { import {isConvoActive} from '#/state/messages/convo/util' import {useMessagesEventBus} from '#/state/messages/events' import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' +import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-converations' +import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' import {useAgent} from '#/state/session' export * from '#/state/messages/convo/util' @@ -52,6 +55,7 @@ export function ConvoProvider({ children, convoId, }: Pick & {children: React.ReactNode}) { + const queryClient = useQueryClient() const isScreenFocused = useIsFocused() const {getAgent} = useAgent() const events = useMessagesEventBus() @@ -78,6 +82,23 @@ export function ConvoProvider({ }, [convo, convoId, markAsRead]), ) + React.useEffect(() => { + return convo.on(event => { + switch (event.type) { + case 'invalidate-block-state': { + for (const did of event.accountDids) { + queryClient.invalidateQueries({ + queryKey: createProfileQueryKey(did), + }) + } + queryClient.invalidateQueries({ + queryKey: ListConvosQueryKey, + }) + } + } + }) + }, [convo, queryClient]) + React.useEffect(() => { const handleAppStateChange = (nextAppState: string) => { if (isScreenFocused) { diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 3fb0eb6ad3..25e79aba6d 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -23,10 +23,6 @@ export enum ConvoStatus { } export enum ConvoItemError { - /** - * Generic error - */ - Network = 'network', /** * Error connecting to event firehose */ @@ -95,6 +91,7 @@ export type ConvoItem = | ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null + failed: boolean /** * Retry sending the message. If present, the message is in a failed state. */ @@ -110,10 +107,13 @@ export type ConvoItem = | null } | { - type: 'error-recoverable' + type: 'error' key: string code: ConvoItemError - retry: () => void + /** + * If present, error is recoverable. + */ + retry?: () => void } type DeleteMessage = (messageId: string) => Promise @@ -186,7 +186,7 @@ export type ConvoStateError = { status: ConvoStatus.Error items: [] convo: undefined - error: any + error: ConvoError sender: undefined recipients: undefined isFetchingHistory: false @@ -201,3 +201,8 @@ export type ConvoState = | ConvoStateBackgrounded | ConvoStateSuspended | ConvoStateError + +export type ConvoEvent = { + type: 'invalidate-block-state' + accountDids: string[] +} From 5e8650a204cf4b52fa321e672801ce790b3cb554 Mon Sep 17 00:00:00 2001 From: Hailey Date: Thu, 16 May 2024 12:15:35 -0700 Subject: [PATCH 12/12] =?UTF-8?q?[=F0=9F=90=B4]=20Decrement=20app=20badge?= =?UTF-8?q?=20when=20opening=20unread=20chat=20(#4040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * decrement badge count for chats * handle decrement in `useMarkAsRead` * remove async * oops --- src/lib/hooks/useNotificationHandler.ts | 5 +++-- src/lib/notifications/notifications.ts | 12 ++++++++++++ src/state/queries/messages/list-converations.ts | 17 +++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 6f5fbd66bb..e288ac3ad4 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -169,10 +169,11 @@ export function useNotificationsHandler() { payload.reason === 'chat-message' && payload.recipientDid === currentAccount?.did ) { + const isCurrentConvo = payload.convoId === currentConvoId return { - shouldShowAlert: payload.convoId !== currentConvoId, + shouldShowAlert: !isCurrentConvo, shouldPlaySound: false, - shouldSetBadge: false, + shouldSetBadge: !isCurrentConvo, } } diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 52f984a599..1182bfcbbe 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -1,5 +1,6 @@ import React from 'react' import * as Notifications from 'expo-notifications' +import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications' import {BskyAgent} from '@atproto/api' import {logger} from '#/logger' @@ -109,3 +110,14 @@ export function useRequestNotificationsPermission() { [gate], ) } + +export async function decrementBadgeCount(by = 1) { + if (!isNative) return + + const currCount = await getBadgeCountAsync() + let newCount = currCount - by + if (newCount < 0) { + newCount = 0 + } + await setBadgeCountAsync(newCount) +} diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts index f2c277068a..4b4d50c493 100644 --- a/src/state/queries/messages/list-converations.ts +++ b/src/state/queries/messages/list-converations.ts @@ -10,6 +10,7 @@ import { import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' +import {decrementBadgeCount} from 'lib/notifications/notifications' export const RQKEY = ['convo-list'] type RQPageParam = string | undefined @@ -116,10 +117,18 @@ export function useOnMarkAsRead() { return useCallback( (chatId: string) => { queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { - return optimisticUpdate(chatId, old, convo => ({ - ...convo, - unreadCount: 0, - })) + return optimisticUpdate(chatId, old, convo => { + // We only want to decrement the badge by one no matter the unread count, since we only increment once per + // sender regardless of message count + if (convo.unreadCount > 0) { + decrementBadgeCount(1) + } + + return { + ...convo, + unreadCount: 0, + } + }) }) }, [queryClient],