diff options
author | Hailey <me@haileyok.com> | 2024-10-07 11:15:17 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-10-07 11:15:17 -0700 |
commit | 58b1d9326d7f5f308746e2471f5a7552bb0db250 (patch) | |
tree | 5e7a2d671ce4e4a010a1c967711cfd7fa9ec0c7d | |
parent | 8d80f1344df4897cfe4f754d37e654809850b794 (diff) | |
download | voidsky-58b1d9326d7f5f308746e2471f5a7552bb0db250.tar.zst |
Swipeable to delete chat, custom swipeable (#5614)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r-- | assets/icons/envelope_open_stroke2_corner0_rounded.svg | 1 | ||||
-rw-r--r-- | src/components/icons/EnveopeOpen.tsx | 5 | ||||
-rw-r--r-- | src/lib/custom-animations/GestureActionView.tsx | 410 | ||||
-rw-r--r-- | src/lib/custom-animations/GestureActionView.web.tsx | 5 | ||||
-rw-r--r-- | src/screens/Messages/components/ChatListItem.tsx | 380 |
5 files changed, 634 insertions, 167 deletions
diff --git a/assets/icons/envelope_open_stroke2_corner0_rounded.svg b/assets/icons/envelope_open_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..985476194 --- /dev/null +++ b/assets/icons/envelope_open_stroke2_corner0_rounded.svg @@ -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> diff --git a/src/components/icons/EnveopeOpen.tsx b/src/components/icons/EnveopeOpen.tsx new file mode 100644 index 000000000..2873e8913 --- /dev/null +++ b/src/components/icons/EnveopeOpen.tsx @@ -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', +}) diff --git a/src/lib/custom-animations/GestureActionView.tsx b/src/lib/custom-animations/GestureActionView.tsx new file mode 100644 index 000000000..79e9db8a9 --- /dev/null +++ b/src/lib/custom-animations/GestureActionView.tsx @@ -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 +} diff --git a/src/lib/custom-animations/GestureActionView.web.tsx b/src/lib/custom-animations/GestureActionView.web.tsx new file mode 100644 index 000000000..3caaa724f --- /dev/null +++ b/src/lib/custom-animations/GestureActionView.web.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export function GestureActionView({children}: {children: React.ReactNode}) { + return children +} diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index 11c071082..bb9c1cd4c 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -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}} /> - - <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])]}> + <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 - emoji - style={[ - a.text_md, - t.atoms.text, - a.font_bold, - {lineHeight: 21}, - isDimStyle && t.atoms.text_contrast_medium, - ]}> - {displayName} + 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> - </Text> - {lastMessageSentAt && ( - <TimeElapsed timestamp={lastMessageSentAt}> - {({timeElapsed}) => ( - <Text - style={[ - a.text_sm, - {lineHeight: 21}, - t.atoms.text_contrast_medium, - web({whiteSpace: 'preserve nowrap'}), - ]}> - {' '} - · {timeElapsed} - </Text> - )} - </TimeElapsed> - )} - {(convo.muted || moderation.blocked) && ( + {lastMessageSentAt && ( + <TimeElapsed timestamp={lastMessageSentAt}> + {({timeElapsed}) => ( + <Text + style={[ + a.text_sm, + {lineHeight: 21}, + t.atoms.text_contrast_medium, + web({whiteSpace: 'preserve nowrap'}), + ]}> + {' '} + · {timeElapsed} + </Text> + )} + </TimeElapsed> + )} + {(convo.muted || moderation.blocked) && ( + <Text + style={[ + a.text_sm, + {lineHeight: 21}, + t.atoms.text_contrast_medium, + web({whiteSpace: 'preserve nowrap'}), + ]}> + {' '} + ·{' '} + <BellStroke + size="xs" + style={[t.atoms.text_contrast_medium]} + /> + </Text> + )} + </View> + + {!isDeletedAccount && ( <Text - style={[ - a.text_sm, - {lineHeight: 21}, - t.atoms.text_contrast_medium, - web({whiteSpace: 'preserve nowrap'}), - ]}> - {' '} - ·{' '} - <BellStroke - size="xs" - style={[t.atoms.text_contrast_medium]} - /> + numberOfLines={1} + style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> + @{profile.handle} </Text> )} - </View> - {!isDeletedAccount && ( <Text - numberOfLines={1} - style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> - @{profile.handle} + 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> - )} - <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]} - /> + <PostAlerts + modui={moderation.ui('contentList')} + size="lg" + style={[a.pt_xs]} + /> + </View> + + {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> - {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> ) } |