Swipeable to delete chat, custom swipeable (#5614)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Hailey
2024-10-07 11:15:17 -07:00
committed by GitHub
parent 8d80f1344d
commit 58b1d9326d
5 changed files with 637 additions and 170 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 426 B

+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Envelope_Open_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z',
})
@@ -0,0 +1,410 @@
import React from 'react'
import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
interpolateColor,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useReducedMotion,
useSharedValue,
withSequence,
withTiming,
} from 'react-native-reanimated'
import {useHaptics} from '#/lib/haptics'
interface GestureAction {
color: ColorValue
action: () => void
threshold: number
icon: React.ElementType
}
interface GestureActions {
leftFirst?: GestureAction
leftSecond?: GestureAction
rightFirst?: GestureAction
rightSecond?: GestureAction
}
const MAX_WIDTH = Dimensions.get('screen').width
const ICON_SIZE = 32
export function GestureActionView({
children,
actions,
}: {
children: React.ReactNode
actions: GestureActions
}) {
if (
(actions.leftSecond && !actions.leftFirst) ||
(actions.rightSecond && !actions.rightFirst)
) {
throw new Error(
'You must provide the first action before the second action',
)
}
const [activeAction, setActiveAction] = React.useState<
'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null
>(null)
const haptic = useHaptics()
const isReducedMotion = useReducedMotion()
const transX = useSharedValue(0)
const clampedTransX = useDerivedValue(() => {
const min = actions.leftFirst ? -MAX_WIDTH : 0
const max = actions.rightFirst ? MAX_WIDTH : 0
return clamp(transX.value, min, max)
})
const iconScale = useSharedValue(1)
const isActive = useSharedValue(false)
const hitFirst = useSharedValue(false)
const hitSecond = useSharedValue(false)
const runPopAnimation = () => {
'worklet'
if (isReducedMotion) {
return
}
iconScale.value = withSequence(
withTiming(1.2, {duration: 175}),
withTiming(1, {duration: 100}),
)
}
useAnimatedReaction(
() => transX,
() => {
if (transX.value === 0) {
runOnJS(setActiveAction)(null)
} else if (transX.value < 0) {
if (
actions.leftSecond &&
transX.value <= -actions.leftSecond.threshold
) {
if (activeAction !== 'leftSecond') {
runOnJS(setActiveAction)('leftSecond')
}
} else if (activeAction !== 'leftFirst') {
runOnJS(setActiveAction)('leftFirst')
}
} else if (transX.value > 0) {
if (
actions.rightSecond &&
transX.value > actions.rightSecond.threshold
) {
if (activeAction !== 'rightSecond') {
runOnJS(setActiveAction)('rightSecond')
}
} else if (activeAction !== 'rightFirst') {
runOnJS(setActiveAction)('rightFirst')
}
}
},
)
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10])
// Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
// reanimated doesn't offer great support for disabling y/x axes :/
.activeOffsetY([-200, 200])
.onStart(() => {
'worklet'
isActive.value = true
})
.onChange(e => {
'worklet'
transX.value = e.translationX
if (e.translationX < 0) {
// Left side
if (actions.leftSecond) {
if (
e.translationX <= -actions.leftSecond.threshold &&
!hitSecond.value
) {
runPopAnimation()
runOnJS(haptic)()
hitSecond.value = true
} else if (
hitSecond.value &&
e.translationX > -actions.leftSecond.threshold
) {
runPopAnimation()
hitSecond.value = false
}
}
if (!hitSecond.value && actions.leftFirst) {
if (
e.translationX <= -actions.leftFirst.threshold &&
!hitFirst.value
) {
runPopAnimation()
runOnJS(haptic)()
hitFirst.value = true
} else if (
hitFirst.value &&
e.translationX > -actions.leftFirst.threshold
) {
hitFirst.value = false
}
}
} else if (e.translationX > 0) {
// Right side
if (actions.rightSecond) {
if (
e.translationX >= actions.rightSecond.threshold &&
!hitSecond.value
) {
runPopAnimation()
runOnJS(haptic)()
hitSecond.value = true
} else if (
hitSecond.value &&
e.translationX < actions.rightSecond.threshold
) {
runPopAnimation()
hitSecond.value = false
}
}
if (!hitSecond.value && actions.rightFirst) {
if (
e.translationX >= actions.rightFirst.threshold &&
!hitFirst.value
) {
runPopAnimation()
runOnJS(haptic)()
hitFirst.value = true
} else if (
hitFirst.value &&
e.translationX < actions.rightFirst.threshold
) {
hitFirst.value = false
}
}
}
})
.onEnd(e => {
'worklet'
if (e.translationX < 0) {
if (hitSecond.value && actions.leftSecond) {
runOnJS(actions.leftSecond.action)()
} else if (hitFirst.value && actions.leftFirst) {
runOnJS(actions.leftFirst.action)()
}
} else if (e.translationX > 0) {
if (hitSecond.value && actions.rightSecond) {
runOnJS(actions.rightSecond.action)()
} else if (hitSecond.value && actions.rightFirst) {
runOnJS(actions.rightFirst.action)()
}
}
transX.value = withTiming(0, {duration: 200})
hitFirst.value = false
hitSecond.value = false
isActive.value = false
})
const composedGesture = Gesture.Simultaneous(panGesture)
const animatedSliderStyle = useAnimatedStyle(() => {
return {
transform: [{translateX: clampedTransX.value}],
}
})
const leftSideInterpolation = React.useMemo(() => {
return createInterpolation({
firstColor: actions.leftFirst?.color,
secondColor: actions.leftSecond?.color,
firstThreshold: actions.leftFirst?.threshold,
secondThreshold: actions.leftSecond?.threshold,
side: 'left',
})
}, [actions.leftFirst, actions.leftSecond])
const rightSideInterpolation = React.useMemo(() => {
return createInterpolation({
firstColor: actions.rightFirst?.color,
secondColor: actions.rightSecond?.color,
firstThreshold: actions.rightFirst?.threshold,
secondThreshold: actions.rightSecond?.threshold,
side: 'right',
})
}, [actions.rightFirst, actions.rightSecond])
const interpolation = React.useMemo<{
inputRange: number[]
outputRange: ColorValue[]
}>(() => {
if (!actions.leftFirst) {
return rightSideInterpolation!
} else if (!actions.rightFirst) {
return leftSideInterpolation!
} else {
return {
inputRange: [
...leftSideInterpolation.inputRange,
...rightSideInterpolation.inputRange,
],
outputRange: [
...leftSideInterpolation.outputRange,
...rightSideInterpolation.outputRange,
],
}
}
}, [
leftSideInterpolation,
rightSideInterpolation,
actions.leftFirst,
actions.rightFirst,
])
const animatedBackgroundStyle = useAnimatedStyle(() => {
return {
backgroundColor: interpolateColor(
clampedTransX.value,
interpolation.inputRange,
// @ts-expect-error - Weird type expected by reanimated, but this is okay
interpolation.outputRange,
),
}
})
const animatedIconStyle = useAnimatedStyle(() => {
const absTransX = Math.abs(clampedTransX.value)
return {
opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
transform: [{scale: iconScale.value}],
}
})
return (
<GestureDetector gesture={composedGesture}>
<View>
<Animated.View
style={[StyleSheet.absoluteFill, animatedBackgroundStyle]}>
<View
style={{
flex: 1,
marginHorizontal: 12,
justifyContent: 'center',
alignItems:
activeAction === 'leftFirst' || activeAction === 'leftSecond'
? 'flex-end'
: 'flex-start',
}}>
<Animated.View style={[animatedIconStyle]}>
{activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
<actions.leftFirst.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{
color: 'white',
}}
/>
) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
<actions.leftSecond.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{color: 'white'}}
/>
) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
<actions.rightFirst.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{color: 'white'}}
/>
) : activeAction === 'rightSecond' &&
actions.rightSecond?.icon ? (
<actions.rightSecond.icon
height={ICON_SIZE}
width={ICON_SIZE}
style={{color: 'white'}}
/>
) : null}
</Animated.View>
</View>
</Animated.View>
<Animated.View style={animatedSliderStyle}>{children}</Animated.View>
</View>
</GestureDetector>
)
}
function createInterpolation({
firstColor,
secondColor,
firstThreshold,
secondThreshold,
side,
}: {
firstColor?: ColorValue
secondColor?: ColorValue
firstThreshold?: number
secondThreshold?: number
side: 'left' | 'right'
}): {
inputRange: number[]
outputRange: ColorValue[]
} {
if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
throw new Error(
'You must provide a second color if you provide a second threshold',
)
}
if (!firstThreshold) {
return {
inputRange: [0],
outputRange: ['transparent'],
}
}
const offset = side === 'left' ? -20 : 20
if (side === 'left') {
firstThreshold = -firstThreshold
if (secondThreshold) {
secondThreshold = -secondThreshold
}
}
let res
if (secondThreshold) {
res = {
inputRange: [
0,
firstThreshold,
firstThreshold + offset - 20,
secondThreshold,
],
outputRange: ['transparent', firstColor!, firstColor!, secondColor!],
}
} else {
res = {
inputRange: [0, firstThreshold],
outputRange: ['transparent', firstColor!],
}
}
if (side === 'left') {
// Reverse the input/output ranges
res.inputRange.reverse()
res.outputRange.reverse()
}
return res
}
@@ -0,0 +1,5 @@
import React from 'react'
export function GestureActionView({children}: {children: React.ReactNode}) {
return children
}
+216 -170
View File
@@ -1,4 +1,4 @@
import React, {useCallback, useState} from 'react'
import React, {useCallback, useMemo, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {
AppBskyActorDefs,
@@ -10,6 +10,7 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
import {useHaptics} from '#/lib/haptics'
import {decrementBadgeCount} from '#/lib/notifications/notifications'
import {logEvent} from '#/lib/statsig/statsig'
@@ -22,13 +23,18 @@ import {
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import * as tokens from '#/alf/tokens'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen'
import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash'
import {Link} from '#/components/Link'
import {useMenuControl} from '#/components/Menu'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -74,15 +80,18 @@ function ChatListItemReady({
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {gtMobile} = useBreakpoints()
const profile = useProfileShadow(profileUnshadowed)
const {mutate: markAsRead} = useMarkAsReadMutation()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const playHaptic = useHaptics()
const isUnread = convo.unreadCount > 0
const blockInfo = React.useMemo(() => {
const blockInfo = useMemo(() => {
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
@@ -103,7 +112,7 @@ function ChatListItemReady({
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
const {lastMessage, lastMessageSentAt} = useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
@@ -196,183 +205,220 @@ function ChatListItemReady({
menuControl.open()
}, [playHaptic, menuControl])
const markReadAction = {
threshold: 120,
color: t.palette.primary_500,
icon: EnvelopeOpen,
action: () => {
markAsRead({
convoId: convo.id,
})
},
}
const deleteAction = {
threshold: 225,
color: t.palette.negative_500,
icon: Trash_Stroke2_Corner0_Rounded,
action: () => {
leaveConvoControl.open()
},
}
const actions = isUnread
? {
leftFirst: markReadAction,
leftSecond: deleteAction,
}
: {
leftFirst: deleteAction,
}
return (
<View
// @ts-expect-error web only
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={[a.relative]}>
<GestureActionView actions={actions}>
<View
style={[
a.z_10,
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
]}>
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
</View>
// @ts-expect-error web only
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onMouseLeave}
style={[a.relative, t.atoms.bg]}>
<View
style={[
a.z_10,
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
]}>
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
</View>
<Link
to={`/messages/${convo.id}`}
label={displayName}
accessibilityHint={
!isDeletedAccount
? _(msg`Go to conversation with ${profile.handle}`)
: _(
msg`This conversation is with a deleted or a deactivated account. Press for options.`,
)
}
accessibilityActions={
isNative
? [
{name: 'magicTap', label: _(msg`Open conversation options`)},
{name: 'longpress', label: _(msg`Open conversation options`)},
]
: undefined
}
onPress={onPress}
onLongPress={isNative ? onLongPress : undefined}
onAccessibilityAction={onLongPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_row,
isDeletedAccount ? a.align_center : a.align_start,
a.flex_1,
a.px_lg,
a.py_md,
a.gap_md,
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
{/* Avatar goes here */}
<View style={{width: 52, height: 52}} />
<Link
to={`/messages/${convo.id}`}
label={displayName}
accessibilityHint={
!isDeletedAccount
? _(msg`Go to conversation with ${profile.handle}`)
: _(
msg`This conversation is with a deleted or a deactivated account. Press for options.`,
)
}
accessibilityActions={
isNative
? [
{name: 'magicTap', label: _(msg`Open conversation options`)},
{name: 'longpress', label: _(msg`Open conversation options`)},
]
: undefined
}
onPress={onPress}
onLongPress={isNative ? onLongPress : undefined}
onAccessibilityAction={onLongPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_row,
isDeletedAccount ? a.align_center : a.align_start,
a.flex_1,
a.px_lg,
a.py_md,
a.gap_md,
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
{/* Avatar goes here */}
<View style={{width: 52, height: 52}} />
<View
style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
<View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
<Text
numberOfLines={1}
style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
<Text
emoji
style={[
a.text_md,
t.atoms.text,
a.font_bold,
{lineHeight: 21},
isDimStyle && t.atoms.text_contrast_medium,
]}>
{displayName}
</Text>
</Text>
{lastMessageSentAt && (
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text
style={[
a.text_sm,
{lineHeight: 21},
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{' '}
&middot; {timeElapsed}
</Text>
)}
</TimeElapsed>
)}
{(convo.muted || moderation.blocked) && (
<Text
style={[
a.text_sm,
{lineHeight: 21},
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{' '}
&middot;{' '}
<BellStroke
size="xs"
style={[t.atoms.text_contrast_medium]}
/>
</Text>
)}
</View>
{!isDeletedAccount && (
<Text
numberOfLines={1}
style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}>
@{profile.handle}
</Text>
)}
<View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
<View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
<Text
numberOfLines={1}
style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
<Text
emoji
style={[
a.text_md,
t.atoms.text,
a.font_bold,
{lineHeight: 21},
isDimStyle && t.atoms.text_contrast_medium,
]}>
{displayName}
</Text>
emoji
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
convo.unreadCount > 0
? a.font_bold
: t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
{lastMessageSentAt && (
<TimeElapsed timestamp={lastMessageSentAt}>
{({timeElapsed}) => (
<Text
style={[
a.text_sm,
{lineHeight: 21},
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{' '}
&middot; {timeElapsed}
</Text>
)}
</TimeElapsed>
)}
{(convo.muted || moderation.blocked) && (
<Text
style={[
a.text_sm,
{lineHeight: 21},
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{' '}
&middot;{' '}
<BellStroke
size="xs"
style={[t.atoms.text_contrast_medium]}
/>
</Text>
)}
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
</View>
{!isDeletedAccount && (
<Text
numberOfLines={1}
style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}>
@{profile.handle}
</Text>
{convo.unreadCount > 0 && (
<View
style={[
a.absolute,
a.rounded_full,
{
backgroundColor: isDimStyle
? t.palette.contrast_200
: t.palette.primary_500,
height: 7,
width: 7,
top: 15,
right: 12,
},
]}
/>
)}
<Text
emoji
numberOfLines={2}
style={[
a.text_sm,
a.leading_snug,
convo.unreadCount > 0
? a.font_bold
: t.atoms.text_contrast_high,
isDimStyle && t.atoms.text_contrast_medium,
]}>
{lastMessage}
</Text>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
</View>
)}
</Link>
{convo.unreadCount > 0 && (
<View
style={[
a.absolute,
a.rounded_full,
{
backgroundColor: isDimStyle
? t.palette.contrast_200
: t.palette.primary_500,
height: 7,
width: 7,
top: 15,
right: 12,
},
]}
/>
)}
</View>
)}
</Link>
<ConvoMenu
convo={convo}
profile={profile}
control={menuControl}
currentScreen="list"
showMarkAsRead={convo.unreadCount > 0}
hideTrigger={isNative}
blockInfo={blockInfo}
style={[
a.absolute,
a.h_full,
a.self_end,
a.justify_center,
{
right: tokens.space.lg,
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
},
]}
/>
</View>
<ConvoMenu
convo={convo}
profile={profile}
control={menuControl}
currentScreen="list"
showMarkAsRead={convo.unreadCount > 0}
hideTrigger={isNative}
blockInfo={blockInfo}
style={[
a.absolute,
a.h_full,
a.self_end,
a.justify_center,
{
right: tokens.space.lg,
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
},
]}
/>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
currentScreen="list"
/>
</View>
</GestureActionView>
)
}