[DMs] Emoji reaction picker (#8023)

This commit is contained in:
Samuel Newman
2025-03-28 08:43:40 +02:00
committed by GitHub
parent ac2c2a9a1d
commit 55a40c2436
16 changed files with 697 additions and 197 deletions
+1 -23
View File
@@ -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>
+35 -5
View File
@@ -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>
)
}}
+82
View File
@@ -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>
</>
)
}
+1
View File
@@ -0,0 +1 @@
export {EmojiPopup} from 'react-native-emoji-popup'
+118
View File
@@ -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>
)
}
@@ -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>
)
}
+10 -2
View File
@@ -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={_(