[DMs] Emoji reaction picker (#8023)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {EmojiPopup} from 'react-native-emoji-popup'
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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={_(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user