mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Clipclops] Message actions for native and web (#3807)

* haptic on long press

* add animation to press and hold

* eslint disable for now

* adjust styles

* dont trigger if animation is cancelled

* organize

* add a delete menu

* reset scale automatically

* message actions dialog

cleanup

center the trigger

handle focus/unfocus better

make triggers accessible

weg dropdown menu

add a wep specific wrapper

decrease press delay

add report button

improve shrink logic

use `self_end` instead of `margin: auto`

rm extra `?`

move `MessageItem` to `components`

add delete button

* rm some padding

* update after merge

* fix merge

* web only types

* fix crash

* add an explanation

* fix web types

---------

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

authored by hailey.at

Samuel Newman and committed by
GitHub
8ba1b10c 6da18e3d

+297 -29
+82
src/components/dms/ActionsWrapper.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import Animated, { 4 + cancelAnimation, 5 + runOnJS, 6 + useAnimatedStyle, 7 + useSharedValue, 8 + withTiming, 9 + } from 'react-native-reanimated' 10 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 11 + 12 + import {useHaptics} from 'lib/haptics' 13 + import {atoms as a} from '#/alf' 14 + import {MessageMenu} from '#/components/dms/MessageMenu' 15 + import {useMenuControl} from '#/components/Menu' 16 + 17 + const AnimatedPressable = Animated.createAnimatedComponent(Pressable) 18 + 19 + export function ActionsWrapper({ 20 + message, 21 + isFromSelf, 22 + children, 23 + }: { 24 + message: ChatBskyConvoDefs.MessageView 25 + isFromSelf: boolean 26 + children: React.ReactNode 27 + }) { 28 + const playHaptic = useHaptics() 29 + const menuControl = useMenuControl() 30 + 31 + const scale = useSharedValue(1) 32 + const animationDidComplete = useSharedValue(false) 33 + 34 + const animatedStyle = useAnimatedStyle(() => ({ 35 + transform: [{scale: scale.value}], 36 + })) 37 + 38 + // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this 39 + // function 40 + const open = useCallback(() => { 41 + menuControl.open() 42 + }, [menuControl]) 43 + 44 + const shrink = useCallback(() => { 45 + 'worklet' 46 + cancelAnimation(scale) 47 + scale.value = withTiming(1, {duration: 200}, () => { 48 + animationDidComplete.value = false 49 + }) 50 + }, [animationDidComplete, scale]) 51 + 52 + const grow = React.useCallback(() => { 53 + 'worklet' 54 + scale.value = withTiming(1.05, {duration: 750}, finished => { 55 + if (!finished) return 56 + animationDidComplete.value = true 57 + runOnJS(playHaptic)() 58 + runOnJS(open)() 59 + 60 + shrink() 61 + }) 62 + }, [scale, animationDidComplete, playHaptic, shrink, open]) 63 + 64 + return ( 65 + <View 66 + style={[ 67 + { 68 + maxWidth: '65%', 69 + }, 70 + isFromSelf ? a.self_end : a.self_start, 71 + ]}> 72 + <AnimatedPressable 73 + style={animatedStyle} 74 + unstable_pressDelay={200} 75 + onPressIn={grow} 76 + onTouchEnd={shrink}> 77 + {children} 78 + </AnimatedPressable> 79 + <MessageMenu message={message} control={menuControl} hideTrigger={true} /> 80 + </View> 81 + ) 82 + }
+86
src/components/dms/ActionsWrapper.web.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 4 + 5 + import {atoms as a} from '#/alf' 6 + import {MessageMenu} from '#/components/dms/MessageMenu' 7 + import {useMenuControl} from '#/components/Menu' 8 + 9 + export function ActionsWrapper({ 10 + message, 11 + isFromSelf, 12 + children, 13 + }: { 14 + message: ChatBskyConvoDefs.MessageView 15 + isFromSelf: boolean 16 + children: React.ReactNode 17 + }) { 18 + const menuControl = useMenuControl() 19 + const viewRef = React.useRef(null) 20 + 21 + const [showActions, setShowActions] = React.useState(false) 22 + 23 + const onMouseEnter = React.useCallback(() => { 24 + setShowActions(true) 25 + }, []) 26 + 27 + const onMouseLeave = React.useCallback(() => { 28 + setShowActions(false) 29 + }, []) 30 + 31 + // We need to handle the `onFocus` separately because we want to know if there is a related target (the element 32 + // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed. 33 + const onFocus = React.useCallback<React.FocusEventHandler>(e => { 34 + if (e.nativeEvent.relatedTarget == null) return 35 + setShowActions(true) 36 + }, []) 37 + 38 + return ( 39 + <View 40 + // @ts-expect-error web only 41 + onMouseEnter={onMouseEnter} 42 + onMouseLeave={onMouseLeave} 43 + onFocus={onFocus} 44 + onBlur={onMouseLeave} 45 + style={StyleSheet.flatten([a.flex_1, a.flex_row])} 46 + ref={viewRef}> 47 + {isFromSelf && ( 48 + <View 49 + style={[ 50 + a.mr_xl, 51 + a.justify_center, 52 + { 53 + marginLeft: 'auto', 54 + }, 55 + ]}> 56 + <MessageMenu 57 + message={message} 58 + control={menuControl} 59 + triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} 60 + onTriggerPress={onMouseEnter} 61 + // @ts-expect-error web only 62 + onMouseLeave={onMouseLeave} 63 + /> 64 + </View> 65 + )} 66 + <View 67 + style={{ 68 + maxWidth: '65%', 69 + }}> 70 + {children} 71 + </View> 72 + {!isFromSelf && ( 73 + <View style={[a.flex_row, a.align_center, a.ml_xl]}> 74 + <MessageMenu 75 + message={message} 76 + control={menuControl} 77 + triggerOpacity={showActions || menuControl.isOpen ? 1 : 0} 78 + onTriggerPress={onMouseEnter} 79 + // @ts-expect-error web only 80 + onMouseLeave={onMouseLeave} 81 + /> 82 + </View> 83 + )} 84 + </View> 85 + ) 86 + }
+99
src/components/dms/MessageMenu.tsx
··· 1 + import React from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {ChatBskyConvoDefs} from '@atproto-labs/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {useSession} from 'state/session' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 10 + import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 11 + import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 12 + import * as Menu from '#/components/Menu' 13 + import * as Prompt from '#/components/Prompt' 14 + import {usePromptControl} from '#/components/Prompt' 15 + 16 + export let MessageMenu = ({ 17 + message, 18 + control, 19 + hideTrigger, 20 + triggerOpacity, 21 + }: { 22 + hideTrigger?: boolean 23 + triggerOpacity?: number 24 + onTriggerPress?: () => void 25 + message: ChatBskyConvoDefs.MessageView 26 + control: Menu.MenuControlProps 27 + }): React.ReactNode => { 28 + const {_} = useLingui() 29 + const t = useTheme() 30 + const {currentAccount} = useSession() 31 + const deleteControl = usePromptControl() 32 + 33 + const isFromSelf = message.sender?.did === currentAccount?.did 34 + 35 + const onDelete = React.useCallback(() => { 36 + // TODO delete the message 37 + }, []) 38 + 39 + const onReport = React.useCallback(() => { 40 + // TODO report the message 41 + }, []) 42 + 43 + return ( 44 + <> 45 + <Menu.Root control={control}> 46 + {!hideTrigger && ( 47 + <View style={{opacity: triggerOpacity}}> 48 + <Menu.Trigger label={_(msg`Chat settings`)}> 49 + {({props, state}) => ( 50 + <Pressable 51 + {...props} 52 + style={[ 53 + a.p_sm, 54 + a.rounded_full, 55 + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 56 + ]}> 57 + <DotsHorizontal size="sm" style={t.atoms.text} /> 58 + </Pressable> 59 + )} 60 + </Menu.Trigger> 61 + </View> 62 + )} 63 + 64 + <Menu.Outer> 65 + <Menu.Group> 66 + <Menu.Item 67 + testID="messageDropdownDeleteBtn" 68 + label={_(msg`Delete message`)} 69 + onPress={deleteControl.open}> 70 + <Menu.ItemText>{_(msg`Delete`)}</Menu.ItemText> 71 + <Menu.ItemIcon icon={Trash} position="right" /> 72 + </Menu.Item> 73 + {!isFromSelf && ( 74 + <Menu.Item 75 + testID="messageDropdownReportBtn" 76 + label={_(msg`Report message`)} 77 + onPress={onReport}> 78 + <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText> 79 + <Menu.ItemIcon icon={Warning} position="right" /> 80 + </Menu.Item> 81 + )} 82 + </Menu.Group> 83 + </Menu.Outer> 84 + </Menu.Root> 85 + 86 + <Prompt.Basic 87 + control={deleteControl} 88 + title={_(msg`Delete message`)} 89 + description={_( 90 + msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`, 91 + )} 92 + confirmButtonCta={_(msg`Delete`)} 93 + confirmButtonColor="negative" 94 + onConfirm={onDelete} 95 + /> 96 + </> 97 + ) 98 + } 99 + MessageMenu = React.memo(MessageMenu)
+29 -28
src/screens/Messages/Conversation/MessageItem.tsx src/components/dms/MessageItem.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {useSession} from '#/state/session' 8 - import {TimeElapsed} from '#/view/com/util/TimeElapsed' 8 + import {TimeElapsed} from 'view/com/util/TimeElapsed' 9 9 import {atoms as a, useTheme} from '#/alf' 10 + import {ActionsWrapper} from '#/components/dms/ActionsWrapper' 10 11 import {Text} from '#/components/Typography' 11 12 12 13 export function MessageItem({ ··· 50 51 51 52 return ( 52 53 <View> 53 - <View 54 - style={[ 55 - a.py_sm, 56 - a.px_lg, 57 - a.my_2xs, 58 - a.rounded_md, 59 - isFromSelf ? a.self_end : a.self_start, 60 - { 61 - maxWidth: '65%', 62 - backgroundColor: isFromSelf 63 - ? t.palette.primary_500 64 - : t.palette.contrast_50, 65 - borderRadius: 17, 66 - }, 67 - isFromSelf 68 - ? {borderBottomRightRadius: isLastInGroup ? 2 : 17} 69 - : {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, 70 - ]}> 71 - <Text 54 + <ActionsWrapper isFromSelf={isFromSelf} message={item}> 55 + <View 72 56 style={[ 73 - a.text_md, 74 - a.leading_snug, 75 - isFromSelf && {color: t.palette.white}, 57 + a.py_sm, 58 + a.px_lg, 59 + a.my_2xs, 60 + a.rounded_md, 61 + { 62 + backgroundColor: isFromSelf 63 + ? t.palette.primary_500 64 + : t.palette.contrast_50, 65 + borderRadius: 17, 66 + }, 67 + isFromSelf 68 + ? {borderBottomRightRadius: isLastInGroup ? 2 : 17} 69 + : {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, 76 70 ]}> 77 - {item.text} 78 - </Text> 79 - </View> 80 - <Metadata 71 + <Text 72 + style={[ 73 + a.text_md, 74 + a.leading_snug, 75 + isFromSelf && {color: t.palette.white}, 76 + ]}> 77 + {item.text} 78 + </Text> 79 + </View> 80 + </ActionsWrapper> 81 + <MessageItemMetadata 81 82 message={item} 82 83 isLastInGroup={isLastInGroup} 83 84 style={isFromSelf ? a.text_right : a.text_left} ··· 86 87 ) 87 88 } 88 89 89 - function Metadata({ 90 + export function MessageItemMetadata({ 90 91 message, 91 92 isLastInGroup, 92 93 style,
+1 -1
src/screens/Messages/Conversation/MessagesList.tsx
··· 17 17 import {ConvoItem, ConvoStatus} from '#/state/messages/convo' 18 18 import {useSetMinimalShellMode} from '#/state/shell' 19 19 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' 20 - import {MessageItem} from '#/screens/Messages/Conversation/MessageItem' 21 20 import {atoms as a} from '#/alf' 22 21 import {Button, ButtonText} from '#/components/Button' 22 + import {MessageItem} from '#/components/dms/MessageItem' 23 23 import {Loader} from '#/components/Loader' 24 24 import {Text} from '#/components/Typography' 25 25