Merge branch 'main' into hailey/proper-badge-count
This commit is contained in:
+1
-2
@@ -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 =
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export type ProfileHoverCardProps = {
|
|||||||
children: React.ReactElement
|
children: React.ReactElement
|
||||||
did: string
|
did: string
|
||||||
inline?: boolean
|
inline?: boolean
|
||||||
|
disable?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ let MessageItemMetadata = ({
|
|||||||
)}
|
)}
|
||||||
</TimeElapsed>
|
</TimeElapsed>
|
||||||
|
|
||||||
{item.type === 'pending-message' && item.retry && (
|
{item.type === 'pending-message' && item.failed && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
·{' '}
|
·{' '}
|
||||||
@@ -214,15 +214,20 @@ let MessageItemMetadata = ({
|
|||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
{_(msg`Failed to send`)}
|
{_(msg`Failed to send`)}
|
||||||
</Text>{' '}
|
</Text>
|
||||||
·{' '}
|
{item.retry && (
|
||||||
<InlineLinkText
|
<>
|
||||||
label={_(msg`Click to retry failed message`)}
|
{' '}
|
||||||
to="#"
|
·{' '}
|
||||||
onPress={handleRetry}
|
<InlineLinkText
|
||||||
style={[a.text_xs]}>
|
label={_(msg`Click to retry failed message`)}
|
||||||
{_(msg`Retry`)}
|
to="#"
|
||||||
</InlineLinkText>
|
onPress={handleRetry}
|
||||||
|
style={[a.text_xs]}>
|
||||||
|
{_(msg`Retry`)}
|
||||||
|
</InlineLinkText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} ·{' '}
|
||||||
size="small"
|
{item.retry && (
|
||||||
variant="ghost"
|
<InlineLinkText
|
||||||
color="secondary"
|
to="#"
|
||||||
onPress={e => {
|
label={help}
|
||||||
e.preventDefault()
|
onPress={e => {
|
||||||
item.retry()
|
e.preventDefault()
|
||||||
return false
|
item.retry?.()
|
||||||
}}>
|
return false
|
||||||
<ButtonText>{_(msg`Retry`)}</ButtonText>
|
}}>
|
||||||
<ButtonIcon icon={Refresh} position="right" />
|
{cta}
|
||||||
</Button>
|
</InlineLinkText>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
}
|
|
||||||
|
|
||||||
flatListRef.current?.scrollToOffset({
|
// If the size of the content is changing by more than the height of the screen, then we should only
|
||||||
animated: hasInitiallyScrolled.value,
|
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be
|
||||||
offset: height,
|
// 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
|
||||||
isMomentumScrolling.value = true
|
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,
|
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>
|
||||||
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
{showNewMessagesPill && <NewMessagesPill />}
|
||||||
|
<Animated.View style={[a.relative, t.atoms.bg, animatedInputStyle]}>
|
||||||
|
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
|
||||||
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,45 +105,33 @@ 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
|
<Header profile={convoState.recipients?.[0]} />
|
||||||
style={[
|
<View style={[a.flex_1]}>
|
||||||
a.flex_1,
|
{isConvoActive(convoState) ? (
|
||||||
isNative && {marginBottom: bottomInset + nativeBottomBarHeight},
|
<MessagesList />
|
||||||
]}
|
) : (
|
||||||
keyboardVerticalOffset={isIOS ? topInset : 0}
|
<ListMaybePlaceholder isLoading />
|
||||||
behavior="padding"
|
)}
|
||||||
contentContainerStyle={a.flex_1}>
|
{!hasInitiallyRendered && (
|
||||||
<CenteredView style={a.flex_1} sideBorders>
|
<View
|
||||||
<Header profile={convoState.recipients?.[0]} />
|
style={[
|
||||||
<View style={[a.flex_1]}>
|
a.absolute,
|
||||||
{isConvoActive(convoState) ? (
|
a.z_10,
|
||||||
<MessagesList />
|
a.w_full,
|
||||||
) : (
|
a.h_full,
|
||||||
<ListMaybePlaceholder isLoading />
|
a.justify_center,
|
||||||
)}
|
a.align_center,
|
||||||
{!hasInitiallyRendered && (
|
t.atoms.bg,
|
||||||
<View
|
]}>
|
||||||
style={[
|
<View style={[{marginBottom: 75}]}>
|
||||||
a.absolute,
|
<Loader size="xl" />
|
||||||
a.z_10,
|
</View>
|
||||||
a.w_full,
|
|
||||||
a.h_full,
|
|
||||||
a.justify_center,
|
|
||||||
a.align_center,
|
|
||||||
t.atoms.bg,
|
|
||||||
]}>
|
|
||||||
<View style={[{marginBottom: 75}]}>
|
|
||||||
<Loader size="xl" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
)}
|
||||||
</KeyboardAvoidingView>
|
</View>
|
||||||
</KeyboardProvider>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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]}
|
||||||
|
|||||||
@@ -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>
|
<Toggle.Item
|
||||||
<View style={[a.px_md, a.py_lg, a.gap_md]}>
|
name="all"
|
||||||
<Toggle.Item
|
label={_(msg`Everyone`)}
|
||||||
name="a"
|
style={[a.justify_between, a.py_sm]}>
|
||||||
label="Click me"
|
<Toggle.LabelText>
|
||||||
value={preferences.playSoundChat}
|
<Trans>Everyone</Trans>
|
||||||
onChange={() => {
|
</Toggle.LabelText>
|
||||||
setPref('playSoundChat', !preferences.playSoundChat)
|
<Toggle.Radio />
|
||||||
}}>
|
</Toggle.Item>
|
||||||
<Toggle.Checkbox />
|
<Toggle.Item
|
||||||
<Toggle.LabelText>Notification Sounds</Toggle.LabelText>
|
name="following"
|
||||||
</Toggle.Item>
|
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}
|
||||||
|
onChange={() => {
|
||||||
|
setPref('playSoundChat', !preferences.playSoundChat)
|
||||||
|
}}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<Toggle.LabelText>
|
||||||
|
<Trans>Play notification sounds</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</Toggle.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.pendingFailed = false
|
this.pendingMessageFailure === 'recoverable'
|
||||||
this.commit()
|
? () => {
|
||||||
this.batchRetryPendingMessages()
|
this.maybeRecoverFromNetworkError()
|
||||||
}
|
}
|
||||||
: 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 => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user