Bluesky app fork with some witchin' additions 💫

Swipeable to delete chat, custom swipeable (#5614)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by hailey.at

Samuel Newman and committed by
GitHub
58b1d932 8d80f134

+632 -165
+1
assets/icons/envelope_open_stroke2_corner0_rounded.svg
···
··· 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>
+5
src/components/icons/EnveopeOpen.tsx
···
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Envelope_Open_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+410
src/lib/custom-animations/GestureActionView.tsx
···
··· 1 + import React from 'react' 2 + import {ColorValue, Dimensions, StyleSheet, View} from 'react-native' 3 + import {Gesture, GestureDetector} from 'react-native-gesture-handler' 4 + import Animated, { 5 + clamp, 6 + interpolate, 7 + interpolateColor, 8 + runOnJS, 9 + useAnimatedReaction, 10 + useAnimatedStyle, 11 + useDerivedValue, 12 + useReducedMotion, 13 + useSharedValue, 14 + withSequence, 15 + withTiming, 16 + } from 'react-native-reanimated' 17 + 18 + import {useHaptics} from '#/lib/haptics' 19 + 20 + interface GestureAction { 21 + color: ColorValue 22 + action: () => void 23 + threshold: number 24 + icon: React.ElementType 25 + } 26 + 27 + interface GestureActions { 28 + leftFirst?: GestureAction 29 + leftSecond?: GestureAction 30 + rightFirst?: GestureAction 31 + rightSecond?: GestureAction 32 + } 33 + 34 + const MAX_WIDTH = Dimensions.get('screen').width 35 + const ICON_SIZE = 32 36 + 37 + export function GestureActionView({ 38 + children, 39 + actions, 40 + }: { 41 + children: React.ReactNode 42 + actions: GestureActions 43 + }) { 44 + if ( 45 + (actions.leftSecond && !actions.leftFirst) || 46 + (actions.rightSecond && !actions.rightFirst) 47 + ) { 48 + throw new Error( 49 + 'You must provide the first action before the second action', 50 + ) 51 + } 52 + 53 + const [activeAction, setActiveAction] = React.useState< 54 + 'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null 55 + >(null) 56 + 57 + const haptic = useHaptics() 58 + const isReducedMotion = useReducedMotion() 59 + 60 + const transX = useSharedValue(0) 61 + const clampedTransX = useDerivedValue(() => { 62 + const min = actions.leftFirst ? -MAX_WIDTH : 0 63 + const max = actions.rightFirst ? MAX_WIDTH : 0 64 + return clamp(transX.value, min, max) 65 + }) 66 + 67 + const iconScale = useSharedValue(1) 68 + const isActive = useSharedValue(false) 69 + const hitFirst = useSharedValue(false) 70 + const hitSecond = useSharedValue(false) 71 + 72 + const runPopAnimation = () => { 73 + 'worklet' 74 + if (isReducedMotion) { 75 + return 76 + } 77 + 78 + iconScale.value = withSequence( 79 + withTiming(1.2, {duration: 175}), 80 + withTiming(1, {duration: 100}), 81 + ) 82 + } 83 + 84 + useAnimatedReaction( 85 + () => transX, 86 + () => { 87 + if (transX.value === 0) { 88 + runOnJS(setActiveAction)(null) 89 + } else if (transX.value < 0) { 90 + if ( 91 + actions.leftSecond && 92 + transX.value <= -actions.leftSecond.threshold 93 + ) { 94 + if (activeAction !== 'leftSecond') { 95 + runOnJS(setActiveAction)('leftSecond') 96 + } 97 + } else if (activeAction !== 'leftFirst') { 98 + runOnJS(setActiveAction)('leftFirst') 99 + } 100 + } else if (transX.value > 0) { 101 + if ( 102 + actions.rightSecond && 103 + transX.value > actions.rightSecond.threshold 104 + ) { 105 + if (activeAction !== 'rightSecond') { 106 + runOnJS(setActiveAction)('rightSecond') 107 + } 108 + } else if (activeAction !== 'rightFirst') { 109 + runOnJS(setActiveAction)('rightFirst') 110 + } 111 + } 112 + }, 113 + ) 114 + 115 + const panGesture = Gesture.Pan() 116 + .activeOffsetX([-10, 10]) 117 + // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll) 118 + // reanimated doesn't offer great support for disabling y/x axes :/ 119 + .activeOffsetY([-200, 200]) 120 + .onStart(() => { 121 + 'worklet' 122 + isActive.value = true 123 + }) 124 + .onChange(e => { 125 + 'worklet' 126 + transX.value = e.translationX 127 + 128 + if (e.translationX < 0) { 129 + // Left side 130 + if (actions.leftSecond) { 131 + if ( 132 + e.translationX <= -actions.leftSecond.threshold && 133 + !hitSecond.value 134 + ) { 135 + runPopAnimation() 136 + runOnJS(haptic)() 137 + hitSecond.value = true 138 + } else if ( 139 + hitSecond.value && 140 + e.translationX > -actions.leftSecond.threshold 141 + ) { 142 + runPopAnimation() 143 + hitSecond.value = false 144 + } 145 + } 146 + 147 + if (!hitSecond.value && actions.leftFirst) { 148 + if ( 149 + e.translationX <= -actions.leftFirst.threshold && 150 + !hitFirst.value 151 + ) { 152 + runPopAnimation() 153 + runOnJS(haptic)() 154 + hitFirst.value = true 155 + } else if ( 156 + hitFirst.value && 157 + e.translationX > -actions.leftFirst.threshold 158 + ) { 159 + hitFirst.value = false 160 + } 161 + } 162 + } else if (e.translationX > 0) { 163 + // Right side 164 + if (actions.rightSecond) { 165 + if ( 166 + e.translationX >= actions.rightSecond.threshold && 167 + !hitSecond.value 168 + ) { 169 + runPopAnimation() 170 + runOnJS(haptic)() 171 + hitSecond.value = true 172 + } else if ( 173 + hitSecond.value && 174 + e.translationX < actions.rightSecond.threshold 175 + ) { 176 + runPopAnimation() 177 + hitSecond.value = false 178 + } 179 + } 180 + 181 + if (!hitSecond.value && actions.rightFirst) { 182 + if ( 183 + e.translationX >= actions.rightFirst.threshold && 184 + !hitFirst.value 185 + ) { 186 + runPopAnimation() 187 + runOnJS(haptic)() 188 + hitFirst.value = true 189 + } else if ( 190 + hitFirst.value && 191 + e.translationX < actions.rightFirst.threshold 192 + ) { 193 + hitFirst.value = false 194 + } 195 + } 196 + } 197 + }) 198 + .onEnd(e => { 199 + 'worklet' 200 + if (e.translationX < 0) { 201 + if (hitSecond.value && actions.leftSecond) { 202 + runOnJS(actions.leftSecond.action)() 203 + } else if (hitFirst.value && actions.leftFirst) { 204 + runOnJS(actions.leftFirst.action)() 205 + } 206 + } else if (e.translationX > 0) { 207 + if (hitSecond.value && actions.rightSecond) { 208 + runOnJS(actions.rightSecond.action)() 209 + } else if (hitSecond.value && actions.rightFirst) { 210 + runOnJS(actions.rightFirst.action)() 211 + } 212 + } 213 + transX.value = withTiming(0, {duration: 200}) 214 + hitFirst.value = false 215 + hitSecond.value = false 216 + isActive.value = false 217 + }) 218 + 219 + const composedGesture = Gesture.Simultaneous(panGesture) 220 + 221 + const animatedSliderStyle = useAnimatedStyle(() => { 222 + return { 223 + transform: [{translateX: clampedTransX.value}], 224 + } 225 + }) 226 + 227 + const leftSideInterpolation = React.useMemo(() => { 228 + return createInterpolation({ 229 + firstColor: actions.leftFirst?.color, 230 + secondColor: actions.leftSecond?.color, 231 + firstThreshold: actions.leftFirst?.threshold, 232 + secondThreshold: actions.leftSecond?.threshold, 233 + side: 'left', 234 + }) 235 + }, [actions.leftFirst, actions.leftSecond]) 236 + 237 + const rightSideInterpolation = React.useMemo(() => { 238 + return createInterpolation({ 239 + firstColor: actions.rightFirst?.color, 240 + secondColor: actions.rightSecond?.color, 241 + firstThreshold: actions.rightFirst?.threshold, 242 + secondThreshold: actions.rightSecond?.threshold, 243 + side: 'right', 244 + }) 245 + }, [actions.rightFirst, actions.rightSecond]) 246 + 247 + const interpolation = React.useMemo<{ 248 + inputRange: number[] 249 + outputRange: ColorValue[] 250 + }>(() => { 251 + if (!actions.leftFirst) { 252 + return rightSideInterpolation! 253 + } else if (!actions.rightFirst) { 254 + return leftSideInterpolation! 255 + } else { 256 + return { 257 + inputRange: [ 258 + ...leftSideInterpolation.inputRange, 259 + ...rightSideInterpolation.inputRange, 260 + ], 261 + outputRange: [ 262 + ...leftSideInterpolation.outputRange, 263 + ...rightSideInterpolation.outputRange, 264 + ], 265 + } 266 + } 267 + }, [ 268 + leftSideInterpolation, 269 + rightSideInterpolation, 270 + actions.leftFirst, 271 + actions.rightFirst, 272 + ]) 273 + 274 + const animatedBackgroundStyle = useAnimatedStyle(() => { 275 + return { 276 + backgroundColor: interpolateColor( 277 + clampedTransX.value, 278 + interpolation.inputRange, 279 + // @ts-expect-error - Weird type expected by reanimated, but this is okay 280 + interpolation.outputRange, 281 + ), 282 + } 283 + }) 284 + 285 + const animatedIconStyle = useAnimatedStyle(() => { 286 + const absTransX = Math.abs(clampedTransX.value) 287 + return { 288 + opacity: interpolate(absTransX, [0, 75], [0.15, 1]), 289 + transform: [{scale: iconScale.value}], 290 + } 291 + }) 292 + 293 + return ( 294 + <GestureDetector gesture={composedGesture}> 295 + <View> 296 + <Animated.View 297 + style={[StyleSheet.absoluteFill, animatedBackgroundStyle]}> 298 + <View 299 + style={{ 300 + flex: 1, 301 + marginHorizontal: 12, 302 + justifyContent: 'center', 303 + alignItems: 304 + activeAction === 'leftFirst' || activeAction === 'leftSecond' 305 + ? 'flex-end' 306 + : 'flex-start', 307 + }}> 308 + <Animated.View style={[animatedIconStyle]}> 309 + {activeAction === 'leftFirst' && actions.leftFirst?.icon ? ( 310 + <actions.leftFirst.icon 311 + height={ICON_SIZE} 312 + width={ICON_SIZE} 313 + style={{ 314 + color: 'white', 315 + }} 316 + /> 317 + ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? ( 318 + <actions.leftSecond.icon 319 + height={ICON_SIZE} 320 + width={ICON_SIZE} 321 + style={{color: 'white'}} 322 + /> 323 + ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? ( 324 + <actions.rightFirst.icon 325 + height={ICON_SIZE} 326 + width={ICON_SIZE} 327 + style={{color: 'white'}} 328 + /> 329 + ) : activeAction === 'rightSecond' && 330 + actions.rightSecond?.icon ? ( 331 + <actions.rightSecond.icon 332 + height={ICON_SIZE} 333 + width={ICON_SIZE} 334 + style={{color: 'white'}} 335 + /> 336 + ) : null} 337 + </Animated.View> 338 + </View> 339 + </Animated.View> 340 + <Animated.View style={animatedSliderStyle}>{children}</Animated.View> 341 + </View> 342 + </GestureDetector> 343 + ) 344 + } 345 + 346 + function createInterpolation({ 347 + firstColor, 348 + secondColor, 349 + firstThreshold, 350 + secondThreshold, 351 + side, 352 + }: { 353 + firstColor?: ColorValue 354 + secondColor?: ColorValue 355 + firstThreshold?: number 356 + secondThreshold?: number 357 + side: 'left' | 'right' 358 + }): { 359 + inputRange: number[] 360 + outputRange: ColorValue[] 361 + } { 362 + if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) { 363 + throw new Error( 364 + 'You must provide a second color if you provide a second threshold', 365 + ) 366 + } 367 + 368 + if (!firstThreshold) { 369 + return { 370 + inputRange: [0], 371 + outputRange: ['transparent'], 372 + } 373 + } 374 + 375 + const offset = side === 'left' ? -20 : 20 376 + 377 + if (side === 'left') { 378 + firstThreshold = -firstThreshold 379 + 380 + if (secondThreshold) { 381 + secondThreshold = -secondThreshold 382 + } 383 + } 384 + 385 + let res 386 + if (secondThreshold) { 387 + res = { 388 + inputRange: [ 389 + 0, 390 + firstThreshold, 391 + firstThreshold + offset - 20, 392 + secondThreshold, 393 + ], 394 + outputRange: ['transparent', firstColor!, firstColor!, secondColor!], 395 + } 396 + } else { 397 + res = { 398 + inputRange: [0, firstThreshold], 399 + outputRange: ['transparent', firstColor!], 400 + } 401 + } 402 + 403 + if (side === 'left') { 404 + // Reverse the input/output ranges 405 + res.inputRange.reverse() 406 + res.outputRange.reverse() 407 + } 408 + 409 + return res 410 + }
+5
src/lib/custom-animations/GestureActionView.web.tsx
···
··· 1 + import React from 'react' 2 + 3 + export function GestureActionView({children}: {children: React.ReactNode}) { 4 + return children 5 + }
+211 -165
src/screens/Messages/components/ChatListItem.tsx
··· 1 - import React, {useCallback, useState} from 'react' 2 import {GestureResponderEvent, View} from 'react-native' 3 import { 4 AppBskyActorDefs, ··· 10 import {msg} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 import {useHaptics} from '#/lib/haptics' 14 import {decrementBadgeCount} from '#/lib/notifications/notifications' 15 import {logEvent} from '#/lib/statsig/statsig' ··· 22 import {isNative} from '#/platform/detection' 23 import {useProfileShadow} from '#/state/cache/profile-shadow' 24 import {useModerationOpts} from '#/state/preferences/moderation-opts' 25 import {useSession} from '#/state/session' 26 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 27 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 28 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 29 import * as tokens from '#/alf/tokens' 30 import {ConvoMenu} from '#/components/dms/ConvoMenu' 31 import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' 32 import {Link} from '#/components/Link' 33 import {useMenuControl} from '#/components/Menu' 34 import {PostAlerts} from '#/components/moderation/PostAlerts' ··· 74 const {_} = useLingui() 75 const {currentAccount} = useSession() 76 const menuControl = useMenuControl() 77 const {gtMobile} = useBreakpoints() 78 const profile = useProfileShadow(profileUnshadowed) 79 const moderation = React.useMemo( 80 () => moderateProfile(profile, moderationOpts), 81 [profile, moderationOpts], 82 ) 83 const playHaptic = useHaptics() 84 85 - const blockInfo = React.useMemo(() => { 86 const modui = moderation.ui('profileView') 87 const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 88 const listBlocks = blocks.filter(alert => alert.source.type === 'list') ··· 103 104 const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount 105 106 - const {lastMessage, lastMessageSentAt} = React.useMemo(() => { 107 let lastMessage = _(msg`No messages yet`) 108 let lastMessageSentAt: string | null = null 109 ··· 196 menuControl.open() 197 }, [playHaptic, menuControl]) 198 199 return ( 200 - <View 201 - // @ts-expect-error web only 202 - onMouseEnter={onMouseEnter} 203 - onMouseLeave={onMouseLeave} 204 - onFocus={onFocus} 205 - onBlur={onMouseLeave} 206 - style={[a.relative]}> 207 <View 208 - style={[ 209 - a.z_10, 210 - a.absolute, 211 - {top: tokens.space.md, left: tokens.space.lg}, 212 - ]}> 213 - <PreviewableUserAvatar 214 - profile={profile} 215 - size={52} 216 - moderation={moderation.ui('avatar')} 217 - /> 218 - </View> 219 220 - <Link 221 - to={`/messages/${convo.id}`} 222 - label={displayName} 223 - accessibilityHint={ 224 - !isDeletedAccount 225 - ? _(msg`Go to conversation with ${profile.handle}`) 226 - : _( 227 - msg`This conversation is with a deleted or a deactivated account. Press for options.`, 228 - ) 229 - } 230 - accessibilityActions={ 231 - isNative 232 - ? [ 233 - {name: 'magicTap', label: _(msg`Open conversation options`)}, 234 - {name: 'longpress', label: _(msg`Open conversation options`)}, 235 - ] 236 - : undefined 237 - } 238 - onPress={onPress} 239 - onLongPress={isNative ? onLongPress : undefined} 240 - onAccessibilityAction={onLongPress}> 241 - {({hovered, pressed, focused}) => ( 242 - <View 243 - style={[ 244 - a.flex_row, 245 - isDeletedAccount ? a.align_center : a.align_start, 246 - a.flex_1, 247 - a.px_lg, 248 - a.py_md, 249 - a.gap_md, 250 - (hovered || pressed || focused) && t.atoms.bg_contrast_25, 251 - t.atoms.border_contrast_low, 252 - ]}> 253 - {/* Avatar goes here */} 254 - <View style={{width: 52, height: 52}} /> 255 256 - <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> 257 - <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> 258 - <Text 259 - numberOfLines={1} 260 - style={[{maxWidth: '85%'}, web([a.leading_normal])]}> 261 <Text 262 - emoji 263 - style={[ 264 - a.text_md, 265 - t.atoms.text, 266 - a.font_bold, 267 - {lineHeight: 21}, 268 - isDimStyle && t.atoms.text_contrast_medium, 269 - ]}> 270 - {displayName} 271 </Text> 272 - </Text> 273 - {lastMessageSentAt && ( 274 - <TimeElapsed timestamp={lastMessageSentAt}> 275 - {({timeElapsed}) => ( 276 - <Text 277 - style={[ 278 - a.text_sm, 279 - {lineHeight: 21}, 280 - t.atoms.text_contrast_medium, 281 - web({whiteSpace: 'preserve nowrap'}), 282 - ]}> 283 - {' '} 284 - &middot; {timeElapsed} 285 - </Text> 286 - )} 287 - </TimeElapsed> 288 - )} 289 - {(convo.muted || moderation.blocked) && ( 290 <Text 291 - style={[ 292 - a.text_sm, 293 - {lineHeight: 21}, 294 - t.atoms.text_contrast_medium, 295 - web({whiteSpace: 'preserve nowrap'}), 296 - ]}> 297 - {' '} 298 - &middot;{' '} 299 - <BellStroke 300 - size="xs" 301 - style={[t.atoms.text_contrast_medium]} 302 - /> 303 </Text> 304 )} 305 - </View> 306 307 - {!isDeletedAccount && ( 308 <Text 309 - numberOfLines={1} 310 - style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> 311 - @{profile.handle} 312 </Text> 313 - )} 314 315 - <Text 316 - emoji 317 - numberOfLines={2} 318 - style={[ 319 - a.text_sm, 320 - a.leading_snug, 321 - convo.unreadCount > 0 322 - ? a.font_bold 323 - : t.atoms.text_contrast_high, 324 - isDimStyle && t.atoms.text_contrast_medium, 325 - ]}> 326 - {lastMessage} 327 - </Text> 328 329 - <PostAlerts 330 - modui={moderation.ui('contentList')} 331 - size="lg" 332 - style={[a.pt_xs]} 333 - /> 334 </View> 335 - 336 - {convo.unreadCount > 0 && ( 337 - <View 338 - style={[ 339 - a.absolute, 340 - a.rounded_full, 341 - { 342 - backgroundColor: isDimStyle 343 - ? t.palette.contrast_200 344 - : t.palette.primary_500, 345 - height: 7, 346 - width: 7, 347 - top: 15, 348 - right: 12, 349 - }, 350 - ]} 351 - /> 352 - )} 353 - </View> 354 - )} 355 - </Link> 356 357 - <ConvoMenu 358 - convo={convo} 359 - profile={profile} 360 - control={menuControl} 361 - currentScreen="list" 362 - showMarkAsRead={convo.unreadCount > 0} 363 - hideTrigger={isNative} 364 - blockInfo={blockInfo} 365 - style={[ 366 - a.absolute, 367 - a.h_full, 368 - a.self_end, 369 - a.justify_center, 370 - { 371 - right: tokens.space.lg, 372 - opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, 373 - }, 374 - ]} 375 - /> 376 - </View> 377 ) 378 }
··· 1 + import React, {useCallback, useMemo, useState} from 'react' 2 import {GestureResponderEvent, View} from 'react-native' 3 import { 4 AppBskyActorDefs, ··· 10 import {msg} from '@lingui/macro' 11 import {useLingui} from '@lingui/react' 12 13 + import {GestureActionView} from '#/lib/custom-animations/GestureActionView' 14 import {useHaptics} from '#/lib/haptics' 15 import {decrementBadgeCount} from '#/lib/notifications/notifications' 16 import {logEvent} from '#/lib/statsig/statsig' ··· 23 import {isNative} from '#/platform/detection' 24 import {useProfileShadow} from '#/state/cache/profile-shadow' 25 import {useModerationOpts} from '#/state/preferences/moderation-opts' 26 + import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' 27 import {useSession} from '#/state/session' 28 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 29 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 30 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 31 import * as tokens from '#/alf/tokens' 32 + import {useDialogControl} from '#/components/Dialog' 33 import {ConvoMenu} from '#/components/dms/ConvoMenu' 34 + import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 35 import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' 36 + import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' 37 + import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash' 38 import {Link} from '#/components/Link' 39 import {useMenuControl} from '#/components/Menu' 40 import {PostAlerts} from '#/components/moderation/PostAlerts' ··· 80 const {_} = useLingui() 81 const {currentAccount} = useSession() 82 const menuControl = useMenuControl() 83 + const leaveConvoControl = useDialogControl() 84 const {gtMobile} = useBreakpoints() 85 const profile = useProfileShadow(profileUnshadowed) 86 + const {mutate: markAsRead} = useMarkAsReadMutation() 87 const moderation = React.useMemo( 88 () => moderateProfile(profile, moderationOpts), 89 [profile, moderationOpts], 90 ) 91 const playHaptic = useHaptics() 92 + const isUnread = convo.unreadCount > 0 93 94 + const blockInfo = useMemo(() => { 95 const modui = moderation.ui('profileView') 96 const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 97 const listBlocks = blocks.filter(alert => alert.source.type === 'list') ··· 112 113 const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount 114 115 + const {lastMessage, lastMessageSentAt} = useMemo(() => { 116 let lastMessage = _(msg`No messages yet`) 117 let lastMessageSentAt: string | null = null 118 ··· 205 menuControl.open() 206 }, [playHaptic, menuControl]) 207 208 + const markReadAction = { 209 + threshold: 120, 210 + color: t.palette.primary_500, 211 + icon: EnvelopeOpen, 212 + action: () => { 213 + markAsRead({ 214 + convoId: convo.id, 215 + }) 216 + }, 217 + } 218 + 219 + const deleteAction = { 220 + threshold: 225, 221 + color: t.palette.negative_500, 222 + icon: Trash_Stroke2_Corner0_Rounded, 223 + action: () => { 224 + leaveConvoControl.open() 225 + }, 226 + } 227 + 228 + const actions = isUnread 229 + ? { 230 + leftFirst: markReadAction, 231 + leftSecond: deleteAction, 232 + } 233 + : { 234 + leftFirst: deleteAction, 235 + } 236 + 237 return ( 238 + <GestureActionView actions={actions}> 239 <View 240 + // @ts-expect-error web only 241 + onMouseEnter={onMouseEnter} 242 + onMouseLeave={onMouseLeave} 243 + onFocus={onFocus} 244 + onBlur={onMouseLeave} 245 + style={[a.relative, t.atoms.bg]}> 246 + <View 247 + style={[ 248 + a.z_10, 249 + a.absolute, 250 + {top: tokens.space.md, left: tokens.space.lg}, 251 + ]}> 252 + <PreviewableUserAvatar 253 + profile={profile} 254 + size={52} 255 + moderation={moderation.ui('avatar')} 256 + /> 257 + </View> 258 259 + <Link 260 + to={`/messages/${convo.id}`} 261 + label={displayName} 262 + accessibilityHint={ 263 + !isDeletedAccount 264 + ? _(msg`Go to conversation with ${profile.handle}`) 265 + : _( 266 + msg`This conversation is with a deleted or a deactivated account. Press for options.`, 267 + ) 268 + } 269 + accessibilityActions={ 270 + isNative 271 + ? [ 272 + {name: 'magicTap', label: _(msg`Open conversation options`)}, 273 + {name: 'longpress', label: _(msg`Open conversation options`)}, 274 + ] 275 + : undefined 276 + } 277 + onPress={onPress} 278 + onLongPress={isNative ? onLongPress : undefined} 279 + onAccessibilityAction={onLongPress}> 280 + {({hovered, pressed, focused}) => ( 281 + <View 282 + style={[ 283 + a.flex_row, 284 + isDeletedAccount ? a.align_center : a.align_start, 285 + a.flex_1, 286 + a.px_lg, 287 + a.py_md, 288 + a.gap_md, 289 + (hovered || pressed || focused) && t.atoms.bg_contrast_25, 290 + t.atoms.border_contrast_low, 291 + ]}> 292 + {/* Avatar goes here */} 293 + <View style={{width: 52, height: 52}} /> 294 295 + <View 296 + style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> 297 + <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> 298 <Text 299 + numberOfLines={1} 300 + style={[{maxWidth: '85%'}, web([a.leading_normal])]}> 301 + <Text 302 + emoji 303 + style={[ 304 + a.text_md, 305 + t.atoms.text, 306 + a.font_bold, 307 + {lineHeight: 21}, 308 + isDimStyle && t.atoms.text_contrast_medium, 309 + ]}> 310 + {displayName} 311 + </Text> 312 </Text> 313 + {lastMessageSentAt && ( 314 + <TimeElapsed timestamp={lastMessageSentAt}> 315 + {({timeElapsed}) => ( 316 + <Text 317 + style={[ 318 + a.text_sm, 319 + {lineHeight: 21}, 320 + t.atoms.text_contrast_medium, 321 + web({whiteSpace: 'preserve nowrap'}), 322 + ]}> 323 + {' '} 324 + &middot; {timeElapsed} 325 + </Text> 326 + )} 327 + </TimeElapsed> 328 + )} 329 + {(convo.muted || moderation.blocked) && ( 330 + <Text 331 + style={[ 332 + a.text_sm, 333 + {lineHeight: 21}, 334 + t.atoms.text_contrast_medium, 335 + web({whiteSpace: 'preserve nowrap'}), 336 + ]}> 337 + {' '} 338 + &middot;{' '} 339 + <BellStroke 340 + size="xs" 341 + style={[t.atoms.text_contrast_medium]} 342 + /> 343 + </Text> 344 + )} 345 + </View> 346 + 347 + {!isDeletedAccount && ( 348 <Text 349 + numberOfLines={1} 350 + style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> 351 + @{profile.handle} 352 </Text> 353 )} 354 355 <Text 356 + emoji 357 + numberOfLines={2} 358 + style={[ 359 + a.text_sm, 360 + a.leading_snug, 361 + convo.unreadCount > 0 362 + ? a.font_bold 363 + : t.atoms.text_contrast_high, 364 + isDimStyle && t.atoms.text_contrast_medium, 365 + ]}> 366 + {lastMessage} 367 </Text> 368 369 + <PostAlerts 370 + modui={moderation.ui('contentList')} 371 + size="lg" 372 + style={[a.pt_xs]} 373 + /> 374 + </View> 375 376 + {convo.unreadCount > 0 && ( 377 + <View 378 + style={[ 379 + a.absolute, 380 + a.rounded_full, 381 + { 382 + backgroundColor: isDimStyle 383 + ? t.palette.contrast_200 384 + : t.palette.primary_500, 385 + height: 7, 386 + width: 7, 387 + top: 15, 388 + right: 12, 389 + }, 390 + ]} 391 + /> 392 + )} 393 </View> 394 + )} 395 + </Link> 396 397 + <ConvoMenu 398 + convo={convo} 399 + profile={profile} 400 + control={menuControl} 401 + currentScreen="list" 402 + showMarkAsRead={convo.unreadCount > 0} 403 + hideTrigger={isNative} 404 + blockInfo={blockInfo} 405 + style={[ 406 + a.absolute, 407 + a.h_full, 408 + a.self_end, 409 + a.justify_center, 410 + { 411 + right: tokens.space.lg, 412 + opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, 413 + }, 414 + ]} 415 + /> 416 + <LeaveConvoPrompt 417 + control={leaveConvoControl} 418 + convoId={convo.id} 419 + currentScreen="list" 420 + /> 421 + </View> 422 + </GestureActionView> 423 ) 424 }