Merge branch 'main' into hailey/proper-badge-count

This commit is contained in:
Hailey
2024-05-16 12:18:16 -07:00
25 changed files with 574 additions and 316 deletions
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.82.0", "version": "1.83.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -171,7 +171,6 @@
"react-native-get-random-values": "~1.11.0", "react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1", "react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3", "react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.11.7",
"react-native-pager-view": "6.2.3", "react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0", "react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress", "react-native-progress": "bluesky-social/react-native-progress",
@@ -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
@@ -46,7 +46,11 @@ const floatingMiddlewares = [
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
export function ProfileHoverCard(props: ProfileHoverCardProps) { export function ProfileHoverCard(props: ProfileHoverCardProps) {
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} /> if (props.disable || isTouchDevice) {
return props.children
} else {
return <ProfileHoverCardInner {...props} />
}
} }
type State = type State =
+1
View File
@@ -4,4 +4,5 @@ export type ProfileHoverCardProps = {
children: React.ReactElement children: React.ReactElement
did: string did: string
inline?: boolean inline?: boolean
disable?: boolean
} }
-76
View File
@@ -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<T extends string | number>({
value,
onSelect,
items,
...props
}: ViewProps & {
value: T
onSelect: (value: T) => void
items: Array<{label: string; value: T}>
}) {
return (
<View {...props}>
{items.map(item => (
<Button
label={item.label}
key={item.value}
variant="ghost"
color="secondary"
size="small"
onPress={() => onSelect(item.value)}
style={[a.justify_between, a.px_sm]}>
<Text style={a.text_md}>{item.label}</Text>
<RadioIcon selected={value === item.value} />
</Button>
))}
</View>
)
}
function RadioIcon({selected}: {selected: boolean}) {
const t = useTheme()
return (
<View
style={[
{
width: 30,
height: 30,
borderWidth: 2,
borderColor: selected
? t.palette.primary_500
: t.palette.contrast_200,
},
selected
? {
backgroundColor:
t.name === 'light'
? t.palette.primary_100
: t.palette.primary_900,
}
: t.atoms.bg,
a.align_center,
a.justify_center,
a.rounded_full,
]}>
{selected && (
<View
style={[
{
width: 18,
height: 18,
backgroundColor: t.palette.primary_500,
},
a.rounded_full,
]}
/>
)}
</View>
)
}
+4 -4
View File
@@ -171,13 +171,13 @@ let ConvoMenu = ({
<Menu.ItemIcon icon={Person} /> <Menu.ItemIcon icon={Person} />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
label={_(msg`Mute notifications`)} label={_(msg`Mute conversation`)}
onPress={() => muteConvo({mute: !convo?.muted})}> onPress={() => muteConvo({mute: !convo?.muted})}>
<Menu.ItemText> <Menu.ItemText>
{convo?.muted ? ( {convo?.muted ? (
<Trans>Unmute notifications</Trans> <Trans>Unmute conversation</Trans>
) : ( ) : (
<Trans>Mute notifications</Trans> <Trans>Mute conversation</Trans>
)} )}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} /> <Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
@@ -222,7 +222,7 @@ let ConvoMenu = ({
control={leaveConvoControl} control={leaveConvoControl}
title={_(msg`Leave conversation`)} title={_(msg`Leave conversation`)}
description={_( 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`)} confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative" confirmButtonColor="negative"
+7 -2
View File
@@ -202,7 +202,7 @@ let MessageItemMetadata = ({
)} )}
</TimeElapsed> </TimeElapsed>
{item.type === 'pending-message' && item.retry && ( {item.type === 'pending-message' && item.failed && (
<> <>
{' '} {' '}
&middot;{' '} &middot;{' '}
@@ -214,7 +214,10 @@ let MessageItemMetadata = ({
}, },
]}> ]}>
{_(msg`Failed to send`)} {_(msg`Failed to send`)}
</Text>{' '} </Text>
{item.retry && (
<>
{' '}
&middot;{' '} &middot;{' '}
<InlineLinkText <InlineLinkText
label={_(msg`Click to retry failed message`)} label={_(msg`Click to retry failed message`)}
@@ -225,6 +228,8 @@ let MessageItemMetadata = ({
</InlineLinkText> </InlineLinkText>
</> </>
)} )}
</>
)}
</Text> </Text>
) )
} }
+1 -1
View File
@@ -119,7 +119,7 @@ export let MessageMenu = ({
control={deleteControl} control={deleteControl}
title={_(msg`Delete message`)} title={_(msg`Delete message`)}
description={_( 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`)} confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative" confirmButtonColor="negative"
+47
View File
@@ -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 (
<Animated.View
style={[
a.py_sm,
a.rounded_full,
a.shadow_sm,
a.border,
t.atoms.bg_contrast_50,
t.atoms.border_contrast_medium,
{
position: 'absolute',
bottom: 70,
width: '40%',
left: '30%',
alignItems: 'center',
shadowOpacity: 0.125,
shadowRadius: 12,
shadowOffset: {width: 0, height: 5},
},
]}
entering={ScaleAndFadeIn}
exiting={ScaleAndFadeOut}>
<View style={{flex: 1}}>
<Text style={[a.font_bold]}>
<Trans>New messages</Trans>
</Text>
</View>
</Animated.View>
)
}
+39
View File
@@ -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,
}
}
+5
View File
@@ -53,6 +53,11 @@ export function useAccountSwitcher() {
logger.error(`switch account: selectAccount failed`, { logger.error(`switch account: selectAccount failed`, {
message: e.message, message: e.message,
}) })
requestSwitchToAccount({requestedAccount: account.did})
Toast.show(
_(msg`Please sign in as @${account.handle}`),
'circle-exclamation',
)
} finally { } finally {
setPendingDid(null) setPendingDid(null)
} }
+2 -4
View File
@@ -1,4 +1,5 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {isReducedMotion} from 'react-native-reanimated'
import {getLocales} from 'expo-localization' import {getLocales} from 'expo-localization'
import {dedupArray} from 'lib/functions' import {dedupArray} from 'lib/functions'
@@ -20,7 +21,4 @@ export const deviceLocales = dedupArray(
.filter(code => typeof code === 'string'), .filter(code => typeof code === 'string'),
) as string[] ) as string[]
export const prefersReducedMotion = export const prefersReducedMotion = isReducedMotion()
isWeb &&
// @ts-ignore we know window exists -prf
!global.window.matchMedia('(prefers-reduced-motion: no-preference)')?.matches
@@ -19,6 +19,7 @@ import {
useMessageDraft, useMessageDraft,
useSaveMessageDraft, useSaveMessageDraft,
} from '#/state/messages/message-drafts' } from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
@@ -64,7 +65,7 @@ export function MessageInput({
const keyboardHeight = Keyboard.metrics()?.height ?? 0 const keyboardHeight = Keyboard.metrics()?.height ?? 0
const windowHeight = Dimensions.get('window').height 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 const availableSpace = max - e.nativeEvent.contentSize.height
setMaxHeight(max) setMaxHeight(max)
@@ -97,13 +98,19 @@ export function MessageInput({
value={message} value={message}
multiline={true} multiline={true}
onChangeText={setMessage} 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'} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
scrollEnabled={isInputScrollable} scrollEnabled={isInputScrollable}
blurOnSubmit={false} blurOnSubmit={false}
onFocus={scrollToEnd}
onContentSizeChange={onInputLayout} onContentSizeChange={onInputLayout}
ref={inputRef} ref={inputRef}
hitSlop={HITSLOP_10}
/> />
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
@@ -63,7 +63,6 @@ export function MessageInput({
<View <View
style={[ style={[
a.flex_row, a.flex_row,
a.py_sm,
a.px_sm, a.px_sm,
a.pl_md, a.pl_md,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
@@ -76,9 +75,10 @@ export function MessageInput({
a.border_0, a.border_0,
t.atoms.text, t.atoms.text,
{ {
paddingTop: 10,
paddingBottom: 12,
backgroundColor: 'transparent', backgroundColor: 'transparent',
resize: 'none', resize: 'none',
paddingTop: 4,
}, },
])} ])}
maxRows={12} maxRows={12}
@@ -98,7 +98,12 @@ export function MessageInput({
a.rounded_full, a.rounded_full,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
{height: 30, width: 30, backgroundColor: t.palette.primary_500}, {
height: 30,
width: 30,
marginTop: 6,
backgroundColor: t.palette.primary_500,
},
]} ]}
onPress={onSubmit}> onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} /> <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
@@ -5,27 +5,25 @@ import {useLingui} from '@lingui/react'
import {ConvoItem, ConvoItemError} from '#/state/messages/convo/types' import {ConvoItem, ConvoItemError} from '#/state/messages/convo/types'
import {atoms as a, useTheme} from '#/alf' 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function MessageListError({ export function MessageListError({item}: {item: ConvoItem & {type: 'error'}}) {
item,
}: {
item: ConvoItem & {type: 'error-recoverable'}
}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const message = React.useMemo(() => { const {description, help, cta} = React.useMemo(() => {
return { return {
[ConvoItemError.Network]: _( [ConvoItemError.FirehoseFailed]: {
msg`There was an issue connecting to the chat.`, description: _(msg`This chat was disconnected`),
), help: _(msg`Press to attempt reconnection`),
[ConvoItemError.FirehoseFailed]: _( cta: _(msg`Reconnect`),
msg`This chat was disconnected due to a network error.`, },
), [ConvoItemError.HistoryFailed]: {
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`), description: _(msg`Failed to load past messages`),
help: _(msg`Press to retry`),
cta: _(msg`Retry`),
},
}[item.code] }[item.code]
}, [_, item.code]) }, [_, item.code])
@@ -36,37 +34,31 @@ export function MessageListError({
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.justify_between, a.justify_between,
a.gap_lg, a.gap_sm,
a.py_md, a.pb_lg,
a.px_lg,
a.rounded_md,
t.atoms.bg_contrast_25,
{maxWidth: 400}, {maxWidth: 400},
]}> ]}>
<View style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}>
<CircleInfo <CircleInfo
size="sm" size="sm"
fill={t.palette.negative_400} fill={t.palette.negative_400}
style={[{top: 3}]} style={[{top: 3}]}
/> />
<View style={[a.flex_1, {maxWidth: 200}]}>
<Text style={[a.leading_snug]}>{message}</Text>
</View>
</View>
<Button <Text style={[a.leading_snug, a.flex_1, t.atoms.text_contrast_medium]}>
label={_(msg`Press to retry`)} {description} &middot;{' '}
size="small" {item.retry && (
variant="ghost" <InlineLinkText
color="secondary" to="#"
label={help}
onPress={e => { onPress={e => {
e.preventDefault() e.preventDefault()
item.retry() item.retry?.()
return false return false
}}> }}>
<ButtonText>{_(msg`Retry`)}</ButtonText> {cta}
<ButtonIcon icon={Refresh} position="right" /> </InlineLinkText>
</Button> )}
</Text>
</View> </View>
</View> </View>
) )
@@ -1,12 +1,18 @@
import React, {useCallback, useRef} from 'react' import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native' import {FlatList, View} from 'react-native'
import {useKeyboardHandler} from 'react-native-keyboard-controller' import Animated, {
import {runOnJS, useSharedValue} from 'react-native-reanimated' runOnJS,
useAnimatedKeyboard,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' 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 {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {shortenLinks} from '#/lib/strings/rich-text-manip' 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 {useConvoActive} from '#/state/messages/convo'
import {ConvoItem} from '#/state/messages/convo/types' import {ConvoItem} from '#/state/messages/convo/types'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
@@ -15,8 +21,9 @@ import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' 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 {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -39,7 +46,7 @@ function renderItem({item}: {item: ConvoItem}) {
return <MessageItem item={item} /> return <MessageItem item={item} />
} else if (item.type === 'deleted-message') { } else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text> return <Text>Deleted message</Text>
} else if (item.type === 'error-recoverable') { } else if (item.type === 'error') {
return <MessageListError item={item} /> return <MessageListError item={item} />
} }
@@ -55,10 +62,13 @@ function onScrollToIndexFailed() {
} }
export function MessagesList() { export function MessagesList() {
const t = useTheme()
const convo = useConvoActive() const convo = useConvoActive()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const flatListRef = useRef<FlatList>(null) const flatListRef = useRef<FlatList>(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 // 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 // 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. // the bottom.
@@ -70,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 // 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. // onStartReached to fire.
const contentHeight = useSharedValue(0) 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 // 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. // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false) const isMomentumScrolling = useSharedValue(false)
const hasInitiallyScrolled = 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: // 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 // 1. New messages are being added from the log or from a message you have sent
@@ -90,7 +102,7 @@ export function MessagesList() {
const onContentSizeChange = useCallback( const onContentSizeChange = useCallback(
(_: number, height: number) => { (_: number, height: number) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the // 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) { if (isWeb && isAtTop.value && hasInitiallyScrolled.value) {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
animated: false, animated: false,
@@ -98,25 +110,41 @@ export function MessagesList() {
}) })
} }
contentHeight.value = height
// This number _must_ be the height of the MaybeLoader component // This number _must_ be the height of the MaybeLoader component
if (height <= 50 || !isAtBottom.value) { if (height > 50 && (isAtBottom.value || keyboardIsOpening.value)) {
return let newOffset = height
// 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({ flatListRef.current?.scrollToOffset({
animated: hasInitiallyScrolled.value, animated: hasInitiallyScrolled.value && !keyboardIsOpening.value,
offset: height, offset: newOffset,
}) })
isMomentumScrolling.value = true isMomentumScrolling.value = true
}
contentHeight.value = height
prevItemCount.current = convo.items.length
}, },
[ [
contentHeight, contentHeight,
hasInitiallyScrolled, hasInitiallyScrolled.value,
isAtBottom.value, isAtBottom.value,
isAtTop.value, isAtTop.value,
isMomentumScrolling, isMomentumScrolling,
layoutHeight.value,
convo.items.length,
keyboardIsOpening.value,
], ],
) )
@@ -156,8 +184,17 @@ export function MessagesList() {
const onScroll = React.useCallback( const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => { (e: ReanimatedScrollEvent) => {
'worklet' 'worklet'
layoutHeight.value = e.layoutMeasurement.height
const bottomOffset = e.contentOffset.y + 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 // 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 // when a new message is added, hence the 100 pixel offset
isAtBottom.value = e.contentSize.height - 100 < bottomOffset isAtBottom.value = e.contentSize.height - 100 < bottomOffset
@@ -170,7 +207,14 @@ export function MessagesList() {
hasInitiallyScrolled.value = true hasInitiallyScrolled.value = true
} }
}, },
[contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop], [
layoutHeight,
showNewMessagesPill,
isAtBottom,
isAtTop,
contentHeight.value,
hasInitiallyScrolled,
],
) )
const onMomentumEnd = React.useCallback(() => { const onMomentumEnd = React.useCallback(() => {
@@ -187,17 +231,46 @@ export function MessagesList() {
}) })
}, [isMomentumScrolling]) }, [isMomentumScrolling])
// This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly. // -- Keyboard animation handling
const scrollToEndNow = React.useCallback(() => { const animatedKeyboard = useAnimatedKeyboard()
flatListRef.current?.scrollToEnd({animated: false}) const {gtMobile} = useBreakpoints()
}, []) const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset =
isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight
useKeyboardHandler({ // We need to keep track of when the keyboard is animating and when it isn't, since we want our `onContentSizeChanged`
onMove: () => { // callback to animate the scroll _only_ when the keyboard isn't animating. Any time the previous value of kb height
'worklet' // is different, we know that it is animating. When it finally settles, now will be equal to prev.
runOnJS(scrollToEndNow)() 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 ( return (
<> <>
@@ -211,8 +284,9 @@ export function MessagesList() {
containWeb={true} containWeb={true}
contentContainerStyle={[a.px_md]} contentContainerStyle={[a.px_md]}
disableVirtualization={true} disableVirtualization={true}
initialNumToRender={isNative ? 30 : 60} // The extra two items account for the header and the footer components
maxToRenderPerBatch={isWeb ? 30 : 60} initialNumToRender={isNative ? 32 : 62}
maxToRenderPerBatch={isWeb ? 32 : 62}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{ maintainVisibleContentPosition={{
@@ -227,9 +301,13 @@ export function MessagesList() {
ListHeaderComponent={ ListHeaderComponent={
<MaybeLoader isLoading={convo.isFetchingHistory} /> <MaybeLoader isLoading={convo.isFetchingHistory} />
} }
ListFooterComponent={<Animated.View style={[animatedFooterStyle]} />}
/> />
</ScrollProvider> </ScrollProvider>
{showNewMessagesPill && <NewMessagesPill />}
<Animated.View style={[a.relative, t.atoms.bg, animatedInputStyle]}>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} /> <MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</Animated.View>
</> </>
) )
} }
+5 -22
View File
@@ -1,8 +1,5 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native' 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 {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -18,7 +15,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {BACK_HITSLOP} from 'lib/constants' import {BACK_HITSLOP} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names' 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 {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo'
import {ConvoStatus} from 'state/messages/convo/types' import {ConvoStatus} from 'state/messages/convo/types'
import {useSetMinimalShellMode} from 'state/shell' import {useSetMinimalShellMode} from 'state/shell'
@@ -39,8 +36,8 @@ type Props = NativeStackScreenProps<
> >
export function MessagesConversationScreen({route}: Props) { export function MessagesConversationScreen({route}: Props) {
const gate = useGate() const gate = useGate()
const setMinimalShellMode = useSetMinimalShellMode()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const setMinimalShellMode = useSetMinimalShellMode()
const convoId = route.params.conversation const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId() const {setCurrentConvoId} = useCurrentConvoId()
@@ -57,7 +54,7 @@ export function MessagesConversationScreen({route}: Props) {
setCurrentConvoId(undefined) setCurrentConvoId(undefined)
setMinimalShellMode(false) setMinimalShellMode(false)
} }
}, [convoId, gtMobile, setCurrentConvoId, setMinimalShellMode]), }, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]),
) )
if (!gate('dms')) return <ClipClopGate /> if (!gate('dms')) return <ClipClopGate />
@@ -76,9 +73,6 @@ function Inner() {
const [hasInitiallyRendered, setHasInitiallyRendered] = React.useState(false) 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 // 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 // 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 // a little flicker when the items are first renedered at the top and immediately scrolled to the bottom. to prevent
@@ -111,18 +105,8 @@ function Inner() {
/* /*
* Any other convo states (atm) are "ready" states * Any other convo states (atm) are "ready" states
*/ */
return ( return (
<KeyboardProvider> <CenteredView style={[a.flex_1]} sideBorders>
<KeyboardAvoidingView
style={[
a.flex_1,
isNative && {marginBottom: bottomInset + nativeBottomBarHeight},
]}
keyboardVerticalOffset={isIOS ? topInset : 0}
behavior="padding"
contentContainerStyle={a.flex_1}>
<CenteredView style={a.flex_1} sideBorders>
<Header profile={convoState.recipients?.[0]} /> <Header profile={convoState.recipients?.[0]} />
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
{isConvoActive(convoState) ? ( {isConvoActive(convoState) ? (
@@ -148,8 +132,6 @@ function Inner() {
)} )}
</View> </View>
</CenteredView> </CenteredView>
</KeyboardAvoidingView>
</KeyboardProvider>
) )
} }
@@ -277,6 +259,7 @@ function HeaderReady({
size={32} size={32}
profile={profile} profile={profile}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/> />
<Text <Text
style={[a.text_lg, a.font_bold, a.pt_sm, a.pb_2xs]} style={[a.text_lg, a.font_bold, a.pt_sm, a.pb_2xs]}
+55 -19
View File
@@ -8,6 +8,7 @@ import {UseQueryResult} from '@tanstack/react-query'
import {CommonNavigatorParams} from '#/lib/routes/types' import {CommonNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' 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 {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views' import {CenteredView} from '#/view/com/util/Views'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {RadioGroup} from '#/components/RadioGroup'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {ClipClopGate} from './gate' import {ClipClopGate} from './gate'
@@ -39,7 +40,9 @@ export function MessagesSettingsScreen({}: Props) {
}) })
const onSelectItem = useCallback( const onSelectItem = useCallback(
(key: string) => { (keys: string[]) => {
const key = keys[0]
if (!key) return
updateDeclaration(key as AllowIncoming) updateDeclaration(key as AllowIncoming)
}, },
[updateDeclaration], [updateDeclaration],
@@ -48,37 +51,70 @@ export function MessagesSettingsScreen({}: Props) {
const gate = useGate() const gate = useGate()
if (!gate('dms')) return <ClipClopGate /> if (!gate('dms')) return <ClipClopGate />
console.log(profile?.associated?.chat?.allowIncoming)
return ( return (
<CenteredView sideBorders style={a.h_full_vh}> <CenteredView sideBorders style={a.h_full_vh}>
<ViewHeader title={_(msg`Settings`)} showOnDesktop showBorder /> <ViewHeader title={_(msg`Settings`)} showOnDesktop showBorder />
<View style={[a.px_md, a.py_lg, a.gap_md]}> <View style={[a.p_lg, a.gap_md]}>
<Text style={[a.text_xl, a.font_bold, a.px_sm]}> <Text style={[a.text_lg, a.font_bold]}>
<Trans>Allow messages from</Trans> <Trans>Allow messages from</Trans>
</Text> </Text>
<RadioGroup<AllowIncoming> <Toggle.Group
value={ label={_(msg`Allow messages from`)}
type="radio"
values={[
(profile?.associated?.chat?.allowIncoming as AllowIncoming) ?? (profile?.associated?.chat?.allowIncoming as AllowIncoming) ??
'following' 'following',
}
items={[
{label: _(msg`Everyone`), value: 'all'},
{label: _(msg`People I Follow`), value: 'following'},
{label: _(msg`No one`), value: 'none'},
]} ]}
onSelect={onSelectItem} onChange={onSelectItem}>
/> <View>
</View>
<View style={[a.px_md, a.py_lg, a.gap_md]}>
<Toggle.Item <Toggle.Item
name="a" name="all"
label="Click me" label={_(msg`Everyone`)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>Everyone</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="following"
label={_(msg`Users I follow`)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>Users I follow</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="none"
label={_(msg`No one`)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>No one</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
</View>
</Toggle.Group>
{isNative && (
<>
<Divider style={[a.my_lg]} />
<Toggle.Item
name="playSoundChat"
label={_(msg`Play notification sounds`)}
value={preferences.playSoundChat} value={preferences.playSoundChat}
onChange={() => { onChange={() => {
setPref('playSoundChat', !preferences.playSoundChat) setPref('playSoundChat', !preferences.playSoundChat)
}}> }}>
<Toggle.Checkbox /> <Toggle.Checkbox />
<Toggle.LabelText>Notification Sounds</Toggle.LabelText> <Toggle.LabelText>
<Trans>Play notification sounds</Trans>
</Toggle.LabelText>
</Toggle.Item> </Toggle.Item>
</>
)}
</View> </View>
</CenteredView> </CenteredView>
) )
+123 -47
View File
@@ -5,6 +5,8 @@ import {
ChatBskyConvoGetLog, ChatBskyConvoGetLog,
ChatBskyConvoSendMessage, ChatBskyConvoSendMessage,
} from '@atproto/api' } from '@atproto/api'
import {XRPCError} from '@atproto/xrpc'
import EventEmitter from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
@@ -14,11 +16,14 @@ import {
ACTIVE_POLL_INTERVAL, ACTIVE_POLL_INTERVAL,
BACKGROUND_POLL_INTERVAL, BACKGROUND_POLL_INTERVAL,
INACTIVE_TIMEOUT, INACTIVE_TIMEOUT,
NETWORK_FAILURE_STATUSES,
} from '#/state/messages/convo/const' } from '#/state/messages/convo/const'
import { import {
ConvoDispatch, ConvoDispatch,
ConvoDispatchEvent, ConvoDispatchEvent,
ConvoError,
ConvoErrorCode, ConvoErrorCode,
ConvoEvent,
ConvoItem, ConvoItem,
ConvoItemError, ConvoItemError,
ConvoParams, ConvoParams,
@@ -51,13 +56,7 @@ export class Convo {
private senderUserDid: string private senderUserDid: string
private status: ConvoStatus = ConvoStatus.Uninitialized private status: ConvoStatus = ConvoStatus.Uninitialized
private error: private error: ConvoError | undefined
| {
code: ConvoErrorCode
exception?: Error
retry: () => void
}
| undefined
private oldestRev: string | undefined | null = undefined private oldestRev: string | undefined | null = undefined
private isFetchingHistory = false private isFetchingHistory = false
private latestRev: string | undefined = undefined private latestRev: string | undefined = undefined
@@ -75,13 +74,13 @@ export class Convo {
{id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']}
> = new Map() > = new Map()
private deletedMessages: Set<string> = new Set() private deletedMessages: Set<string> = new Set()
private footerItems: Map<string, ConvoItem> = new Map()
private headerItems: Map<string, ConvoItem> = new Map()
private isProcessingPendingMessages = false private isProcessingPendingMessages = false
private lastActiveTimestamp: number | undefined private lastActiveTimestamp: number | undefined
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
convoId: string convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined convo: ChatBskyConvoDefs.ConvoView | undefined
sender: AppBskyActorDefs.ProfileViewBasic | undefined sender: AppBskyActorDefs.ProfileViewBasic | undefined
@@ -174,7 +173,7 @@ export class Convo {
status: ConvoStatus.Error, status: ConvoStatus.Error,
items: [], items: [],
convo: undefined, convo: undefined,
error: this.error, error: this.error!,
sender: undefined, sender: undefined,
recipients: undefined, recipients: undefined,
isFetchingHistory: false, isFetchingHistory: false,
@@ -282,6 +281,7 @@ export class Convo {
if (this.convo) { if (this.convo) {
this.status = ConvoStatus.Ready this.status = ConvoStatus.Ready
this.refreshConvo() this.refreshConvo()
this.maybeRecoverFromNetworkError()
} else { } else {
this.status = ConvoStatus.Initializing this.status = ConvoStatus.Initializing
this.setup() this.setup()
@@ -379,12 +379,30 @@ export class Convo {
this.newMessages = new Map() this.newMessages = new Map()
this.pendingMessages = new Map() this.pendingMessages = new Map()
this.deletedMessages = new Set() 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}) 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() { private async setup() {
try { try {
const {convo, sender, recipients} = await this.fetchConvo() const {convo, sender, recipients} = await this.fetchConvo()
@@ -520,6 +538,11 @@ export class Convo {
} }
} }
private fetchMessageHistoryError:
| {
retry: () => void
}
| undefined
async fetchMessageHistory() { async fetchMessageHistory() {
logger.debug('Convo: fetch message history', {}, logger.DebugContext.convo) 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, * If we've rendered a retry state for history fetching, exit. Upon retry,
* this will be removed and we'll try again. * this will be removed and we'll try again.
*/ */
if (this.headerItems.has(ConvoItemError.HistoryFailed)) return if (this.fetchMessageHistoryError) return
try { try {
this.isFetchingHistory = true this.isFetchingHistory = true
@@ -586,15 +609,11 @@ export class Convo {
} catch (e: any) { } catch (e: any) {
logger.error('Convo: failed to fetch message history') logger.error('Convo: failed to fetch message history')
this.headerItems.set(ConvoItemError.HistoryFailed, { this.fetchMessageHistoryError = {
type: 'error-recoverable',
key: ConvoItemError.HistoryFailed,
code: ConvoItemError.HistoryFailed,
retry: () => { retry: () => {
this.headerItems.delete(ConvoItemError.HistoryFailed)
this.fetchMessageHistory() this.fetchMessageHistory()
}, },
}) }
} finally { } finally {
this.isFetchingHistory = false this.isFetchingHistory = false
this.commit() this.commit()
@@ -628,22 +647,16 @@ export class Convo {
) )
} }
private firehoseError: MessagesEventBusError | undefined
onFirehoseConnect() { onFirehoseConnect() {
this.footerItems.delete(ConvoItemError.FirehoseFailed) this.firehoseError = undefined
this.batchRetryPendingMessages()
this.commit() this.commit()
} }
onFirehoseError(error?: MessagesEventBusError) { onFirehoseError(error?: MessagesEventBusError) {
this.footerItems.set(ConvoItemError.FirehoseFailed, { this.firehoseError = error
type: 'error-recoverable',
key: ConvoItemError.FirehoseFailed,
code: ConvoItemError.FirehoseFailed,
retry: () => {
this.footerItems.delete(ConvoItemError.FirehoseFailed)
this.commit()
error?.retry()
},
})
this.commit() this.commit()
} }
@@ -724,7 +737,7 @@ export class Convo {
} }
} }
private pendingFailed = false private pendingMessageFailure: 'recoverable' | 'unrecoverable' | null = null
async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) { async sendMessage(message: ChatBskyConvoSendMessage.InputSchema['message']) {
// Ignore empty messages for now since they have no other purpose atm // Ignore empty messages for now since they have no other purpose atm
@@ -734,13 +747,14 @@ export class Convo {
const tempId = nanoid() const tempId = nanoid()
this.pendingMessageFailure = null
this.pendingMessages.set(tempId, { this.pendingMessages.set(tempId, {
id: tempId, id: tempId,
message, message,
}) })
this.commit() this.commit()
if (!this.isProcessingPendingMessages && !this.pendingFailed) { if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
this.processPendingMessages() this.processPendingMessages()
} }
} }
@@ -765,7 +779,6 @@ export class Convo {
try { try {
this.isProcessingPendingMessages = true this.isProcessingPendingMessages = true
// throw new Error('UNCOMMENT TO TEST RETRY')
const {id, message} = pendingMessage const {id, message} = pendingMessage
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
@@ -794,23 +807,65 @@ export class Convo {
this.commit() this.commit()
} catch (e: any) { } catch (e: any) {
logger.error(e, {context: `Convo: failed to send message`}) logger.error(e, {context: `Convo: failed to send message`})
this.pendingFailed = true this.handleSendMessageFailure(e)
this.commit()
} finally { } finally {
this.isProcessingPendingMessages = false 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() { 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( logger.debug(
`Convo: retrying ${this.pendingMessages.size} pending messages`, `Convo: batch retrying ${this.pendingMessages.size} pending messages`,
{}, {},
logger.DebugContext.convo, logger.DebugContext.convo,
) )
try { try {
// throw new Error('UNCOMMENT TO TEST RETRY') // throw new Error('UNCOMMENT TO TEST RETRY')
const messageArray = Array.from(this.pendingMessages.values())
const {data} = await networkRetry(2, () => { const {data} = await networkRetry(2, () => {
return this.agent.api.chat.bsky.convo.sendMessageBatch( return this.agent.api.chat.bsky.convo.sendMessageBatch(
{ {
@@ -848,8 +903,7 @@ export class Convo {
) )
} catch (e: any) { } catch (e: any) {
logger.error(e, {context: `Convo: failed to batch retry messages`}) logger.error(e, {context: `Convo: failed to batch retry messages`})
this.pendingFailed = true this.handleSendMessageFailure(e)
this.commit()
} }
} }
@@ -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 * Items in reverse order, since FlatList inverts
*/ */
@@ -901,9 +963,16 @@ export class Convo {
} }
}) })
this.headerItems.forEach(item => { if (this.fetchMessageHistoryError) {
items.unshift(item) items.unshift({
type: 'error',
code: ConvoItemError.HistoryFailed,
key: ConvoItemError.HistoryFailed,
retry: () => {
this.maybeRecoverFromNetworkError()
},
}) })
}
this.newMessages.forEach(m => { this.newMessages.forEach(m => {
if (ChatBskyConvoDefs.isMessageView(m)) { if (ChatBskyConvoDefs.isMessageView(m)) {
@@ -940,19 +1009,26 @@ export class Convo {
sender: this.sender!, sender: this.sender!,
}, },
nextMessage: null, nextMessage: null,
retry: this.pendingFailed failed: this.pendingMessageFailure !== null,
retry:
this.pendingMessageFailure === 'recoverable'
? () => { ? () => {
this.pendingFailed = false this.maybeRecoverFromNetworkError()
this.commit()
this.batchRetryPendingMessages()
} }
: undefined, : undefined,
}) })
}) })
this.footerItems.forEach(item => { if (this.firehoseError) {
items.push(item) items.push({
type: 'error',
code: ConvoItemError.FirehoseFailed,
key: ConvoItemError.FirehoseFailed,
retry: () => {
this.firehoseError?.retry()
},
}) })
}
return items return items
.filter(item => { .filter(item => {
+4
View File
@@ -1,3 +1,7 @@
export const ACTIVE_POLL_INTERVAL = 1e3 export const ACTIVE_POLL_INTERVAL = 1e3
export const BACKGROUND_POLL_INTERVAL = 5e3 export const BACKGROUND_POLL_INTERVAL = 5e3
export const INACTIVE_TIMEOUT = 60e3 * 5 export const INACTIVE_TIMEOUT = 60e3 * 5
export const NETWORK_FAILURE_STATUSES = [
1, 408, 425, 429, 500, 502, 503, 504, 522, 524,
]
+21
View File
@@ -1,6 +1,7 @@
import React, {useContext, useState, useSyncExternalStore} from 'react' import React, {useContext, useState, useSyncExternalStore} from 'react'
import {AppState} from 'react-native' import {AppState} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {Convo} from '#/state/messages/convo/agent' import {Convo} from '#/state/messages/convo/agent'
import { import {
@@ -13,6 +14,8 @@ import {
import {isConvoActive} from '#/state/messages/convo/util' import {isConvoActive} from '#/state/messages/convo/util'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' 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' import {useAgent} from '#/state/session'
export * from '#/state/messages/convo/util' export * from '#/state/messages/convo/util'
@@ -52,6 +55,7 @@ export function ConvoProvider({
children, children,
convoId, convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const queryClient = useQueryClient()
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const events = useMessagesEventBus() const events = useMessagesEventBus()
@@ -78,6 +82,23 @@ export function ConvoProvider({
}, [convo, convoId, markAsRead]), }, [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(() => { React.useEffect(() => {
const handleAppStateChange = (nextAppState: string) => { const handleAppStateChange = (nextAppState: string) => {
if (isScreenFocused) { if (isScreenFocused) {
+12 -7
View File
@@ -23,10 +23,6 @@ export enum ConvoStatus {
} }
export enum ConvoItemError { export enum ConvoItemError {
/**
* Generic error
*/
Network = 'network',
/** /**
* Error connecting to event firehose * Error connecting to event firehose
*/ */
@@ -95,6 +91,7 @@ export type ConvoItem =
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
| null | null
failed: boolean
/** /**
* Retry sending the message. If present, the message is in a failed state. * Retry sending the message. If present, the message is in a failed state.
*/ */
@@ -110,10 +107,13 @@ export type ConvoItem =
| null | null
} }
| { | {
type: 'error-recoverable' type: 'error'
key: string key: string
code: ConvoItemError code: ConvoItemError
retry: () => void /**
* If present, error is recoverable.
*/
retry?: () => void
} }
type DeleteMessage = (messageId: string) => Promise<void> type DeleteMessage = (messageId: string) => Promise<void>
@@ -186,7 +186,7 @@ export type ConvoStateError = {
status: ConvoStatus.Error status: ConvoStatus.Error
items: [] items: []
convo: undefined convo: undefined
error: any error: ConvoError
sender: undefined sender: undefined
recipients: undefined recipients: undefined
isFetchingHistory: false isFetchingHistory: false
@@ -201,3 +201,8 @@ export type ConvoState =
| ConvoStateBackgrounded | ConvoStateBackgrounded
| ConvoStateSuspended | ConvoStateSuspended
| ConvoStateError | ConvoStateError
export type ConvoEvent = {
type: 'invalidate-block-state'
accountDids: string[]
}
@@ -4,6 +4,7 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useOnMarkAsRead} from '#/state/queries/messages/list-converations' import {useOnMarkAsRead} from '#/state/queries/messages/list-converations'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {STALE} from 'state/queries'
import {RQKEY as LIST_CONVOS_KEY} from './list-converations' import {RQKEY as LIST_CONVOS_KEY} from './list-converations'
const RQKEY_ROOT = 'convo' const RQKEY_ROOT = 'convo'
@@ -22,6 +23,7 @@ export function useConvoQuery(convo: ChatBskyConvoDefs.ConvoView) {
return data.convo return data.convo
}, },
initialData: convo, initialData: convo,
staleTime: STALE.INFINITY,
}) })
} }
+4 -2
View File
@@ -50,8 +50,9 @@ interface EditableUserAvatarProps extends BaseUserAvatarProps {
interface PreviewableUserAvatarProps extends BaseUserAvatarProps { interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
moderation?: ModerationUI moderation?: ModerationUI
onBeforePress?: () => void
profile: AppBskyActorDefs.ProfileViewBasic profile: AppBskyActorDefs.ProfileViewBasic
disableHoverCard?: boolean
onBeforePress?: () => void
} }
const BLUR_AMOUNT = isWeb ? 5 : 100 const BLUR_AMOUNT = isWeb ? 5 : 100
@@ -383,6 +384,7 @@ export {EditableUserAvatar}
let PreviewableUserAvatar = ({ let PreviewableUserAvatar = ({
moderation, moderation,
profile, profile,
disableHoverCard,
onBeforePress, onBeforePress,
...rest ...rest
}: PreviewableUserAvatarProps): React.ReactNode => { }: PreviewableUserAvatarProps): React.ReactNode => {
@@ -395,7 +397,7 @@ let PreviewableUserAvatar = ({
}, [profile, queryClient, onBeforePress]) }, [profile, queryClient, onBeforePress])
return ( return (
<ProfileHoverCard did={profile.did}> <ProfileHoverCard did={profile.did} disable={disableHoverCard}>
<Link <Link
label={_(msg`See profile`)} label={_(msg`See profile`)}
to={makeProfileLink({ to={makeProfileLink({
-5
View File
@@ -18496,11 +18496,6 @@ react-native-ios-context-menu@^1.15.3:
dependencies: dependencies:
"@dominicstop/ts-event-emitter" "^1.1.0" "@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: react-native-pager-view@6.2.3:
version "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" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"