[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
+45 -9
View File
@@ -2,26 +2,36 @@ import {Pressable} from 'react-native'
import Animated, {
Extrapolation,
interpolate,
SharedValue,
type SharedValue,
useAnimatedProps,
useAnimatedStyle,
} from 'react-native-reanimated'
import {BlurView} from 'expo-blur'
import {msg} from '@lingui/macro'
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)
export function Backdrop({
animation,
intensity = 50,
onPress,
}: {
type Props = {
animation: SharedValue<number>
intensity?: number
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 animatedProps = useAnimatedProps(() => ({
@@ -37,7 +47,7 @@ export function Backdrop({
<AnimatedBlurView
animatedProps={animatedProps}
style={[a.absolute, a.inset_0]}
tint="systemThinMaterialDark">
tint="systemMaterialDark">
<Pressable
style={a.flex_1}
accessibilityLabel={_(msg`Close menu`)}
@@ -47,3 +57,29 @@ export function Backdrop({
</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 {atoms as a, useTheme} from '#/alf'
import {useContextMenuContext} from './context'
export function Backdrop({
animation,
@@ -21,12 +22,17 @@ export function Backdrop({
}) {
const t = useTheme()
const {_} = useLingui()
const {mode} = useContextMenuContext()
const reduced = mode === 'auxiliary-only'
const target = reduced ? 0.05 : intensity / 100
const animatedStyle = useAnimatedStyle(() => ({
opacity: interpolate(
animation.get(),
[0, 1],
[0, intensity / 100],
[0, target],
Extrapolation.CLAMP,
),
}))
+268 -144
View File
@@ -9,37 +9,37 @@ import React, {
import {
BackHandler,
Keyboard,
LayoutChangeEvent,
type LayoutChangeEvent,
Pressable,
StyleProp,
type StyleProp,
useWindowDimensions,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {
Gesture,
GestureDetector,
GestureStateChangeEvent,
GestureUpdateEvent,
PanGestureHandlerEventPayload,
type GestureStateChangeEvent,
type GestureUpdateEvent,
type PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
SharedValue,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
WithSpringConfig,
type WithSpringConfig,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
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 {useLingui} from '@lingui/react'
import {useIsFocused} from '@react-navigation/native'
@@ -60,12 +60,13 @@ import {
useContextMenuMenuContext,
} from '#/components/ContextMenu/context'
import {
ContextType,
ItemIconProps,
ItemProps,
ItemTextProps,
Measurement,
TriggerProps,
type AuxiliaryViewProps,
type ContextType,
type ItemIconProps,
type ItemProps,
type ItemTextProps,
type Measurement,
type TriggerProps,
} from '#/components/ContextMenu/types'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {createPortalGroup} from '#/components/Portal'
@@ -79,7 +80,14 @@ export {
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,
damping: 150,
stiffness: 1000,
@@ -100,6 +108,7 @@ export function Provider({children}: {children: React.ReactNode}) {
export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics()
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
const [measurement, setMeasurement] = useState<Measurement | null>(null)
const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0)
@@ -134,13 +143,15 @@ export function Root({children}: {children: React.ReactNode}) {
measurement,
animationSV,
translationSV,
open: (evt: Measurement) => {
mode,
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
setMeasurement(evt)
animationSV.set(withSpring(1, SPRING))
setMode(mode)
animationSV.set(withSpring(1, SPRING_IN))
},
close: () => {
animationSV.set(
withSpring(0, SPRING, finished => {
withSpring(0, SPRING_OUT, finished => {
if (finished) {
hoverablesSV.set({})
translationSV.set(0)
@@ -192,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
hoveredMenuItem,
setHoveredMenuItem,
playHaptic,
mode,
],
)
@@ -216,45 +228,49 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
const ref = useRef<View>(null)
const isFocused = useIsFocused()
const [image, setImage] = useState<string | null>(null)
const [pendingMeasurement, setPendingMeasurement] =
useState<Measurement | null>(null)
const [pendingMeasurement, setPendingMeasurement] = useState<{
measurement: Measurement
mode: 'full' | 'auxiliary-only'
} | null>(null)
const open = useNonReactiveCallback(async () => {
playHaptic()
Keyboard.dismiss()
const [measurement, capture] = await Promise.all([
new Promise<Measurement>(resolve => {
ref.current?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: topInset, // not included in measurement
}),
width,
height,
}),
)
}),
captureRef(ref, {result: 'data-uri'}).catch(err => {
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>'
}),
])
setImage(capture)
setPendingMeasurement(measurement)
})
const open = useNonReactiveCallback(
async (mode: 'full' | 'auxiliary-only') => {
playHaptic()
Keyboard.dismiss()
const [measurement, capture] = await Promise.all([
new Promise<Measurement>(resolve => {
ref.current?.measureInWindow((x, y, width, height) =>
resolve({
x,
y:
y +
platform({
default: 0,
android: topInset, // not included in measurement
}),
width,
height,
}),
)
}),
captureRef(ref, {result: 'data-uri'}).catch(err => {
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>'
}),
])
setImage(capture)
setPendingMeasurement({measurement, mode})
},
)
const doubleTapGesture = useMemo(() => {
return Gesture.Tap()
.numberOfTaps(2)
.hitSlop(HITSLOP_10)
.onEnd(open)
.onEnd(() => open('auxiliary-only'))
.runOnJS(true)
}, [open])
@@ -283,17 +299,19 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
.averageTouches(true)
.onStart(() => {
'worklet'
runOnJS(open)()
runOnJS(open)('full')
})
.onUpdate(evt => {
'worklet'
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
hoveredItemSV.set(item)
})
.onEnd(evt => {
.onEnd(() => {
'worklet'
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
hoveredItemSV.set(null)
// don't recalculate hovered item - if they haven't moved their finger from
// 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) {
runOnJS(onTouchUpMenuItem)(item)
}
@@ -305,7 +323,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
pressAndHoldGesture,
)
const measurement = context.measurement || pendingMeasurement
const measurement = context.measurement || pendingMeasurement?.measurement
return (
<>
@@ -343,7 +361,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
measurement={measurement}
onDisplay={() => {
if (pendingMeasurement) {
context.open(pendingMeasurement)
context.open(
pendingMeasurement.measurement,
pendingMeasurement.mode,
)
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({
children,
@@ -491,85 +595,95 @@ export function Outer({
<Context.Provider value={context}>
<MenuContext.Provider value={menuContext}>
<Backdrop animation={animationSV} onPress={context.close} />
{/* containing element - stays the same size, so we measure it
to determine if a translation is necessary. also has the positioning */}
<Animated.View
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 */}
{context.mode === 'full' && (
/* containing element - stays the same size, so we measure it
to determine if a translation is necessary. also has the positioning */
<Animated.View
onLayout={onLayout}
style={[
a.rounded_md,
a.shadow_md,
t.atoms.bg_contrast_25,
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
a.absolute,
a.z_10,
a.mt_xs,
{
transformOrigin:
// "top right" doesn't seem to work on android, so set explicity in pixels
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
width: MENU_WIDTH,
top: context.measurement.y + context.measurement.height,
},
animatedStyle,
style,
align === 'left'
? {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,
so put the shadow on the scaling element and the overflow on the innermost element */}
<View
{/* scaling element - has the scale/fade animation on it */}
<Animated.View
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
a.shadow_md,
t.atoms.bg_contrast_25,
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) => {
return React.isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View
style={[a.border_b, t.atoms.border_contrast_low]}
/>
) : null}
{React.cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
so put the shadow on the scaling element and the overflow on the innermost element */}
<View
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
]}>
{flattenReactChildren(children).map((child, i) => {
return React.isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View
style={[a.border_b, t.atoms.border_contrast_low]}
/>
) : null}
{React.cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
</Animated.View>
</Animated.View>
</Animated.View>
)}
</MenuContext.Provider>
</Context.Provider>
</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 context = useContextMenuContext()
const playHaptic = useHaptics()
@@ -590,16 +704,22 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
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(
id,
{
width: layout.width,
height: layout.height,
y: measurement.y + measurement.height + tokens.space.xs + layout.y,
x:
align === 'left'
? measurement.x
: measurement.x + measurement.width - layout.width,
y: yOffset + layout.y,
x: xOffset + layout.x,
},
() => {
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(
@@ -637,22 +757,26 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
rest.onPressOut?.(e)
}}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.py_sm,
a.px_md,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{minHeight: 40},
!unstyled && [
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10},
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled &&
t.atoms.bg_contrast_50,
],
style,
(focused || pressed || context.hoveredMenuItem === id) &&
!rest.disabled && [t.atoms.bg_contrast_50],
]}>
<ItemContext.Provider value={itemContext}>
{children}
{typeof children === 'function'
? children(focused || pressed || context.hoveredMenuItem === id)
: children}
</ItemContext.Provider>
</Pressable>
)
@@ -667,7 +791,7 @@ export function ItemText({children, style}: ItemTextProps) {
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_sm,
a.text_md,
a.font_bold,
t.atoms.text_contrast_high,
{paddingTop: 3},
@@ -684,7 +808,7 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
const {disabled} = useContextMenuItemContext()
return (
<Comp
size="md"
size="lg"
fill={
disabled
? t.atoms.text_contrast_low.color
+7
View File
@@ -1,5 +1,12 @@
import {type AuxiliaryViewProps} from './types'
export * from '#/components/Menu'
export function Provider({children}: {children: React.ReactNode}) {
return children
}
// native only
export function AuxiliaryView({}: AuxiliaryViewProps) {
return null
}
+27 -13
View File
@@ -1,16 +1,16 @@
import React from 'react'
import {
AccessibilityRole,
GestureResponderEvent,
StyleProp,
ViewStyle,
type AccessibilityRole,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} 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 {
ItemProps as MenuItemProps,
RadixPassThroughTriggerProps,
type ItemProps as MenuItemProps,
type RadixPassThroughTriggerProps,
} from '#/components/Menu/types'
export type {
@@ -19,9 +19,19 @@ export type {
ItemTextProps,
} from '#/components/Menu/types'
// Same as Menu.ItemProps, but onPress is not guaranteed to get an event
export type ItemProps = Omit<MenuItemProps, 'onPress'> & {
export type AuxiliaryViewProps = {
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
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 = {
@@ -38,7 +48,8 @@ export type ContextType = {
animationSV: SharedValue<number>
/* Translation in Y axis to ensure everything's onscreen */
translationSV: SharedValue<number>
open: (evt: Measurement) => void
mode: 'full' | 'auxiliary-only'
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
close: () => void
registerHoverable: (
id: string,
@@ -76,7 +87,10 @@ export type TriggerProps = {
export type TriggerChildProps =
| {
isNative: true
control: {isOpen: boolean; open: () => void}
control: {
isOpen: boolean
open: (mode: 'full' | 'auxiliary-only') => void
}
state: {
hovered: false
focused: false