diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-03-28 08:43:40 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-03-28 08:43:40 +0200 |
commit | 55a40c2436b68dea850e54a65c5dd197132c08e4 (patch) | |
tree | e6d4d2d45ce5a3475aa4f73556910ff7d818986f /src/components/dms | |
parent | ac2c2a9a1d2d09442a497dc0dcfd8bc0bf715372 (diff) | |
download | voidsky-55a40c2436b68dea850e54a65c5dd197132c08e4.tar.zst |
[DMs] Emoji reaction picker (#8023)
Diffstat (limited to 'src/components/dms')
-rw-r--r-- | src/components/dms/ActionsWrapper.tsx | 24 | ||||
-rw-r--r-- | src/components/dms/ActionsWrapper.web.tsx | 40 | ||||
-rw-r--r-- | src/components/dms/EmojiPopup.android.tsx | 82 | ||||
-rw-r--r-- | src/components/dms/EmojiPopup.tsx | 1 | ||||
-rw-r--r-- | src/components/dms/EmojiReactionPicker.tsx | 118 | ||||
-rw-r--r-- | src/components/dms/EmojiReactionPicker.web.tsx | 86 | ||||
-rw-r--r-- | src/components/dms/MessageContextMenu.tsx | 12 |
7 files changed, 333 insertions, 30 deletions
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 385086d7c..120a5f8ad 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -23,28 +23,6 @@ export function ActionsWrapper({ // will always be true, since this file is platform split trigger.isNative && ( <View style={[a.flex_1, a.relative]}> - {/* {isNative && ( - <View - style={[ - a.rounded_full, - a.absolute, - {bottom: '100%'}, - isFromSelf ? a.right_0 : a.left_0, - t.atoms.bg, - a.flex_row, - a.shadow_lg, - a.py_xs, - a.px_md, - a.gap_md, - a.mb_xs, - ]}> - {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( - <Text key={emoji} style={[a.text_center, {fontSize: 32}]}> - {emoji} - </Text> - ))} - </View> - )} */} <View style={[ {maxWidth: '80%'}, @@ -56,7 +34,7 @@ export function ActionsWrapper({ accessibilityActions={[ {name: 'activate', label: _(msg`Open message options`)}, ]} - onAccessibilityAction={trigger.control.open}> + onAccessibilityAction={() => trigger.control.open('full')}> {children} </View> </View> diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 188d18eb7..82113eba8 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -4,7 +4,9 @@ import {ChatBskyConvoDefs} from '@atproto/api' import {atoms as a, useTheme} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' +import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' +import {EmojiReactionPicker} from './EmojiReactionPicker' export function ActionsWrapper({ message, @@ -47,10 +49,35 @@ export function ActionsWrapper({ <View style={[ a.justify_center, + a.flex_row, + a.align_center, + a.gap_xs, isFromSelf - ? [a.mr_xl, {marginLeft: 'auto'}] - : [a.ml_xl, {marginRight: 'auto'}], + ? [a.mr_md, {marginLeft: 'auto'}] + : [a.ml_md, {marginRight: 'auto'}], ]}> + <EmojiReactionPicker message={message}> + {({props, state, isNative, control}) => { + // always false, file is platform split + if (isNative) return null + const showMenuTrigger = showActions || control.isOpen ? 1 : 0 + return ( + <Pressable + {...props} + style={[ + {opacity: showMenuTrigger}, + a.p_xs, + a.rounded_full, + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, + ]}> + <EmojiSmileIcon + size="md" + style={t.atoms.text_contrast_medium} + /> + </Pressable> + ) + }} + </EmojiReactionPicker> <MessageContextMenu message={message}> {({props, state, isNative, control}) => { // always false, file is platform split @@ -61,11 +88,14 @@ export function ActionsWrapper({ {...props} style={[ {opacity: showMenuTrigger}, - a.p_sm, + a.p_xs, a.rounded_full, (state.hovered || state.pressed) && t.atoms.bg_contrast_25, ]}> - <DotsHorizontalIcon size="md" style={t.atoms.text} /> + <DotsHorizontalIcon + size="md" + style={t.atoms.text_contrast_medium} + /> </Pressable> ) }} diff --git a/src/components/dms/EmojiPopup.android.tsx b/src/components/dms/EmojiPopup.android.tsx new file mode 100644 index 000000000..05369cf3e --- /dev/null +++ b/src/components/dms/EmojiPopup.android.tsx @@ -0,0 +1,82 @@ +import {useState} from 'react' +import {Modal, Pressable, View} from 'react-native' +// @ts-expect-error internal component, not supposed to be used directly +// waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753 +import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent' +import {Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +export function EmojiPopup({ + children, + onEmojiSelected, +}: { + children: React.ReactNode + onEmojiSelected: (emoji: string) => void +}) { + const [modalVisible, setModalVisible] = useState(false) + const {_} = useLingui() + const t = useTheme() + + return ( + <> + <Pressable + accessibilityLabel={_('Open full emoji list')} + accessibilityHint="" + accessibilityRole="button" + onPress={() => setModalVisible(true)}> + {children} + </Pressable> + + <Modal + animationType="slide" + transparent={true} + visible={modalVisible} + onRequestClose={() => setModalVisible(false)}> + <View style={[a.flex_1, {backgroundColor: t.palette.white}]}> + <View + style={[ + t.atoms.bg, + a.pl_lg, + a.pr_md, + a.py_sm, + a.w_full, + a.align_center, + a.flex_row, + a.justify_between, + a.border_b, + t.atoms.border_contrast_low, + ]}> + <Text style={[a.font_bold, a.text_md]}> + <Trans>Add Reaction</Trans> + </Text> + <Button + label={_('Close')} + onPress={() => setModalVisible(false)} + size="small" + variant="ghost" + color="secondary" + shape="round"> + <ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} /> + </Button> + </View> + <EmojiPopupView + onEmojiSelected={({ + nativeEvent: {emoji}, + }: { + nativeEvent: {emoji: string} + }) => { + setModalVisible(false) + onEmojiSelected(emoji) + }} + style={[a.flex_1, a.w_full]} + /> + </View> + </Modal> + </> + ) +} diff --git a/src/components/dms/EmojiPopup.tsx b/src/components/dms/EmojiPopup.tsx new file mode 100644 index 000000000..a8f2f83e7 --- /dev/null +++ b/src/components/dms/EmojiPopup.tsx @@ -0,0 +1 @@ +export {EmojiPopup} from 'react-native-emoji-popup' diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx new file mode 100644 index 000000000..a98cebf9a --- /dev/null +++ b/src/components/dms/EmojiReactionPicker.tsx @@ -0,0 +1,118 @@ +import {useMemo, useState} from 'react' +import {Alert, useWindowDimensions, View} from 'react-native' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useSession} from '#/state/session' +import {atoms as a, tokens, useTheme} from '#/alf' +import * as ContextMenu from '#/components/ContextMenu' +import { + useContextMenuContext, + useContextMenuMenuContext, +} from '#/components/ContextMenu/context' +import { + EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon, + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon, +} from '#/components/icons/Emoji' +import {type TriggerProps} from '#/components/Menu/types' +import {Text} from '#/components/Typography' +import {EmojiPopup} from './EmojiPopup' + +export function EmojiReactionPicker({ + message, +}: { + message: ChatBskyConvoDefs.MessageView + children?: TriggerProps['children'] +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const t = useTheme() + const isFromSelf = message.sender?.did === currentAccount?.did + const {measurement, close} = useContextMenuContext() + const {align} = useContextMenuMenuContext() + const [layout, setLayout] = useState({width: 0, height: 0}) + const {width: screenWidth} = useWindowDimensions() + + // 1 in 100 chance of showing heart eyes icon + const EmojiIcon = useMemo(() => { + return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon + }, []) + + const handleEmojiSelect = (emoji: string) => { + Alert.alert(emoji) + } + + const position = useMemo(() => { + return { + x: align === 'left' ? 12 : screenWidth - layout.width - 12, + y: (measurement?.y ?? 0) - tokens.space.xs - layout.height, + height: layout.height, + width: layout.width, + } + }, [measurement, align, screenWidth, layout]) + + return ( + <View + onLayout={evt => setLayout(evt.nativeEvent.layout)} + style={[ + a.rounded_full, + a.absolute, + {bottom: '100%'}, + isFromSelf ? a.right_0 : a.left_0, + t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, + a.flex_row, + a.p_xs, + a.gap_xs, + a.mb_xs, + a.z_20, + a.border, + t.atoms.border_contrast_low, + a.shadow_md, + ]}> + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( + <ContextMenu.Item + position={position} + label={_(msg`React with ${emoji}`)} + key={emoji} + onPress={() => handleEmojiSelect(emoji)} + unstyled> + {hovered => ( + <View + style={[ + a.rounded_full, + hovered && {backgroundColor: t.palette.primary_500}, + {height: 40, width: 40}, + a.justify_center, + a.align_center, + ]}> + <Text style={[a.text_center, {fontSize: 30}]} emoji> + {emoji} + </Text> + </View> + )} + </ContextMenu.Item> + ))} + <EmojiPopup + onEmojiSelected={emoji => { + close() + handleEmojiSelect(emoji) + }}> + <View + style={[ + a.rounded_full, + t.scheme === 'light' + ? t.atoms.bg_contrast_25 + : t.atoms.bg_contrast_50, + {height: 40, width: 40}, + a.justify_center, + a.align_center, + a.border, + t.atoms.border_contrast_low, + ]}> + <EmojiIcon size="xl" fill={t.palette.contrast_400} /> + </View> + </EmojiPopup> + </View> + ) +} diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx new file mode 100644 index 000000000..bd51b4fd2 --- /dev/null +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -0,0 +1,86 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {ChatBskyConvoDefs} from '@atproto/api' +import EmojiPicker from '@emoji-mart/react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web' +import {PressableWithHover} from '#/view/com/util/PressableWithHover' +import {atoms as a} from '#/alf' +import {useTheme} from '#/alf' +import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import * as Menu from '#/components/Menu' +import {TriggerProps} from '#/components/Menu/types' +import {Text} from '#/components/Typography' + +export function EmojiReactionPicker({ + children, +}: { + message: ChatBskyConvoDefs.MessageView + children?: TriggerProps['children'] +}) { + if (!children) + throw new Error('EmojiReactionPicker requires the children prop on web') + + const {_} = useLingui() + + return ( + <Menu.Root> + <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger> + <Menu.Outer> + <MenuInner /> + </Menu.Outer> + </Menu.Root> + ) +} + +function MenuInner() { + const t = useTheme() + const {control} = Menu.useMenuContext() + + const [expanded, setExpanded] = useState(false) + + const handleEmojiPickerResponse = (emoji: Emoji) => { + handleEmojiSelect(emoji.native) + } + + const handleEmojiSelect = (emoji: string) => { + control.close() + window.alert(emoji) + } + + return expanded ? ( + <EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} /> + ) : ( + <View style={[a.flex_row, a.gap_xs]}> + {['👍', '😆', '❤️', '👀', '😢'].map(emoji => ( + <PressableWithHover + key={emoji} + onPress={() => handleEmojiSelect(emoji)} + hoverStyle={{backgroundColor: t.palette.primary_100}} + style={[ + a.rounded_xs, + {height: 40, width: 40}, + a.justify_center, + a.align_center, + ]}> + <Text style={[a.text_center, {fontSize: 30}]} emoji> + {emoji} + </Text> + </PressableWithHover> + ))} + <PressableWithHover + onPress={() => setExpanded(true)} + hoverStyle={{backgroundColor: t.palette.primary_100}} + style={[ + a.rounded_xs, + {height: 40, width: 40}, + a.justify_center, + a.align_center, + ]}> + <DotGridIcon size="lg" style={t.atoms.text_contrast_medium} /> + </PressableWithHover> + </View> + ) +} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index b5542690f..5591bec69 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -1,19 +1,20 @@ import React from 'react' import {LayoutAnimation} from 'react-native' import * as Clipboard from 'expo-clipboard' -import {ChatBskyConvoDefs, RichText} from '@atproto/api' +import {type ChatBskyConvoDefs, RichText} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' +import {isNative} from '#/platform/detection' import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import * as ContextMenu from '#/components/ContextMenu' -import {TriggerProps} from '#/components/ContextMenu/types' +import {type TriggerProps} from '#/components/ContextMenu/types' import {ReportDialog} from '#/components/dms/ReportDialog' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' @@ -21,6 +22,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' +import {EmojiReactionPicker} from './EmojiReactionPicker' export let MessageContextMenu = ({ message, @@ -77,6 +79,12 @@ export let MessageContextMenu = ({ return ( <> <ContextMenu.Root> + {isNative && ( + <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> + <EmojiReactionPicker message={message} /> + </ContextMenu.AuxiliaryView> + )} + <ContextMenu.Trigger label={_(msg`Message options`)} contentLabel={_( |