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 0000000000..9854761944
--- /dev/null
+++ b/assets/icons/envelope_open_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/icons/EnveopeOpen.tsx b/src/components/icons/EnveopeOpen.tsx
new file mode 100644
index 0000000000..2873e89138
--- /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 0000000000..79e9db8a91
--- /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 (
+
+
+
+
+
+ {activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
+
+ ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
+
+ ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
+
+ ) : activeAction === 'rightSecond' &&
+ actions.rightSecond?.icon ? (
+
+ ) : null}
+
+
+
+ {children}
+
+
+ )
+}
+
+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 0000000000..3caaa724f8
--- /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 11c071082b..bb9c1cd4cb 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 (
-
+
-
-
+ // @ts-expect-error web only
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ onFocus={onFocus}
+ onBlur={onMouseLeave}
+ style={[a.relative, t.atoms.bg]}>
+
+
+
-
- {({hovered, pressed, focused}) => (
-
- {/* Avatar goes here */}
-
+
+ {({hovered, pressed, focused}) => (
+
+ {/* Avatar goes here */}
+
+
+
+
+
+
+ {displayName}
+
+
+ {lastMessageSentAt && (
+
+ {({timeElapsed}) => (
+
+ {' '}
+ · {timeElapsed}
+
+ )}
+
+ )}
+ {(convo.muted || moderation.blocked) && (
+
+ {' '}
+ ·{' '}
+
+
+ )}
+
+
+ {!isDeletedAccount && (
+
+ @{profile.handle}
+
+ )}
-
-
-
- {displayName}
-
+ 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}
- {lastMessageSentAt && (
-
- {({timeElapsed}) => (
-
- {' '}
- · {timeElapsed}
-
- )}
-
- )}
- {(convo.muted || moderation.blocked) && (
-
- {' '}
- ·{' '}
-
-
- )}
+
+
- {!isDeletedAccount && (
-
- @{profile.handle}
-
+ {convo.unreadCount > 0 && (
+
)}
-
- 0
- ? a.font_bold
- : t.atoms.text_contrast_high,
- isDimStyle && t.atoms.text_contrast_medium,
- ]}>
- {lastMessage}
-
-
-
+ )}
+
- {convo.unreadCount > 0 && (
-
- )}
-
- )}
-
-
- 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,
- },
- ]}
- />
-
+ 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,
+ },
+ ]}
+ />
+
+
+
)
}