New ContextMenu menu type for DM messages (#8014)
* get context menu somewhat working ish * take screenshot rather than double rendering * get animations somewhat working * get transform animation working * rm log * upwards safe area * get working on android * get android working once and for all * fix positioning on both platforms * use dark blur on ios always, fix dark mode * allow closing with hardware back press * try and fix type error * add note about ts-ignore * round post * add image capture error handling * extract magic numbers * set explicit embed width, rm top margin * Message embed width tweaks * Format * fix position of embeds * same as above for web --------- Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
+52
-49
@@ -66,6 +66,7 @@ import * as Toast from '#/view/com/util/Toast'
|
|||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
@@ -128,55 +129,57 @@ function InnerApp() {
|
|||||||
return (
|
return (
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<ContextMenuProvider>
|
||||||
<RootSiblingParent>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
<VideoVolumeProvider>
|
<RootSiblingParent>
|
||||||
<React.Fragment
|
<VideoVolumeProvider>
|
||||||
// Resets the entire tree below when it changes:
|
<React.Fragment
|
||||||
key={currentAccount?.did}>
|
// Resets the entire tree below when it changes:
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<ComposerProvider>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<StatsigProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<StatsigProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<MessagesProvider>
|
||||||
<LabelDefsProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
<ModerationOptsProvider>
|
<LabelDefsProvider>
|
||||||
<LoggedOutViewProvider>
|
<ModerationOptsProvider>
|
||||||
<SelectedFeedProvider>
|
<LoggedOutViewProvider>
|
||||||
<HiddenRepliesProvider>
|
<SelectedFeedProvider>
|
||||||
<HomeBadgeProvider>
|
<HiddenRepliesProvider>
|
||||||
<UnreadNotifsProvider>
|
<HomeBadgeProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<UnreadNotifsProvider>
|
||||||
<MutedThreadsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<ProgressGuideProvider>
|
<MutedThreadsProvider>
|
||||||
<TrendingConfigProvider>
|
<ProgressGuideProvider>
|
||||||
<GestureHandlerRootView
|
<TrendingConfigProvider>
|
||||||
style={s.h100pct}>
|
<GestureHandlerRootView
|
||||||
<IntentDialogProvider>
|
style={s.h100pct}>
|
||||||
<TestCtrls />
|
<IntentDialogProvider>
|
||||||
<Shell />
|
<TestCtrls />
|
||||||
<NuxDialogs />
|
<Shell />
|
||||||
</IntentDialogProvider>
|
<NuxDialogs />
|
||||||
</GestureHandlerRootView>
|
</IntentDialogProvider>
|
||||||
</TrendingConfigProvider>
|
</GestureHandlerRootView>
|
||||||
</ProgressGuideProvider>
|
</TrendingConfigProvider>
|
||||||
</MutedThreadsProvider>
|
</ProgressGuideProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</MutedThreadsProvider>
|
||||||
</UnreadNotifsProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</HomeBadgeProvider>
|
</UnreadNotifsProvider>
|
||||||
</HiddenRepliesProvider>
|
</HomeBadgeProvider>
|
||||||
</SelectedFeedProvider>
|
</HiddenRepliesProvider>
|
||||||
</LoggedOutViewProvider>
|
</SelectedFeedProvider>
|
||||||
</ModerationOptsProvider>
|
</LoggedOutViewProvider>
|
||||||
</LabelDefsProvider>
|
</ModerationOptsProvider>
|
||||||
</MessagesProvider>
|
</LabelDefsProvider>
|
||||||
</StatsigProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</StatsigProvider>
|
||||||
</QueryProvider>
|
</ComposerProvider>
|
||||||
</React.Fragment>
|
</QueryProvider>
|
||||||
</VideoVolumeProvider>
|
</React.Fragment>
|
||||||
</RootSiblingParent>
|
</VideoVolumeProvider>
|
||||||
</Splash>
|
</RootSiblingParent>
|
||||||
|
</Splash>
|
||||||
|
</ContextMenuProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
)
|
)
|
||||||
|
|||||||
+51
-48
@@ -56,6 +56,7 @@ import {ToastContainer} from '#/view/com/util/Toast.web'
|
|||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
@@ -107,54 +108,56 @@ function InnerApp() {
|
|||||||
return (
|
return (
|
||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<RootSiblingParent>
|
<ContextMenuProvider>
|
||||||
<VideoVolumeProvider>
|
<RootSiblingParent>
|
||||||
<ActiveVideoProvider>
|
<VideoVolumeProvider>
|
||||||
<React.Fragment
|
<ActiveVideoProvider>
|
||||||
// Resets the entire tree below when it changes:
|
<React.Fragment
|
||||||
key={currentAccount?.did}>
|
// Resets the entire tree below when it changes:
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<ComposerProvider>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<StatsigProvider>
|
<ComposerProvider>
|
||||||
<MessagesProvider>
|
<StatsigProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<MessagesProvider>
|
||||||
<LabelDefsProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
<ModerationOptsProvider>
|
<LabelDefsProvider>
|
||||||
<LoggedOutViewProvider>
|
<ModerationOptsProvider>
|
||||||
<SelectedFeedProvider>
|
<LoggedOutViewProvider>
|
||||||
<HiddenRepliesProvider>
|
<SelectedFeedProvider>
|
||||||
<HomeBadgeProvider>
|
<HiddenRepliesProvider>
|
||||||
<UnreadNotifsProvider>
|
<HomeBadgeProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<UnreadNotifsProvider>
|
||||||
<MutedThreadsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<SafeAreaProvider>
|
<MutedThreadsProvider>
|
||||||
<ProgressGuideProvider>
|
<SafeAreaProvider>
|
||||||
<TrendingConfigProvider>
|
<ProgressGuideProvider>
|
||||||
<IntentDialogProvider>
|
<TrendingConfigProvider>
|
||||||
<Shell />
|
<IntentDialogProvider>
|
||||||
<NuxDialogs />
|
<Shell />
|
||||||
</IntentDialogProvider>
|
<NuxDialogs />
|
||||||
</TrendingConfigProvider>
|
</IntentDialogProvider>
|
||||||
</ProgressGuideProvider>
|
</TrendingConfigProvider>
|
||||||
</SafeAreaProvider>
|
</ProgressGuideProvider>
|
||||||
</MutedThreadsProvider>
|
</SafeAreaProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</MutedThreadsProvider>
|
||||||
</UnreadNotifsProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</HomeBadgeProvider>
|
</UnreadNotifsProvider>
|
||||||
</HiddenRepliesProvider>
|
</HomeBadgeProvider>
|
||||||
</SelectedFeedProvider>
|
</HiddenRepliesProvider>
|
||||||
</LoggedOutViewProvider>
|
</SelectedFeedProvider>
|
||||||
</ModerationOptsProvider>
|
</LoggedOutViewProvider>
|
||||||
</LabelDefsProvider>
|
</ModerationOptsProvider>
|
||||||
</MessagesProvider>
|
</LabelDefsProvider>
|
||||||
</StatsigProvider>
|
</MessagesProvider>
|
||||||
</ComposerProvider>
|
</StatsigProvider>
|
||||||
</QueryProvider>
|
</ComposerProvider>
|
||||||
<ToastContainer />
|
</QueryProvider>
|
||||||
</React.Fragment>
|
<ToastContainer />
|
||||||
</ActiveVideoProvider>
|
</React.Fragment>
|
||||||
</VideoVolumeProvider>
|
</ActiveVideoProvider>
|
||||||
</RootSiblingParent>
|
</VideoVolumeProvider>
|
||||||
|
</RootSiblingParent>
|
||||||
|
</ContextMenuProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {Pressable} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
Extrapolation,
|
||||||
|
interpolate,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedProps,
|
||||||
|
} 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'
|
||||||
|
|
||||||
|
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
|
||||||
|
|
||||||
|
export function Backdrop({
|
||||||
|
animation,
|
||||||
|
intensity = 50,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
animation: SharedValue<number>
|
||||||
|
intensity?: number
|
||||||
|
onPress?: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const animatedProps = useAnimatedProps(() => ({
|
||||||
|
intensity: interpolate(
|
||||||
|
animation.get(),
|
||||||
|
[0, 1],
|
||||||
|
[0, intensity],
|
||||||
|
Extrapolation.CLAMP,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedBlurView
|
||||||
|
animatedProps={animatedProps}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
tint="systemThinMaterialDark">
|
||||||
|
<Pressable
|
||||||
|
style={a.flex_1}
|
||||||
|
accessibilityLabel={_(msg`Close menu`)}
|
||||||
|
accessibilityHint={_(msg`Tap to close context menu`)}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
</AnimatedBlurView>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {Pressable} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
Extrapolation,
|
||||||
|
interpolate,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
|
||||||
|
export function Backdrop({
|
||||||
|
animation,
|
||||||
|
intensity = 50,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
animation: SharedValue<number>
|
||||||
|
intensity?: number
|
||||||
|
onPress?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: interpolate(
|
||||||
|
animation.get(),
|
||||||
|
[0, 1],
|
||||||
|
[0, intensity / 100],
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import type {ContextType, ItemContextType} from '#/components/ContextMenu/types'
|
||||||
|
|
||||||
|
export const Context = React.createContext<ContextType | null>(null)
|
||||||
|
|
||||||
|
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||||
|
|
||||||
|
export function useContextMenuContext() {
|
||||||
|
const context = React.useContext(Context)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useContextMenuContext must be used within a Context.Provider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextMenuItemContext() {
|
||||||
|
const context = React.useContext(ItemContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useContextMenuItemContext must be used within a Context.Provider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
|
import {
|
||||||
|
BackHandler,
|
||||||
|
Keyboard,
|
||||||
|
LayoutChangeEvent,
|
||||||
|
Pressable,
|
||||||
|
StyleProp,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||||
|
import Animated, {
|
||||||
|
clamp,
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withSpring,
|
||||||
|
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 {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useIsFocused} from '@react-navigation/native'
|
||||||
|
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||||
|
|
||||||
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {useHaptics} from '#/lib/haptics'
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isAndroid, isIOS} from '#/platform/detection'
|
||||||
|
import {atoms as a, platform, useTheme} from '#/alf'
|
||||||
|
import {
|
||||||
|
Context,
|
||||||
|
ItemContext,
|
||||||
|
useContextMenuContext,
|
||||||
|
useContextMenuItemContext,
|
||||||
|
} from '#/components/ContextMenu/context'
|
||||||
|
import {
|
||||||
|
ContextType,
|
||||||
|
ItemIconProps,
|
||||||
|
ItemProps,
|
||||||
|
ItemTextProps,
|
||||||
|
Measurement,
|
||||||
|
TriggerProps,
|
||||||
|
} from '#/components/ContextMenu/types'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {createPortalGroup} from '#/components/Portal'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Backdrop} from './Backdrop'
|
||||||
|
|
||||||
|
export {
|
||||||
|
type DialogControlProps as ContextMenuControlProps,
|
||||||
|
useDialogControl as useContextMenuControl,
|
||||||
|
} from '#/components/Dialog'
|
||||||
|
|
||||||
|
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
|
||||||
|
|
||||||
|
const SPRING: WithSpringConfig = {
|
||||||
|
mass: isIOS ? 1.25 : 0.75,
|
||||||
|
damping: 150,
|
||||||
|
stiffness: 1000,
|
||||||
|
restDisplacementThreshold: 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needs placing near the top of the provider stack, but BELOW the theme provider.
|
||||||
|
*/
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<PortalProvider>
|
||||||
|
{children}
|
||||||
|
<Outlet />
|
||||||
|
</PortalProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Root({children}: {children: React.ReactNode}) {
|
||||||
|
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||||
|
const animationSV = useSharedValue(0)
|
||||||
|
const translationSV = useSharedValue(0)
|
||||||
|
const isFocused = useIsFocused()
|
||||||
|
|
||||||
|
const clearMeasurement = useCallback(() => setMeasurement(null), [])
|
||||||
|
|
||||||
|
const context = useMemo<ContextType>(
|
||||||
|
() => ({
|
||||||
|
isOpen: !!measurement && isFocused,
|
||||||
|
measurement,
|
||||||
|
animationSV,
|
||||||
|
translationSV,
|
||||||
|
open: (evt: Measurement) => {
|
||||||
|
setMeasurement(evt)
|
||||||
|
animationSV.set(withSpring(1, SPRING))
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
animationSV.set(
|
||||||
|
withSpring(0, SPRING, finished => {
|
||||||
|
if (finished) {
|
||||||
|
translationSV.set(0)
|
||||||
|
runOnJS(clearMeasurement)()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
measurement,
|
||||||
|
setMeasurement,
|
||||||
|
isFocused,
|
||||||
|
animationSV,
|
||||||
|
translationSV,
|
||||||
|
clearMeasurement,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAndroid && context.isOpen) {
|
||||||
|
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
context.close()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => listener.remove()
|
||||||
|
}
|
||||||
|
}, [context])
|
||||||
|
|
||||||
|
return <Context.Provider value={context}>{children}</Context.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||||
|
const context = useContextMenuContext()
|
||||||
|
const playHaptic = useHaptics()
|
||||||
|
const {top: topInset} = useSafeAreaInsets()
|
||||||
|
const ref = useRef<View>(null)
|
||||||
|
const isFocused = useIsFocused()
|
||||||
|
const [image, setImage] = useState<string | null>(null)
|
||||||
|
const [pendingMeasurement, setPendingMeasurement] =
|
||||||
|
useState<Measurement | 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 doubleTapGesture = useMemo(() => {
|
||||||
|
return Gesture.Tap()
|
||||||
|
.numberOfTaps(2)
|
||||||
|
.hitSlop(HITSLOP_10)
|
||||||
|
.onEnd(open)
|
||||||
|
.runOnJS(true)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const pressAndHoldGesture = useMemo(() => {
|
||||||
|
return Gesture.LongPress()
|
||||||
|
.onStart(() => {
|
||||||
|
runOnJS(open)()
|
||||||
|
})
|
||||||
|
.cancelsTouchesInView(false)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const composedGestures = Gesture.Exclusive(
|
||||||
|
doubleTapGesture,
|
||||||
|
pressAndHoldGesture,
|
||||||
|
)
|
||||||
|
|
||||||
|
const {translationSV, animationSV} = context
|
||||||
|
|
||||||
|
const measurement = context.measurement || pendingMeasurement
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GestureDetector gesture={composedGestures}>
|
||||||
|
<View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
|
||||||
|
{children({
|
||||||
|
isNative: true,
|
||||||
|
control: {isOpen: context.isOpen, open},
|
||||||
|
state: {
|
||||||
|
pressed: false,
|
||||||
|
hovered: false,
|
||||||
|
focused: false,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
ref: null,
|
||||||
|
onPress: null,
|
||||||
|
onFocus: null,
|
||||||
|
onBlur: null,
|
||||||
|
onPressIn: null,
|
||||||
|
onPressOut: null,
|
||||||
|
accessibilityHint: null,
|
||||||
|
accessibilityLabel: label,
|
||||||
|
accessibilityRole: null,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
{isFocused && image && measurement && (
|
||||||
|
<Portal>
|
||||||
|
<TriggerClone
|
||||||
|
label={contentLabel}
|
||||||
|
translation={translationSV}
|
||||||
|
animation={animationSV}
|
||||||
|
image={image}
|
||||||
|
measurement={measurement}
|
||||||
|
onDisplay={() => {
|
||||||
|
if (pendingMeasurement) {
|
||||||
|
context.open(pendingMeasurement)
|
||||||
|
setPendingMeasurement(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* an image of the underlying trigger with a grow animation
|
||||||
|
*/
|
||||||
|
function TriggerClone({
|
||||||
|
translation,
|
||||||
|
animation,
|
||||||
|
image,
|
||||||
|
measurement,
|
||||||
|
onDisplay,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
translation: SharedValue<number>
|
||||||
|
animation: SharedValue<number>
|
||||||
|
image: string
|
||||||
|
measurement: Measurement
|
||||||
|
onDisplay: () => void
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const animatedStyles = useAnimatedStyle(() => ({
|
||||||
|
transform: [{translateY: translation.get() * animation.get()}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleError = useCallback(
|
||||||
|
(evt: ImageErrorEventData) => {
|
||||||
|
logger.error('Context menu image load error', {message: evt.error})
|
||||||
|
onDisplay()
|
||||||
|
},
|
||||||
|
[onDisplay],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
top: measurement.y,
|
||||||
|
left: measurement.x,
|
||||||
|
width: measurement.width,
|
||||||
|
height: measurement.height,
|
||||||
|
},
|
||||||
|
a.z_10,
|
||||||
|
a.pointer_events_none,
|
||||||
|
animatedStyles,
|
||||||
|
]}>
|
||||||
|
<Image
|
||||||
|
onDisplay={onDisplay}
|
||||||
|
onError={handleError}
|
||||||
|
source={image}
|
||||||
|
style={{
|
||||||
|
width: measurement.width,
|
||||||
|
height: measurement.height,
|
||||||
|
}}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityHint={_(msg`The subject of the context menu`)}
|
||||||
|
accessibilityIgnoresInvertColors={false}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_WIDTH = 230
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
align = 'left',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const context = useContextMenuContext()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
const frame = useSafeAreaFrame()
|
||||||
|
|
||||||
|
const {animationSV, translationSV} = context
|
||||||
|
|
||||||
|
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{translateY: translationSV.get() * animationSV.get()}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: clamp(animationSV.get(), 0, 1),
|
||||||
|
transform: [{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onLayout = useCallback(
|
||||||
|
(evt: LayoutChangeEvent) => {
|
||||||
|
if (!context.measurement) return // should not happen
|
||||||
|
let translation = 0
|
||||||
|
|
||||||
|
// pure vibes based
|
||||||
|
const TOP_INSET = insets.top + 80
|
||||||
|
const BOTTOM_INSET_IOS = insets.bottom + 20
|
||||||
|
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
|
||||||
|
|
||||||
|
const {height} = evt.nativeEvent.layout
|
||||||
|
const topPosition = context.measurement.y + context.measurement.height + 4
|
||||||
|
const bottomPosition = topPosition + height
|
||||||
|
const safeAreaBottomLimit =
|
||||||
|
frame.height -
|
||||||
|
platform({
|
||||||
|
ios: BOTTOM_INSET_IOS,
|
||||||
|
android: BOTTOM_INSET_ANDROID,
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
const diff = bottomPosition - safeAreaBottomLimit
|
||||||
|
if (diff > 0) {
|
||||||
|
translation = -diff
|
||||||
|
} else {
|
||||||
|
const distanceMessageFromTop = context.measurement.y - TOP_INSET
|
||||||
|
if (distanceMessageFromTop < 0) {
|
||||||
|
translation = -Math.max(distanceMessageFromTop, diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (translation !== 0) {
|
||||||
|
translationSV.set(translation)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[context.measurement, frame.height, insets, translationSV],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!context.isOpen || !context.measurement) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Context.Provider value={context}>
|
||||||
|
<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:
|
||||||
|
frame.x +
|
||||||
|
frame.width -
|
||||||
|
context.measurement.x -
|
||||||
|
context.measurement.width,
|
||||||
|
},
|
||||||
|
animatedContainerStyle,
|
||||||
|
]}>
|
||||||
|
{/* scaling element - has the scale/fade animation on it */}
|
||||||
|
<Animated.View
|
||||||
|
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
|
||||||
|
{
|
||||||
|
transformOrigin:
|
||||||
|
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{/* 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>
|
||||||
|
</Context.Provider>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const context = useContextMenuContext()
|
||||||
|
const playHaptic = useHaptics()
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const {
|
||||||
|
state: pressed,
|
||||||
|
onIn: onPressIn,
|
||||||
|
onOut: onPressOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
{...rest}
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityLabel={label}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onPress={e => {
|
||||||
|
context.close()
|
||||||
|
onPress?.(e)
|
||||||
|
}}
|
||||||
|
onPressIn={e => {
|
||||||
|
onPressIn()
|
||||||
|
rest.onPressIn?.(e)
|
||||||
|
playHaptic('Light')
|
||||||
|
}}
|
||||||
|
onPressOut={e => {
|
||||||
|
onPressOut()
|
||||||
|
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},
|
||||||
|
style,
|
||||||
|
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
||||||
|
]}>
|
||||||
|
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||||
|
{children}
|
||||||
|
</ItemContext.Provider>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemText({children, style}: ItemTextProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {disabled} = useContextMenuItemContext()
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
{paddingTop: 3},
|
||||||
|
style,
|
||||||
|
disabled && t.atoms.text_contrast_low,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {disabled} = useContextMenuItemContext()
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
size="md"
|
||||||
|
fill={
|
||||||
|
disabled
|
||||||
|
? t.atoms.text_contrast_low.color
|
||||||
|
: t.atoms.text_contrast_medium.color
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRadio({selected}: {selected: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.border_contrast_high,
|
||||||
|
{
|
||||||
|
borderWidth: 1,
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{selected ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.rounded_full,
|
||||||
|
{height: 14, width: 14},
|
||||||
|
selected ? {backgroundColor: t.palette.primary_500} : {},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelText({children}: {children: React.ReactNode}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[a.font_bold, t.atoms.text_contrast_medium, {marginBottom: -8}]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Divider() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[t.atoms.border_contrast_low, a.flex_1, {borderTopWidth: 3}]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from '#/components/Menu'
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native'
|
||||||
|
import {SharedValue} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {RadixPassThroughTriggerProps} from '#/components/Menu/types'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
GroupProps,
|
||||||
|
ItemIconProps,
|
||||||
|
ItemProps,
|
||||||
|
ItemTextProps,
|
||||||
|
} from '#/components/Menu/types'
|
||||||
|
|
||||||
|
export type Measurement = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextType = {
|
||||||
|
isOpen: boolean
|
||||||
|
measurement: Measurement | null
|
||||||
|
/* Spring animation between 0 and 1 */
|
||||||
|
animationSV: SharedValue<number>
|
||||||
|
/* Translation in Y axis to ensure everything's onscreen */
|
||||||
|
translationSV: SharedValue<number>
|
||||||
|
open: (evt: Measurement) => void
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemContextType = {
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerProps = {
|
||||||
|
children(props: TriggerChildProps): React.ReactNode
|
||||||
|
label: string
|
||||||
|
/**
|
||||||
|
* When activated, this is the accessibility label for the entire thing that has been triggered.
|
||||||
|
* For example, if the trigger is a message bubble, use the message content.
|
||||||
|
*
|
||||||
|
* @platform ios, android
|
||||||
|
*/
|
||||||
|
contentLabel: string
|
||||||
|
hint?: string
|
||||||
|
role?: AccessibilityRole
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}
|
||||||
|
export type TriggerChildProps =
|
||||||
|
| {
|
||||||
|
isNative: true
|
||||||
|
control: {isOpen: boolean; open: () => void}
|
||||||
|
state: {
|
||||||
|
hovered: false
|
||||||
|
focused: false
|
||||||
|
pressed: false
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* We don't necessarily know what these will be spread on to, so we
|
||||||
|
* should add props one-by-one.
|
||||||
|
*
|
||||||
|
* On web, these properties are applied to a parent `Pressable`, so this
|
||||||
|
* object is empty.
|
||||||
|
*/
|
||||||
|
props: {
|
||||||
|
ref: null
|
||||||
|
onPress: null
|
||||||
|
onFocus: null
|
||||||
|
onBlur: null
|
||||||
|
onPressIn: null
|
||||||
|
onPressOut: null
|
||||||
|
accessibilityHint: null
|
||||||
|
accessibilityLabel: string
|
||||||
|
accessibilityRole: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isNative: false
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
state: {
|
||||||
|
hovered: false
|
||||||
|
focused: false
|
||||||
|
pressed: false
|
||||||
|
}
|
||||||
|
props: RadixPassThroughTriggerProps & {
|
||||||
|
onPress: () => void
|
||||||
|
onFocus: () => void
|
||||||
|
onBlur: () => void
|
||||||
|
onMouseEnter: () => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
accessibilityHint?: string
|
||||||
|
accessibilityLabel: string
|
||||||
|
accessibilityRole: AccessibilityRole
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,9 @@ import React from 'react'
|
|||||||
|
|
||||||
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
||||||
|
|
||||||
export const Context = React.createContext<ContextType>({
|
export const Context = React.createContext<ContextType | null>(null)
|
||||||
// @ts-ignore
|
|
||||||
control: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ItemContext = React.createContext<ItemContextType>({
|
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||||
disabled: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
export function useMenuContext() {
|
export function useMenuContext() {
|
||||||
const context = React.useContext(Context)
|
const context = React.useContext(Context)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function Root({
|
|||||||
children,
|
children,
|
||||||
control,
|
control,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control?: Dialog.DialogOuterProps['control']
|
control?: Dialog.DialogControlProps
|
||||||
}>) {
|
}>) {
|
||||||
const defaultControl = Dialog.useDialogControl()
|
const defaultControl = Dialog.useDialogControl()
|
||||||
const context = React.useMemo<ContextType>(
|
const context = React.useMemo<ContextType>(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function Root({
|
|||||||
children,
|
children,
|
||||||
control,
|
control,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control?: Dialog.DialogOuterProps['control']
|
control?: Dialog.DialogControlProps
|
||||||
}>) {
|
}>) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const defaultControl = useMenuControl()
|
const defaultControl = useMenuControl()
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import React from 'react'
|
import {View} from 'react-native'
|
||||||
import {Keyboard} from 'react-native'
|
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from 'react-native-reanimated'
|
|
||||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
|
||||||
import {useHaptics} from '#/lib/haptics'
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||||
import {useMenuControl} from '#/components/Menu'
|
|
||||||
|
|
||||||
export function ActionsWrapper({
|
export function ActionsWrapper({
|
||||||
message,
|
message,
|
||||||
@@ -28,71 +16,52 @@ export function ActionsWrapper({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const playHaptic = useHaptics()
|
|
||||||
const menuControl = useMenuControl()
|
|
||||||
|
|
||||||
const scale = useSharedValue(1)
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{scale: scale.get()}],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const open = React.useCallback(() => {
|
|
||||||
playHaptic()
|
|
||||||
Keyboard.dismiss()
|
|
||||||
menuControl.open()
|
|
||||||
}, [menuControl, playHaptic])
|
|
||||||
|
|
||||||
const shrink = React.useCallback(() => {
|
|
||||||
'worklet'
|
|
||||||
cancelAnimation(scale)
|
|
||||||
scale.set(() => withTiming(1, {duration: 200}))
|
|
||||||
}, [scale])
|
|
||||||
|
|
||||||
const doubleTapGesture = Gesture.Tap()
|
|
||||||
.numberOfTaps(2)
|
|
||||||
.hitSlop(HITSLOP_10)
|
|
||||||
.onEnd(open)
|
|
||||||
.runOnJS(true)
|
|
||||||
|
|
||||||
const pressAndHoldGesture = Gesture.LongPress()
|
|
||||||
.onStart(() => {
|
|
||||||
'worklet'
|
|
||||||
scale.set(() =>
|
|
||||||
withTiming(1.05, {duration: 200}, finished => {
|
|
||||||
if (!finished) return
|
|
||||||
runOnJS(open)()
|
|
||||||
shrink()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.onTouchesUp(shrink)
|
|
||||||
.onTouchesMove(shrink)
|
|
||||||
.cancelsTouchesInView(false)
|
|
||||||
|
|
||||||
const composedGestures = Gesture.Exclusive(
|
|
||||||
doubleTapGesture,
|
|
||||||
pressAndHoldGesture,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={composedGestures}>
|
<MessageContextMenu message={message}>
|
||||||
<Animated.View
|
{trigger =>
|
||||||
style={[
|
// will always be true, since this file is platform split
|
||||||
{
|
trigger.isNative && (
|
||||||
maxWidth: '80%',
|
<View style={[a.flex_1, a.relative]}>
|
||||||
},
|
{/* {isNative && (
|
||||||
isFromSelf ? a.self_end : a.self_start,
|
<View
|
||||||
animatedStyle,
|
style={[
|
||||||
]}
|
a.rounded_full,
|
||||||
accessible={true}
|
a.absolute,
|
||||||
accessibilityActions={[
|
{bottom: '100%'},
|
||||||
{name: 'activate', label: _(msg`Open message options`)},
|
isFromSelf ? a.right_0 : a.left_0,
|
||||||
]}
|
t.atoms.bg,
|
||||||
onAccessibilityAction={open}>
|
a.flex_row,
|
||||||
{children}
|
a.shadow_lg,
|
||||||
<MessageMenu message={message} control={menuControl} />
|
a.py_xs,
|
||||||
</Animated.View>
|
a.px_md,
|
||||||
</GestureDetector>
|
a.gap_md,
|
||||||
|
a.mb_xs,
|
||||||
|
]}>
|
||||||
|
{['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
|
||||||
|
<Text key={emoji} style={[a.text_center, {fontSize: 32}]}>
|
||||||
|
{emoji}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)} */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{maxWidth: '80%'},
|
||||||
|
isFromSelf
|
||||||
|
? [a.self_end, a.align_end]
|
||||||
|
: [a.self_start, a.align_start],
|
||||||
|
]}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityActions={[
|
||||||
|
{name: 'activate', label: _(msg`Open message options`)},
|
||||||
|
]}
|
||||||
|
onAccessibilityAction={trigger.control.open}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</MessageContextMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
|
||||||
import {useMenuControl} from '#/components/Menu'
|
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid'
|
||||||
|
|
||||||
export function ActionsWrapper({
|
export function ActionsWrapper({
|
||||||
message,
|
message,
|
||||||
@@ -15,8 +15,8 @@ export function ActionsWrapper({
|
|||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const menuControl = useMenuControl()
|
|
||||||
const viewRef = React.useRef(null)
|
const viewRef = React.useRef(null)
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
const [showActions, setShowActions] = React.useState(false)
|
const [showActions, setShowActions] = React.useState(false)
|
||||||
|
|
||||||
@@ -42,39 +42,39 @@ export function ActionsWrapper({
|
|||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onMouseLeave}
|
onBlur={onMouseLeave}
|
||||||
style={StyleSheet.flatten([a.flex_1, a.flex_row])}
|
style={[a.flex_1, isFromSelf ? a.flex_row : a.flex_row_reverse]}
|
||||||
ref={viewRef}>
|
ref={viewRef}>
|
||||||
{isFromSelf && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.mr_xl,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
marginLeft: 'auto',
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<MessageMenu
|
|
||||||
message={message}
|
|
||||||
control={menuControl}
|
|
||||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={[
|
||||||
maxWidth: '80%',
|
a.justify_center,
|
||||||
}}>
|
isFromSelf
|
||||||
|
? [a.mr_xl, {marginLeft: 'auto'}]
|
||||||
|
: [a.ml_xl, {marginRight: 'auto'}],
|
||||||
|
]}>
|
||||||
|
<MessageContextMenu 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_sm,
|
||||||
|
a.rounded_full,
|
||||||
|
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<DotsHorizontalIcon size="md" style={t.atoms.text} />
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</MessageContextMenu>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[{maxWidth: '80%'}, isFromSelf ? a.align_end : a.align_start]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
{!isFromSelf && (
|
|
||||||
<View style={[a.flex_row, a.align_center, a.ml_xl]}>
|
|
||||||
<MessageMenu
|
|
||||||
message={message}
|
|
||||||
control={menuControl}
|
|
||||||
triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {LayoutAnimation, Pressable, View} 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 {ChatBskyConvoDefs, RichText} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
@@ -8,33 +8,28 @@ 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 {isWeb} 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 {atoms as a, useTheme} from '#/alf'
|
import * as ContextMenu from '#/components/ContextMenu'
|
||||||
|
import {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 {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
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 Menu from '#/components/Menu'
|
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {usePromptControl} from '#/components/Prompt'
|
import {usePromptControl} from '#/components/Prompt'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
|
|
||||||
|
|
||||||
export let MessageMenu = ({
|
export let MessageContextMenu = ({
|
||||||
message,
|
message,
|
||||||
control,
|
children,
|
||||||
triggerOpacity,
|
|
||||||
}: {
|
}: {
|
||||||
triggerOpacity?: number
|
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
control: Menu.MenuControlProps
|
children: TriggerProps['children']
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
const deleteControl = usePromptControl()
|
const deleteControl = usePromptControl()
|
||||||
@@ -75,69 +70,64 @@ export let MessageMenu = ({
|
|||||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||||
}, [_, convo, message.id])
|
}, [_, convo, message.id])
|
||||||
|
|
||||||
|
const sender = convo.convo.members.find(
|
||||||
|
member => member.did === message.sender.did,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu.Root control={control}>
|
<ContextMenu.Root>
|
||||||
{isWeb && (
|
<ContextMenu.Trigger
|
||||||
<View style={{opacity: triggerOpacity}}>
|
label={_(msg`Message options`)}
|
||||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
contentLabel={_(
|
||||||
{({props, state}) => (
|
msg`Message from @${
|
||||||
<Pressable
|
sender?.handle ?? // should always be defined
|
||||||
{...props}
|
'unknown'
|
||||||
style={[
|
}: ${message.text}`,
|
||||||
a.p_sm,
|
)}>
|
||||||
a.rounded_full,
|
{children}
|
||||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_25,
|
</ContextMenu.Trigger>
|
||||||
]}>
|
|
||||||
<DotsHorizontal size="md" style={t.atoms.text} />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</Menu.Trigger>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu.Outer>
|
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}>
|
||||||
{message.text.length > 0 && (
|
{message.text.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Menu.Group>
|
<ContextMenu.Item
|
||||||
<Menu.Item
|
testID="messageDropdownTranslateBtn"
|
||||||
testID="messageDropdownTranslateBtn"
|
label={_(msg`Translate`)}
|
||||||
label={_(msg`Translate`)}
|
onPress={onPressTranslateMessage}>
|
||||||
onPress={onPressTranslateMessage}>
|
<ContextMenu.ItemText>{_(msg`Translate`)}</ContextMenu.ItemText>
|
||||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
<ContextMenu.ItemIcon icon={Translate} position="right" />
|
||||||
<Menu.ItemIcon icon={Translate} position="right" />
|
</ContextMenu.Item>
|
||||||
</Menu.Item>
|
<ContextMenu.Item
|
||||||
<Menu.Item
|
testID="messageDropdownCopyBtn"
|
||||||
testID="messageDropdownCopyBtn"
|
label={_(msg`Copy message text`)}
|
||||||
label={_(msg`Copy message text`)}
|
onPress={onCopyMessage}>
|
||||||
onPress={onCopyMessage}>
|
<ContextMenu.ItemText>
|
||||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
{_(msg`Copy message text`)}
|
||||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
</ContextMenu.ItemText>
|
||||||
</Menu.Item>
|
<ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||||
</Menu.Group>
|
</ContextMenu.Item>
|
||||||
<Menu.Divider />
|
<ContextMenu.Divider />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Menu.Group>
|
<ContextMenu.Item
|
||||||
<Menu.Item
|
testID="messageDropdownDeleteBtn"
|
||||||
testID="messageDropdownDeleteBtn"
|
label={_(msg`Delete message for me`)}
|
||||||
label={_(msg`Delete message for me`)}
|
onPress={() => deleteControl.open()}>
|
||||||
onPress={() => deleteControl.open()}>
|
<ContextMenu.ItemText>{_(msg`Delete for me`)}</ContextMenu.ItemText>
|
||||||
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
|
<ContextMenu.ItemIcon icon={Trash} position="right" />
|
||||||
<Menu.ItemIcon icon={Trash} position="right" />
|
</ContextMenu.Item>
|
||||||
</Menu.Item>
|
{!isFromSelf && (
|
||||||
{!isFromSelf && (
|
<ContextMenu.Item
|
||||||
<Menu.Item
|
testID="messageDropdownReportBtn"
|
||||||
testID="messageDropdownReportBtn"
|
label={_(msg`Report message`)}
|
||||||
label={_(msg`Report message`)}
|
onPress={() => reportControl.open()}>
|
||||||
onPress={() => reportControl.open()}>
|
<ContextMenu.ItemText>{_(msg`Report`)}</ContextMenu.ItemText>
|
||||||
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
|
<ContextMenu.ItemIcon icon={Warning} position="right" />
|
||||||
<Menu.ItemIcon icon={Warning} position="right" />
|
</ContextMenu.Item>
|
||||||
</Menu.Item>
|
)}
|
||||||
)}
|
</ContextMenu.Outer>
|
||||||
</Menu.Group>
|
</ContextMenu.Root>
|
||||||
</Menu.Outer>
|
|
||||||
</Menu.Root>
|
|
||||||
|
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
currentScreen="conversation"
|
currentScreen="conversation"
|
||||||
@@ -158,4 +148,4 @@ export let MessageMenu = ({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageMenu = React.memo(MessageMenu)
|
MessageContextMenu = React.memo(MessageContextMenu)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {useWindowDimensions, View} from 'react-native'
|
||||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||||
|
|
||||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, tokens, useTheme, web} from '#/alf'
|
||||||
import {MessageContextProvider} from './MessageContext'
|
import {MessageContextProvider} from './MessageContext'
|
||||||
|
|
||||||
let MessageItemEmbed = ({
|
let MessageItemEmbed = ({
|
||||||
@@ -12,15 +12,32 @@ let MessageItemEmbed = ({
|
|||||||
embed: AppBskyEmbedRecord.View
|
embed: AppBskyEmbedRecord.View
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const screen = useWindowDimensions()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContextProvider>
|
<MessageContextProvider>
|
||||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
<View
|
||||||
<PostEmbeds
|
style={[
|
||||||
embed={embed}
|
a.my_xs,
|
||||||
allowNestedQuotes
|
t.atoms.bg,
|
||||||
viewContext={PostEmbedViewContext.Feed}
|
a.rounded_md,
|
||||||
/>
|
native({
|
||||||
|
flexBasis: 0,
|
||||||
|
width: Math.min(screen.width, 600) / 1.4,
|
||||||
|
}),
|
||||||
|
web({
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 360,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<View style={{marginTop: tokens.space.sm * -1}}>
|
||||||
|
<PostEmbeds
|
||||||
|
embed={embed}
|
||||||
|
allowNestedQuotes
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</MessageContextProvider>
|
</MessageContextProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user