[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
View File
@@ -178,6 +178,7 @@
"react-native-compressor": "1.10.3", "react-native-compressor": "1.10.3",
"react-native-date-picker": "^5.0.7", "react-native-date-picker": "^5.0.7",
"react-native-drawer-layout": "^4.1.1", "react-native-drawer-layout": "^4.1.1",
"react-native-emoji-popup": "^0.1.2",
"react-native-gesture-handler": "2.20.2", "react-native-gesture-handler": "2.20.2",
"react-native-get-random-values": "~1.11.0", "react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.41.6", "react-native-image-crop-picker": "^0.41.6",
+45 -9
View File
@@ -2,26 +2,36 @@ import {Pressable} from 'react-native'
import Animated, { import Animated, {
Extrapolation, Extrapolation,
interpolate, interpolate,
SharedValue, type SharedValue,
useAnimatedProps, useAnimatedProps,
useAnimatedStyle,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {BlurView} from 'expo-blur' import {BlurView} from 'expo-blur'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useContextMenuContext} from './context'
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView) const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
export function Backdrop({ type Props = {
animation,
intensity = 50,
onPress,
}: {
animation: SharedValue<number> animation: SharedValue<number>
intensity?: number intensity?: number
onPress?: () => void onPress?: () => void
}) { }
export function Backdrop(props: Props) {
const {mode} = useContextMenuContext()
switch (mode) {
case 'full':
return <BlurredBackdrop {...props} />
case 'auxiliary-only':
return <OpacityBackdrop {...props} />
}
}
function BlurredBackdrop({animation, intensity = 50, onPress}: Props) {
const {_} = useLingui() const {_} = useLingui()
const animatedProps = useAnimatedProps(() => ({ const animatedProps = useAnimatedProps(() => ({
@@ -37,7 +47,7 @@ export function Backdrop({
<AnimatedBlurView <AnimatedBlurView
animatedProps={animatedProps} animatedProps={animatedProps}
style={[a.absolute, a.inset_0]} style={[a.absolute, a.inset_0]}
tint="systemThinMaterialDark"> tint="systemMaterialDark">
<Pressable <Pressable
style={a.flex_1} style={a.flex_1}
accessibilityLabel={_(msg`Close menu`)} accessibilityLabel={_(msg`Close menu`)}
@@ -47,3 +57,29 @@ export function Backdrop({
</AnimatedBlurView> </AnimatedBlurView>
) )
} }
function OpacityBackdrop({animation, onPress}: Props) {
const t = useTheme()
const {_} = useLingui()
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animation.get(),
[0, 1],
[0, 0.05],
Extrapolation.CLAMP,
),
}))
return (
<Animated.View
style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
<Pressable
style={a.flex_1}
accessibilityLabel={_(msg`Close menu`)}
accessibilityHint={_(msg`Tap to close context menu`)}
onPress={onPress}
/>
</Animated.View>
)
}
+7 -1
View File
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useContextMenuContext} from './context'
export function Backdrop({ export function Backdrop({
animation, animation,
@@ -21,12 +22,17 @@ export function Backdrop({
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {mode} = useContextMenuContext()
const reduced = mode === 'auxiliary-only'
const target = reduced ? 0.05 : intensity / 100
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate( opacity: interpolate(
animation.get(), animation.get(),
[0, 1], [0, 1],
[0, intensity / 100], [0, target],
Extrapolation.CLAMP, Extrapolation.CLAMP,
), ),
})) }))
+268 -144
View File
@@ -9,37 +9,37 @@ import React, {
import { import {
BackHandler, BackHandler,
Keyboard, Keyboard,
LayoutChangeEvent, type LayoutChangeEvent,
Pressable, Pressable,
StyleProp, type StyleProp,
useWindowDimensions, useWindowDimensions,
View, View,
ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
GestureStateChangeEvent, type GestureStateChangeEvent,
GestureUpdateEvent, type GestureUpdateEvent,
PanGestureHandlerEventPayload, type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler' } from 'react-native-gesture-handler'
import Animated, { import Animated, {
clamp, clamp,
interpolate, interpolate,
runOnJS, runOnJS,
SharedValue, type SharedValue,
useAnimatedReaction, useAnimatedReaction,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withSpring, withSpring,
WithSpringConfig, type WithSpringConfig,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import { import {
useSafeAreaFrame, useSafeAreaFrame,
useSafeAreaInsets, useSafeAreaInsets,
} from 'react-native-safe-area-context' } from 'react-native-safe-area-context'
import {captureRef} from 'react-native-view-shot' import {captureRef} from 'react-native-view-shot'
import {Image, ImageErrorEventData} from 'expo-image' import {Image, type ImageErrorEventData} from 'expo-image'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useIsFocused} from '@react-navigation/native' import {useIsFocused} from '@react-navigation/native'
@@ -60,12 +60,13 @@ import {
useContextMenuMenuContext, useContextMenuMenuContext,
} from '#/components/ContextMenu/context' } from '#/components/ContextMenu/context'
import { import {
ContextType, type AuxiliaryViewProps,
ItemIconProps, type ContextType,
ItemProps, type ItemIconProps,
ItemTextProps, type ItemProps,
Measurement, type ItemTextProps,
TriggerProps, type Measurement,
type TriggerProps,
} from '#/components/ContextMenu/types' } from '#/components/ContextMenu/types'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {createPortalGroup} from '#/components/Portal' import {createPortalGroup} from '#/components/Portal'
@@ -79,7 +80,14 @@ export {
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
const SPRING: WithSpringConfig = { const SPRING_IN: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
damping: 50,
stiffness: 1100,
restDisplacementThreshold: 0.01,
}
const SPRING_OUT: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75, mass: isIOS ? 1.25 : 0.75,
damping: 150, damping: 150,
stiffness: 1000, stiffness: 1000,
@@ -100,6 +108,7 @@ export function Provider({children}: {children: React.ReactNode}) {
export function Root({children}: {children: React.ReactNode}) { export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics() const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null) const [measurement, setMeasurement] = useState<Measurement | null>(null)
const animationSV = useSharedValue(0) const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0) const translationSV = useSharedValue(0)
@@ -134,13 +143,15 @@ export function Root({children}: {children: React.ReactNode}) {
measurement, measurement,
animationSV, animationSV,
translationSV, translationSV,
open: (evt: Measurement) => { mode,
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
setMeasurement(evt) setMeasurement(evt)
animationSV.set(withSpring(1, SPRING)) setMode(mode)
animationSV.set(withSpring(1, SPRING_IN))
}, },
close: () => { close: () => {
animationSV.set( animationSV.set(
withSpring(0, SPRING, finished => { withSpring(0, SPRING_OUT, finished => {
if (finished) { if (finished) {
hoverablesSV.set({}) hoverablesSV.set({})
translationSV.set(0) translationSV.set(0)
@@ -192,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
hoveredMenuItem, hoveredMenuItem,
setHoveredMenuItem, setHoveredMenuItem,
playHaptic, playHaptic,
mode,
], ],
) )
@@ -216,45 +228,49 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const ref = useRef<View>(null) const ref = useRef<View>(null)
const isFocused = useIsFocused() const isFocused = useIsFocused()
const [image, setImage] = useState<string | null>(null) const [image, setImage] = useState<string | null>(null)
const [pendingMeasurement, setPendingMeasurement] = const [pendingMeasurement, setPendingMeasurement] = useState<{
useState<Measurement | null>(null) measurement: Measurement
mode: 'full' | 'auxiliary-only'
} | null>(null)
const open = useNonReactiveCallback(async () => { const open = useNonReactiveCallback(
playHaptic() async (mode: 'full' | 'auxiliary-only') => {
Keyboard.dismiss() playHaptic()
const [measurement, capture] = await Promise.all([ Keyboard.dismiss()
new Promise<Measurement>(resolve => { const [measurement, capture] = await Promise.all([
ref.current?.measureInWindow((x, y, width, height) => new Promise<Measurement>(resolve => {
resolve({ ref.current?.measureInWindow((x, y, width, height) =>
x, resolve({
y: x,
y + y:
platform({ y +
default: 0, platform({
android: topInset, // not included in measurement default: 0,
}), android: topInset, // not included in measurement
width, }),
height, width,
}), height,
) }),
}), )
captureRef(ref, {result: 'data-uri'}).catch(err => { }),
logger.error(err instanceof Error ? err : String(err), { captureRef(ref, {result: 'data-uri'}).catch(err => {
message: 'Failed to capture image of context menu trigger', logger.error(err instanceof Error ? err : String(err), {
}) message: 'Failed to capture image of context menu trigger',
// will cause the image to fail to load, but it will get handled gracefully })
return '<failed capture>' // will cause the image to fail to load, but it will get handled gracefully
}), return '<failed capture>'
]) }),
setImage(capture) ])
setPendingMeasurement(measurement) setImage(capture)
}) setPendingMeasurement({measurement, mode})
},
)
const doubleTapGesture = useMemo(() => { const doubleTapGesture = useMemo(() => {
return Gesture.Tap() return Gesture.Tap()
.numberOfTaps(2) .numberOfTaps(2)
.hitSlop(HITSLOP_10) .hitSlop(HITSLOP_10)
.onEnd(open) .onEnd(() => open('auxiliary-only'))
.runOnJS(true) .runOnJS(true)
}, [open]) }, [open])
@@ -283,17 +299,19 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
.averageTouches(true) .averageTouches(true)
.onStart(() => { .onStart(() => {
'worklet' 'worklet'
runOnJS(open)() runOnJS(open)('full')
}) })
.onUpdate(evt => { .onUpdate(evt => {
'worklet' 'worklet'
const item = getHoveredHoverable(evt, hoverablesSV, translationSV) const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
hoveredItemSV.set(item) hoveredItemSV.set(item)
}) })
.onEnd(evt => { .onEnd(() => {
'worklet' 'worklet'
const item = getHoveredHoverable(evt, hoverablesSV, translationSV) // don't recalculate hovered item - if they haven't moved their finger from
hoveredItemSV.set(null) // the initial press, it's jarring to then select the item underneath
// as the menu may have slid into place beneath their finger
const item = hoveredItemSV.get()
if (item) { if (item) {
runOnJS(onTouchUpMenuItem)(item) runOnJS(onTouchUpMenuItem)(item)
} }
@@ -305,7 +323,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
pressAndHoldGesture, pressAndHoldGesture,
) )
const measurement = context.measurement || pendingMeasurement const measurement = context.measurement || pendingMeasurement?.measurement
return ( return (
<> <>
@@ -343,7 +361,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
measurement={measurement} measurement={measurement}
onDisplay={() => { onDisplay={() => {
if (pendingMeasurement) { if (pendingMeasurement) {
context.open(pendingMeasurement) context.open(
pendingMeasurement.measurement,
pendingMeasurement.mode,
)
setPendingMeasurement(null) setPendingMeasurement(null)
} }
}} }}
@@ -416,7 +437,90 @@ function TriggerClone({
) )
} }
const MENU_WIDTH = 230 export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
const context = useContextMenuContext()
const {width: screenWidth} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets()
const ensureOnScreenTranslationSV = useSharedValue(0)
const {isOpen, mode, measurement, translationSV, animationSV} = context
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: clamp(animationSV.get(), 0, 1),
transform: [
{
translateY:
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
animationSV.get(),
},
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
],
}
})
const menuContext = useMemo(() => ({align}), [align])
const onLayout = useCallback(() => {
if (!measurement) return
let translation = 0
// vibes based, just assuming it'll fit within this space. revisit if we use
// AuxiliaryView for something tall
const TOP_INSET = topInset + 80
const distanceMessageFromTop = measurement.y - TOP_INSET
if (distanceMessageFromTop < 0) {
translation = -distanceMessageFromTop
}
// normally, the context menu is responsible for measuring itself and moving everything into the right place
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
if (mode === 'auxiliary-only') {
translationSV.set(translation)
ensureOnScreenTranslationSV.set(0)
}
// however, we also need to make sure that for super tall triggers, we don't go off the screen
// so we have an additional cap on the standard transform every other element has
// note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
// increase for an edge case
else {
ensureOnScreenTranslationSV.set(translation)
}
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
if (!isOpen || !measurement) return null
return (
<Portal>
<Context.Provider value={context}>
<MenuContext.Provider value={menuContext}>
<Animated.View
onLayout={onLayout}
style={[
a.absolute,
{
top: measurement.y,
transformOrigin:
align === 'left' ? 'bottom left' : 'bottom right',
},
align === 'left'
? {left: measurement.x}
: {right: screenWidth - measurement.x - measurement.width},
animatedStyle,
a.z_20,
]}>
{children}
</Animated.View>
</MenuContext.Provider>
</Context.Provider>
</Portal>
)
}
const MENU_WIDTH = 240
export function Outer({ export function Outer({
children, children,
@@ -491,85 +595,95 @@ export function Outer({
<Context.Provider value={context}> <Context.Provider value={context}>
<MenuContext.Provider value={menuContext}> <MenuContext.Provider value={menuContext}>
<Backdrop animation={animationSV} onPress={context.close} /> <Backdrop animation={animationSV} onPress={context.close} />
{/* containing element - stays the same size, so we measure it {context.mode === 'full' && (
to determine if a translation is necessary. also has the positioning */} /* containing element - stays the same size, so we measure it
<Animated.View to determine if a translation is necessary. also has the positioning */
onLayout={onLayout}
style={[
a.absolute,
a.z_10,
a.mt_xs,
{
width: MENU_WIDTH,
top: context.measurement.y + context.measurement.height,
},
align === 'left'
? {left: context.measurement.x}
: {
right:
screenWidth -
context.measurement.x -
context.measurement.width,
},
animatedContainerStyle,
]}>
{/* scaling element - has the scale/fade animation on it */}
<Animated.View <Animated.View
onLayout={onLayout}
style={[ style={[
a.rounded_md, a.absolute,
a.shadow_md, a.z_10,
t.atoms.bg_contrast_25, a.mt_xs,
a.w_full,
// @ts-ignore react-native-web expects string, and this file is platform-split -sfn
// note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
// in the typecheck CI - presumably because of RNW overriding the types
{ {
transformOrigin: width: MENU_WIDTH,
// "top right" doesn't seem to work on android, so set explicity in pixels top: context.measurement.y + context.measurement.height,
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
}, },
animatedStyle, align === 'left'
style, ? {left: context.measurement.x}
: {
right:
screenWidth -
context.measurement.x -
context.measurement.width,
},
animatedContainerStyle,
]}> ]}>
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow, {/* scaling element - has the scale/fade animation on it */}
so put the shadow on the scaling element and the overflow on the innermost element */} <Animated.View
<View
style={[ style={[
a.flex_1,
a.rounded_md, a.rounded_md,
a.overflow_hidden, a.shadow_md,
a.border, t.atoms.bg_contrast_25,
t.atoms.border_contrast_low, a.w_full,
// @ts-ignore react-native-web expects string, and this file is platform-split -sfn
// note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
// in the typecheck CI - presumably because of RNW overriding the types
{
transformOrigin:
// "top right" doesn't seem to work on android, so set explicitly in pixels
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
},
animatedStyle,
style,
]}> ]}>
{flattenReactChildren(children).map((child, i) => { {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
return React.isValidElement(child) && so put the shadow on the scaling element and the overflow on the innermost element */}
(child.type === Item || child.type === Divider) ? ( <View
<React.Fragment key={i}> style={[
{i > 0 ? ( a.flex_1,
<View a.rounded_md,
style={[a.border_b, t.atoms.border_contrast_low]} a.overflow_hidden,
/> a.border,
) : null} t.atoms.border_contrast_low,
{React.cloneElement(child, { ]}>
// @ts-expect-error not typed {flattenReactChildren(children).map((child, i) => {
style: { return React.isValidElement(child) &&
borderRadius: 0, (child.type === Item || child.type === Divider) ? (
borderWidth: 0, <React.Fragment key={i}>
}, {i > 0 ? (
})} <View
</React.Fragment> style={[a.border_b, t.atoms.border_contrast_low]}
) : null />
})} ) : null}
</View> {React.cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
</Animated.View>
</Animated.View> </Animated.View>
</Animated.View> )}
</MenuContext.Provider> </MenuContext.Provider>
</Context.Provider> </Context.Provider>
</Portal> </Portal>
) )
} }
export function Item({children, label, style, onPress, ...rest}: ItemProps) { export function Item({
children,
label,
unstyled,
style,
onPress,
position,
...rest
}: ItemProps) {
const t = useTheme() const t = useTheme()
const context = useContextMenuContext() const context = useContextMenuContext()
const playHaptic = useHaptics() const playHaptic = useHaptics()
@@ -590,16 +704,22 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
const layout = evt.nativeEvent.layout const layout = evt.nativeEvent.layout
const yOffset = position
? position.y
: measurement.y + measurement.height + tokens.space.xs
const xOffset = position
? position.x
: align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width
registerHoverable( registerHoverable(
id, id,
{ {
width: layout.width, width: layout.width,
height: layout.height, height: layout.height,
y: measurement.y + measurement.height + tokens.space.xs + layout.y, y: yOffset + layout.y,
x: x: xOffset + layout.x,
align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width,
}, },
() => { () => {
close() close()
@@ -607,7 +727,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
}, },
) )
}, },
[id, measurement, registerHoverable, close, onPress, align], [id, measurement, registerHoverable, close, onPress, align, position],
) )
const itemContext = useMemo( const itemContext = useMemo(
@@ -637,22 +757,26 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
rest.onPressOut?.(e) rest.onPressOut?.(e)
}} }}
style={[ style={[
a.flex_row, !unstyled && [
a.align_center, a.flex_row,
a.gap_sm, a.align_center,
a.py_sm, a.gap_sm,
a.px_md, a.px_md,
a.rounded_md, a.rounded_md,
a.border, a.border,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
{minHeight: 40}, {minHeight: 44, paddingVertical: 10},
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled &&
t.atoms.bg_contrast_50,
],
style, style,
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled && [t.atoms.bg_contrast_50],
]}> ]}>
<ItemContext.Provider value={itemContext}> <ItemContext.Provider value={itemContext}>
{children} {typeof children === 'function'
? children(focused || pressed || context.hoveredMenuItem === id)
: children}
</ItemContext.Provider> </ItemContext.Provider>
</Pressable> </Pressable>
) )
@@ -667,7 +791,7 @@ export function ItemText({children, style}: ItemTextProps) {
ellipsizeMode="middle" ellipsizeMode="middle"
style={[ style={[
a.flex_1, a.flex_1,
a.text_sm, a.text_md,
a.font_bold, a.font_bold,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
{paddingTop: 3}, {paddingTop: 3},
@@ -684,7 +808,7 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
const {disabled} = useContextMenuItemContext() const {disabled} = useContextMenuItemContext()
return ( return (
<Comp <Comp
size="md" size="lg"
fill={ fill={
disabled disabled
? t.atoms.text_contrast_low.color ? t.atoms.text_contrast_low.color
+7
View File
@@ -1,5 +1,12 @@
import {type AuxiliaryViewProps} from './types'
export * from '#/components/Menu' export * from '#/components/Menu'
export function Provider({children}: {children: React.ReactNode}) { export function Provider({children}: {children: React.ReactNode}) {
return children return children
} }
// native only
export function AuxiliaryView({}: AuxiliaryViewProps) {
return null
}
+27 -13
View File
@@ -1,16 +1,16 @@
import React from 'react'
import { import {
AccessibilityRole, type AccessibilityRole,
GestureResponderEvent, type GestureResponderEvent,
StyleProp, type StyleProp,
ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {SharedValue} from 'react-native-reanimated' import {type SharedValue} from 'react-native-reanimated'
import type React from 'react'
import * as Dialog from '#/components/Dialog' import type * as Dialog from '#/components/Dialog'
import { import {
ItemProps as MenuItemProps, type ItemProps as MenuItemProps,
RadixPassThroughTriggerProps, type RadixPassThroughTriggerProps,
} from '#/components/Menu/types' } from '#/components/Menu/types'
export type { export type {
@@ -19,9 +19,19 @@ export type {
ItemTextProps, ItemTextProps,
} from '#/components/Menu/types' } from '#/components/Menu/types'
// Same as Menu.ItemProps, but onPress is not guaranteed to get an event export type AuxiliaryViewProps = {
export type ItemProps = Omit<MenuItemProps, 'onPress'> & { children?: React.ReactNode
align?: 'left' | 'right'
}
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
// remove default styles (i.e. for emoji reactions)
unstyled?: boolean
onPress: (evt?: GestureResponderEvent) => void onPress: (evt?: GestureResponderEvent) => void
children?: React.ReactNode | ((hovered: boolean) => React.ReactNode)
// absolute position of the parent element. if undefined, assumed to
// be in the context menu. use this if using AuxiliaryView
position?: Measurement
} }
export type Measurement = { export type Measurement = {
@@ -38,7 +48,8 @@ export type ContextType = {
animationSV: SharedValue<number> animationSV: SharedValue<number>
/* Translation in Y axis to ensure everything's onscreen */ /* Translation in Y axis to ensure everything's onscreen */
translationSV: SharedValue<number> translationSV: SharedValue<number>
open: (evt: Measurement) => void mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
close: () => void close: () => void
registerHoverable: ( registerHoverable: (
id: string, id: string,
@@ -76,7 +87,10 @@ export type TriggerProps = {
export type TriggerChildProps = export type TriggerChildProps =
| { | {
isNative: true isNative: true
control: {isOpen: boolean; open: () => void} control: {
isOpen: boolean
open: (mode: 'full' | 'auxiliary-only') => void
}
state: { state: {
hovered: false hovered: false
focused: false focused: false
+2
View File
@@ -30,6 +30,8 @@ export {
useDialogControl as useMenuControl, useDialogControl as useMenuControl,
} from '#/components/Dialog' } from '#/components/Dialog'
export {useMenuContext}
export function Root({ export function Root({
children, children,
control, control,
+2
View File
@@ -26,6 +26,8 @@ import {
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export {useMenuContext}
export function useMenuControl(): Dialog.DialogControlProps { export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId() const id = React.useId()
const [isOpen, setIsOpen] = React.useState(false) const [isOpen, setIsOpen] = React.useState(false)
+1 -23
View File
@@ -23,28 +23,6 @@ export function ActionsWrapper({
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.isNative && ( trigger.isNative && (
<View style={[a.flex_1, a.relative]}> <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 <View
style={[ style={[
{maxWidth: '80%'}, {maxWidth: '80%'},
@@ -56,7 +34,7 @@ export function ActionsWrapper({
accessibilityActions={[ accessibilityActions={[
{name: 'activate', label: _(msg`Open message options`)}, {name: 'activate', label: _(msg`Open message options`)},
]} ]}
onAccessibilityAction={trigger.control.open}> onAccessibilityAction={() => trigger.control.open('full')}>
{children} {children}
</View> </View>
</View> </View>
+35 -5
View File
@@ -4,7 +4,9 @@ import {ChatBskyConvoDefs} from '@atproto/api'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 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({ export function ActionsWrapper({
message, message,
@@ -47,10 +49,35 @@ export function ActionsWrapper({
<View <View
style={[ style={[
a.justify_center, a.justify_center,
a.flex_row,
a.align_center,
a.gap_xs,
isFromSelf isFromSelf
? [a.mr_xl, {marginLeft: 'auto'}] ? [a.mr_md, {marginLeft: 'auto'}]
: [a.ml_xl, {marginRight: '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}> <MessageContextMenu message={message}>
{({props, state, isNative, control}) => { {({props, state, isNative, control}) => {
// always false, file is platform split // always false, file is platform split
@@ -61,11 +88,14 @@ export function ActionsWrapper({
{...props} {...props}
style={[ style={[
{opacity: showMenuTrigger}, {opacity: showMenuTrigger},
a.p_sm, a.p_xs,
a.rounded_full, a.rounded_full,
(state.hovered || state.pressed) && t.atoms.bg_contrast_25, (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> </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 React from 'react'
import {LayoutAnimation} from 'react-native' import {LayoutAnimation} from 'react-native'
import * as Clipboard from 'expo-clipboard' 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 {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink' import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import * as ContextMenu from '#/components/ContextMenu' 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 {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 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 {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt'
import {EmojiReactionPicker} from './EmojiReactionPicker'
export let MessageContextMenu = ({ export let MessageContextMenu = ({
message, message,
@@ -77,6 +79,12 @@ export let MessageContextMenu = ({
return ( return (
<> <>
<ContextMenu.Root> <ContextMenu.Root>
{isNative && (
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
<EmojiReactionPicker message={message} />
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger <ContextMenu.Trigger
label={_(msg`Message options`)} label={_(msg`Message options`)}
contentLabel={_( contentLabel={_(
+5
View File
@@ -16733,6 +16733,11 @@ react-native-drawer-layout@^4.1.1:
dependencies: dependencies:
use-latest-callback "^0.2.1" use-latest-callback "^0.2.1"
react-native-emoji-popup@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/react-native-emoji-popup/-/react-native-emoji-popup-0.1.2.tgz#7cd3874ba0496031e6f3e24de77e0df895168ce6"
integrity sha512-YxuAwubxe6VLNfTyMlpw9g2WQVUIuJb4flWVZjfR7r6fmVvXw4Sxo6ZD6m/fG9AQP3pHkZptzNUr4gdF23m3ZQ==
react-native-gesture-handler@2.20.2: react-native-gesture-handler@2.20.2:
version "2.20.2" version "2.20.2"
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66"