diff options
author | Paul Frazee <pfrazee@gmail.com> | 2024-11-23 16:20:24 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-23 16:20:24 -0800 |
commit | 32bf8122e8c8a0fbadd53b8a015cfbc9014519a2 (patch) | |
tree | 55bd24596e6fadadbf4326b26e3d14e418c5c7bb /src/view/com/util | |
parent | 523d1f01a51c0e85e49916fb42b204f7004ffac1 (diff) | |
parent | b4d07c4112b9a62b5380948051aa4a7fd391a2d4 (diff) | |
download | voidsky-32bf8122e8c8a0fbadd53b8a015cfbc9014519a2.tar.zst |
Merge branch 'main' into main
Diffstat (limited to 'src/view/com/util')
54 files changed, 1410 insertions, 1451 deletions
diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index 25e882e87..86751861f 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -18,7 +18,7 @@ export function createCustomBackdrop( // animated variables const opacity = useAnimatedStyle(() => ({ opacity: interpolate( - animatedIndex.value, // current snap index + animatedIndex.get(), // current snap index [-1, 0], // input range [0, 0.5], // output range Extrapolation.CLAMP, diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx index 587d84462..7f1632936 100644 --- a/src/view/com/util/EmptyState.tsx +++ b/src/view/com/util/EmptyState.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {IconProp} from '@fortawesome/fontawesome-svg-core' import { diff --git a/src/view/com/util/EmptyStateWithButton.tsx b/src/view/com/util/EmptyStateWithButton.tsx index 7b7aa129e..fcac6df08 100644 --- a/src/view/com/util/EmptyStateWithButton.tsx +++ b/src/view/com/util/EmptyStateWithButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import {IconProp} from '@fortawesome/fontawesome-svg-core' import { diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 46b94932b..c4211ffbc 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, {Component, ErrorInfo, ReactNode} from 'react' +import {Component, ErrorInfo, ReactNode} from 'react' import {StyleProp, ViewStyle} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' diff --git a/src/view/com/util/FeedInfoText.tsx b/src/view/com/util/FeedInfoText.tsx index da5c48af7..55eb1bad4 100644 --- a/src/view/com/util/FeedInfoText.tsx +++ b/src/view/com/util/FeedInfoText.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {sanitizeDisplayName} from '#/lib/strings/display-names' diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 2cc3e30ca..f83258e45 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -18,6 +18,7 @@ import { useNavigationDeduped, } from '#/lib/hooks/useNavigationDeduped' import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getTabState, TabState} from '#/lib/routes/helpers' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -25,6 +26,7 @@ import { } from '#/lib/strings/url-helpers' import {TypographyVariant} from '#/lib/ThemeContext' import {isAndroid, isWeb} from '#/platform/detection' +import {emitSoftReset} from '#/state/events' import {useModalControls} from '#/state/modals' import {WebAuxClickWrapper} from '#/view/com/util/WebAuxClickWrapper' import {useTheme} from '#/alf' @@ -254,7 +256,7 @@ export const TextLink = memo(function TextLink({ if (isExternal) { return { target: '_blank', - // rel: 'noopener noreferrer', + // rel: 'noopener', } } return {} @@ -400,15 +402,22 @@ function onPressInner( } else { closeModal() // close any active modals + const [routeName, params] = router.matchPath(href) if (navigationAction === 'push') { // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.push(...router.matchPath(href))) + navigation.dispatch(StackActions.push(routeName, params)) } else if (navigationAction === 'replace') { // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.replace(...router.matchPath(href))) + navigation.dispatch(StackActions.replace(routeName, params)) } else if (navigationAction === 'navigate') { - // @ts-ignore we're not able to type check on this one -prf - navigation.navigate(...router.matchPath(href)) + const state = navigation.getState() + const tabState = getTabState(state, routeName) + if (tabState === TabState.InsideAtRoot) { + emitSoftReset() + } else { + // @ts-ignore we're not able to type check on this one -prf + navigation.navigate(routeName, params) + } } else { throw Error('Unsupported navigator action.') } diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 0425514e4..fa93ec5e6 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -7,7 +7,8 @@ import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIX import {useDedupe} from '#/lib/hooks/useDedupe' import {useScrollHandlers} from '#/lib/ScrollContext' import {addStyle} from '#/lib/styles' -import {isIOS} from '#/platform/detection' +import {isAndroid, isIOS} from '#/platform/detection' +import {useLightbox} from '#/state/lightbox' import {useTheme} from '#/alf' import {FlatList_INTERNAL} from './Views' @@ -52,6 +53,7 @@ function ListImpl<ItemT>( const isScrolledDown = useSharedValue(false) const t = useTheme() const dedupe = useDedupe(400) + const {activeLightbox} = useLightbox() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -77,8 +79,8 @@ function ListImpl<ItemT>( onScrollFromContext?.(e, ctx) const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT - if (isScrolledDown.value !== didScrollDown) { - isScrolledDown.value = didScrollDown + if (isScrolledDown.get() !== didScrollDown) { + isScrolledDown.set(didScrollDown) if (onScrolledDownChange != null) { runOnJS(handleScrolledDownChange)(didScrollDown) } @@ -143,9 +145,11 @@ function ListImpl<ItemT>( contentOffset={contentOffset} refreshControl={refreshControl} onScroll={scrollHandler} + scrollsToTop={!activeLightbox} scrollEventThrottle={1} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} + showsVerticalScrollIndicator={!isAndroid} style={style} ref={ref} /> diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index d9a2e351e..f112d2d0a 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -46,9 +46,9 @@ function ListImpl<ItemT>( keyExtractor, refreshing: _unsupportedRefreshing, onStartReached, - onStartReachedThreshold = 0, + onStartReachedThreshold = 2, onEndReached, - onEndReachedThreshold = 0, + onEndReachedThreshold = 2, onRefresh: _unsupportedOnRefresh, onScrolledDownChange, onContentSizeChange, diff --git a/src/view/com/util/LoadMoreRetryBtn.tsx b/src/view/com/util/LoadMoreRetryBtn.tsx index 863e8e2f5..07bd733ea 100644 --- a/src/view/com/util/LoadMoreRetryBtn.tsx +++ b/src/view/com/util/LoadMoreRetryBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 6620eb8e2..25ce460d4 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { DimensionValue, StyleProp, @@ -140,7 +139,7 @@ export function NotificationLoadingPlaceholder({ const pal = usePalette('default') return ( <View style={[styles.notification, pal.view, style]}> - <View style={[{width: 70}, a.align_end, a.pr_sm, a.pt_2xs]}> + <View style={[{width: 60}, a.align_end, a.pr_sm, a.pt_2xs]}> <HeartIconFilled size="xl" style={{color: pal.colors.backgroundLight}} @@ -149,8 +148,8 @@ export function NotificationLoadingPlaceholder({ <View style={{flex: 1}}> <View style={[a.flex_row, s.mb10]}> <LoadingPlaceholder - width={30} - height={30} + width={35} + height={35} style={styles.smallAvatar} /> </View> @@ -310,7 +309,7 @@ const styles = StyleSheet.create({ padding: 5, }, avatar: { - borderRadius: 26, + borderRadius: 999, marginRight: 10, marginLeft: 8, }, @@ -324,11 +323,11 @@ const styles = StyleSheet.create({ margin: 1, }, profileCardAvi: { - borderRadius: 20, + borderRadius: 999, marginRight: 10, }, smallAvatar: { - borderRadius: 15, + borderRadius: 999, marginRight: 10, }, }) diff --git a/src/view/com/util/LoadingScreen.tsx b/src/view/com/util/LoadingScreen.tsx index 15066d625..5d2aeb38f 100644 --- a/src/view/com/util/LoadingScreen.tsx +++ b/src/view/com/util/LoadingScreen.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {ActivityIndicator, View} from 'react-native' import {s} from '#/lib/styles' diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 23dffc561..0d084993b 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -3,6 +3,7 @@ import {NativeScrollEvent} from 'react-native' import { cancelAnimation, interpolate, + makeMutable, useSharedValue, withSpring, } from 'react-native-reanimated' @@ -20,6 +21,18 @@ function clamp(num: number, min: number, max: number) { return Math.min(Math.max(num, min), max) } +const V0 = makeMutable( + withSpring(0, { + overshootClamping: true, + }), +) + +const V1 = makeMutable( + withSpring(1, { + overshootClamping: true, + }), +) + export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() const {headerMode} = useMinimalShellMode() @@ -31,9 +44,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (v: boolean) => { 'worklet' cancelAnimation(headerMode) - headerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) + headerMode.set(v ? V1.get() : V0.get()) }, [headerMode], ) @@ -41,9 +52,9 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { useEffect(() => { if (isWeb) { return listenToForcedWindowScroll(() => { - startDragOffset.value = null - startMode.value = null - didJustRestoreScroll.value = true + startDragOffset.set(null) + startMode.set(null) + didJustRestoreScroll.set(true) }) } }) @@ -52,13 +63,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - if (startDragOffset.value === null) { + const startDragOffsetValue = startDragOffset.get() + if (startDragOffsetValue === null) { return } - const didScrollDown = e.contentOffset.y > startDragOffset.value - startDragOffset.value = null - startMode.value = null - if (e.contentOffset.y < headerHeight.value) { + const didScrollDown = e.contentOffset.y > startDragOffsetValue + startDragOffset.set(null) + startMode.set(null) + if (e.contentOffset.y < headerHeight.get()) { // If we're close to the top, show the shell. setMode(false) } else if (didScrollDown) { @@ -66,7 +78,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { setMode(true) } else { // Snap to whichever state is the closest. - setMode(Math.round(headerMode.value) === 1) + setMode(Math.round(headerMode.get()) === 1) } } }, @@ -77,8 +89,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - startDragOffset.value = e.contentOffset.y - startMode.value = headerMode.value + startDragOffset.set(e.contentOffset.y) + startMode.set(headerMode.get()) } }, [headerMode, startDragOffset, startMode], @@ -112,10 +124,12 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - if (startDragOffset.value === null || startMode.value === null) { + const startDragOffsetValue = startDragOffset.get() + const startModeValue = startMode.get() + if (startDragOffsetValue === null || startModeValue === null) { if ( - headerMode.value !== 0 && - e.contentOffset.y < headerHeight.value + headerMode.get() !== 0 && + e.contentOffset.y < headerHeight.get() ) { // If we're close enough to the top, always show the shell. // Even if we're not dragging. @@ -126,29 +140,29 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // The "mode" value is always between 0 and 1. // Figure out how much to move it based on the current dragged distance. - const dy = e.contentOffset.y - startDragOffset.value + const dy = e.contentOffset.y - startDragOffsetValue const dProgress = interpolate( dy, - [-headerHeight.value, headerHeight.value], + [-headerHeight.get(), headerHeight.get()], [-1, 1], ) - const newValue = clamp(startMode.value + dProgress, 0, 1) - if (newValue !== headerMode.value) { + const newValue = clamp(startModeValue + dProgress, 0, 1) + if (newValue !== headerMode.get()) { // Manually adjust the value. This won't be (and shouldn't be) animated. // Cancel any any existing animation cancelAnimation(headerMode) - headerMode.value = newValue + headerMode.set(newValue) } } else { - if (didJustRestoreScroll.value) { - didJustRestoreScroll.value = false + if (didJustRestoreScroll.get()) { + didJustRestoreScroll.set(false) // Don't hide/show navbar based on scroll restoratoin. return } // On the web, we don't try to follow the drag because we don't know when it ends. // Instead, show/hide immediately based on whether we're scrolling up or down. - const dy = e.contentOffset.y - (startDragOffset.value ?? 0) - startDragOffset.value = e.contentOffset.y + const dy = e.contentOffset.y - (startDragOffset.get() ?? 0) + startDragOffset.set(e.contentOffset.y) if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) { setMode(false) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c0166a16e..5384f6827 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -49,6 +49,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { precacheProfile(queryClient, opts.author) }, [queryClient, opts.author]) + const timestampLabel = niceDate(i18n, opts.timestamp) + return ( <View style={[ @@ -115,8 +117,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { {({timeElapsed}) => ( <WebOnlyInlineLinkText to={opts.postHref} - label={niceDate(i18n, opts.timestamp)} - title={niceDate(i18n, opts.timestamp)} + label={timestampLabel} + title={timestampLabel} disableMismatchWarning disableUnderline onPress={onBeforePressPost} diff --git a/src/view/com/util/PressableWithHover.tsx b/src/view/com/util/PressableWithHover.tsx index 48659e229..19a1968cc 100644 --- a/src/view/com/util/PressableWithHover.tsx +++ b/src/view/com/util/PressableWithHover.tsx @@ -1,4 +1,4 @@ -import React, {forwardRef, PropsWithChildren} from 'react' +import {forwardRef, PropsWithChildren} from 'react' import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native' import {View} from 'react-native' diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx deleted file mode 100644 index cf9d347af..000000000 --- a/src/view/com/util/Selector.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, {createRef, useMemo, useRef, useState} from 'react' -import {Animated, Pressable, StyleSheet, View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePalette} from '#/lib/hooks/usePalette' -import {Text} from './text/Text' - -interface Layout { - x: number - width: number -} - -export function Selector({ - selectedIndex, - items, - panX, - onSelect, -}: { - selectedIndex: number - items: string[] - panX: Animated.Value - onSelect?: (index: number) => void -}) { - const {_} = useLingui() - const containerRef = useRef<View>(null) - const pal = usePalette('default') - const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( - undefined, - ) - const itemRefs = useMemo( - () => Array.from({length: items.length}).map(() => createRef<View>()), - [items.length], - ) - - const currentLayouts = useMemo(() => { - const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0} - const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0} - const right = itemLayouts?.[selectedIndex + 1] || { - x: middle.x + 20, - width: middle.width, - } - return [left, middle, right] - }, [selectedIndex, itemLayouts]) - - const underlineStyle = { - backgroundColor: pal.colors.text, - left: panX.interpolate({ - inputRange: [-1, 0, 1], - outputRange: [ - currentLayouts[0].x, - currentLayouts[1].x, - currentLayouts[2].x, - ], - }), - width: panX.interpolate({ - inputRange: [-1, 0, 1], - outputRange: [ - currentLayouts[0].width, - currentLayouts[1].width, - currentLayouts[2].width, - ], - }), - } - - const onLayout = () => { - const promises = [] - for (let i = 0; i < items.length; i++) { - promises.push( - new Promise<Layout>(resolve => { - if (!containerRef.current || !itemRefs[i].current) { - return resolve({x: 0, width: 0}) - } - itemRefs[i].current?.measureLayout( - containerRef.current, - (x: number, _y: number, width: number) => { - resolve({x, width}) - }, - ) - }), - ) - } - Promise.all(promises).then((layouts: Layout[]) => { - setItemLayouts(layouts) - }) - } - - const onPressItem = (index: number) => { - onSelect?.(index) - } - - const numItems = items.length - - return ( - <View - style={[pal.view, styles.outer]} - onLayout={onLayout} - ref={containerRef}> - <Animated.View style={[styles.underline, underlineStyle]} /> - {items.map((item, i) => { - const selected = i === selectedIndex - return ( - <Pressable - testID={`selector-${i}`} - key={item} - onPress={() => onPressItem(i)} - accessibilityLabel={_(msg`Select ${item}`)} - accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}> - <View style={styles.item} ref={itemRefs[i]}> - <Text - style={ - selected - ? [styles.labelSelected, pal.text] - : [styles.label, pal.textLight] - }> - {item} - </Text> - </View> - </Pressable> - ) - })} - </View> - ) -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - paddingTop: 8, - paddingBottom: 12, - paddingHorizontal: 14, - }, - item: { - marginRight: 14, - paddingHorizontal: 10, - }, - label: { - fontWeight: '600', - }, - labelSelected: { - fontWeight: '600', - }, - underline: { - position: 'absolute', - height: 4, - bottom: 0, - }, -}) diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 51e76bdc3..b57e676ae 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,6 +1,20 @@ -import React, {useEffect, useState} from 'react' -import {View} from 'react-native' -import Animated, {FadeInUp, FadeOutUp} from 'react-native-reanimated' +import {useEffect, useMemo, useRef, useState} from 'react' +import {AccessibilityInfo, View} from 'react-native' +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler' +import Animated, { + FadeInUp, + FadeOutUp, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withDecay, + withSpring, +} from 'react-native-reanimated' import RootSiblings from 'react-native-root-siblings' import {useSafeAreaInsets} from 'react-native-safe-area-context' import { @@ -8,6 +22,7 @@ import { Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {IS_TEST} from '#/env' @@ -19,74 +34,174 @@ export function show( icon: FontAwesomeProps['icon'] = 'check', ) { if (IS_TEST) return - const item = new RootSiblings(<Toast message={message} icon={icon} />) - // timeout has some leeway to account for the animation - setTimeout(() => { - item.destroy() - }, TIMEOUT + 1e3) + AccessibilityInfo.announceForAccessibility(message) + const item = new RootSiblings( + <Toast message={message} icon={icon} destroy={() => item.destroy()} />, + ) } function Toast({ message, icon, + destroy, }: { message: string icon: FontAwesomeProps['icon'] + destroy: () => void }) { const t = useTheme() const {top} = useSafeAreaInsets() + const isPanning = useSharedValue(false) + const dismissSwipeTranslateY = useSharedValue(0) + const [cardHeight, setCardHeight] = useState(0) // for the exit animation to work on iOS the animated component // must not be the root component // so we need to wrap it in a view and unmount the toast ahead of time const [alive, setAlive] = useState(true) - useEffect(() => { + const hideAndDestroyImmediately = () => { + setAlive(false) setTimeout(() => { - setAlive(false) - }, TIMEOUT) - }, []) + destroy() + }, 1e3) + } + + const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() + const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT) + }) + const pauseDestroy = useNonReactiveCallback(() => { + clearTimeout(destroyTimeoutRef.current) + }) + + useEffect(() => { + hideAndDestroyAfterTimeout() + }, [hideAndDestroyAfterTimeout]) + + const panGesture = useMemo(() => { + return Gesture.Pan() + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onStart(() => { + 'worklet' + if (!alive) return + isPanning.set(true) + runOnJS(pauseDestroy)() + }) + .onUpdate(e => { + 'worklet' + if (!alive) return + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + 'worklet' + if (!alive) return + runOnJS(hideAndDestroyAfterTimeout)() + isPanning.set(false) + if (e.velocityY < -100) { + if (dismissSwipeTranslateY.value === 0) { + // HACK: If the initial value is 0, withDecay() animation doesn't start. + // This is a bug in Reanimated, but for now we'll work around it like this. + dismissSwipeTranslateY.value = 1 + } + dismissSwipeTranslateY.value = withDecay({ + velocity: e.velocityY, + velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), + deceleration: 1, + }) + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 500, + damping: 50, + }) + } + }) + }, [ + dismissSwipeTranslateY, + isPanning, + alive, + hideAndDestroyAfterTimeout, + pauseDestroy, + ]) + + const topOffset = top + 10 + + useAnimatedReaction( + () => + !isPanning.get() && + dismissSwipeTranslateY.get() < -topOffset - cardHeight, + (isSwipedAway, prevIsSwipedAway) => { + 'worklet' + if (isSwipedAway && !prevIsSwipedAway) { + runOnJS(destroy)() + } + }, + ) + + const animatedStyle = useAnimatedStyle(() => { + const translation = dismissSwipeTranslateY.get() + return { + transform: [ + { + translateY: translation > 0 ? translation ** 0.7 : translation, + }, + ], + } + }) return ( - <View - style={[a.absolute, {top: top + 15, left: 16, right: 16}]} - pointerEvents="none"> + <GestureHandlerRootView + style={[a.absolute, {top: topOffset, left: 16, right: 16}]} + pointerEvents="box-none"> {alive && ( <Animated.View entering={FadeInUp} exiting={FadeOutUp} - style={[ - a.flex_1, - t.atoms.bg, - a.shadow_lg, - t.atoms.border_contrast_medium, - a.rounded_sm, - a.px_md, - a.py_lg, - a.border, - a.flex_row, - a.gap_md, - ]}> - <View + style={[a.flex_1]}> + <Animated.View + onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} + accessibilityRole="alert" + accessible={true} + accessibilityLabel={message} + accessibilityHint="" + onAccessibilityEscape={hideAndDestroyImmediately} style={[ - a.flex_shrink_0, - a.rounded_full, - {width: 32, height: 32}, - t.atoms.bg_contrast_25, - a.align_center, - a.justify_center, + a.flex_1, + t.atoms.bg, + a.shadow_lg, + t.atoms.border_contrast_medium, + a.rounded_sm, + a.border, + animatedStyle, ]}> - <FontAwesomeIcon - icon={icon} - size={16} - style={t.atoms.text_contrast_low} - /> - </View> - <View style={[a.h_full, a.justify_center, a.flex_1]}> - <Text style={a.text_md}>{message}</Text> - </View> + <GestureDetector gesture={panGesture}> + <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> + <View + style={[ + a.flex_shrink_0, + a.rounded_full, + {width: 32, height: 32}, + {backgroundColor: t.palette.primary_50}, + a.align_center, + a.justify_center, + ]}> + <FontAwesomeIcon + icon={icon} + size={16} + style={t.atoms.text_contrast_medium} + /> + </View> + <View style={[a.h_full, a.justify_center, a.flex_1]}> + <Text style={a.text_md}>{message}</Text> + </View> + </View> + </GestureDetector> + </Animated.View> </Animated.View> )} - </View> + </GestureHandlerRootView> ) } diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index 1f9eb479b..96798e61c 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -3,7 +3,7 @@ */ import React, {useEffect, useState} from 'react' -import {StyleSheet, Text, View} from 'react-native' +import {Pressable, StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -43,6 +43,14 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { style={styles.icon as FontAwesomeIconStyle} /> <Text style={styles.text}>{activeToast.text}</Text> + <Pressable + style={styles.dismissBackdrop} + accessibilityLabel="Dismiss" + accessibilityHint="" + onPress={() => { + setActiveToast(undefined) + }} + /> </View> )} </> @@ -77,6 +85,13 @@ const styles = StyleSheet.create({ backgroundColor: '#000c', borderRadius: 10, }, + dismissBackdrop: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + right: 0, + }, icon: { color: '#fff', flexShrink: 0, diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index 8a444d590..64aa37ff2 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' diff --git a/src/view/com/util/anim/TriggerableAnimated.tsx b/src/view/com/util/anim/TriggerableAnimated.tsx deleted file mode 100644 index 97605fb46..000000000 --- a/src/view/com/util/anim/TriggerableAnimated.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import {Animated, StyleProp, View, ViewStyle} from 'react-native' - -import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' - -type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation -type FinishCb = () => void - -interface TriggeredAnimation { - start: CreateAnimFn - style: ( - interp: Animated.Value, - ) => Animated.WithAnimatedValue<StyleProp<ViewStyle>> -} - -export interface TriggerableAnimatedRef { - trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void -} - -type TriggerableAnimatedProps = React.PropsWithChildren<{}> - -type PropsInner = TriggerableAnimatedProps & { - anim: TriggeredAnimation - onFinish: () => void -} - -export const TriggerableAnimated = React.forwardRef< - TriggerableAnimatedRef, - TriggerableAnimatedProps ->(function TriggerableAnimatedImpl({children, ...props}, ref) { - const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>( - undefined, - ) - const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>( - undefined, - ) - React.useImperativeHandle(ref, () => ({ - trigger(v: TriggeredAnimation, cb?: FinishCb) { - setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate - setAnim(v) - }, - })) - const onFinish = () => { - finishCb?.() - setAnim(undefined) - setFinishCb(undefined) - } - return ( - <View key="triggerable"> - {anim ? ( - <AnimatingView anim={anim} onFinish={onFinish} {...props}> - {children} - </AnimatingView> - ) : ( - children - )} - </View> - ) -}) - -function AnimatingView({ - anim, - onFinish, - children, -}: React.PropsWithChildren<PropsInner>) { - const interp = useAnimatedValue(0) - React.useEffect(() => { - anim?.start(interp).start(() => { - onFinish() - }) - }) - const animStyle = anim?.style(interp) - return <Animated.View style={animStyle}>{children}</Animated.View> -} diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index f0ef3a40f..c09d1b2e6 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { StyleProp, StyleSheet, diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index 1b23141f3..b66f43789 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, View} from 'react-native' import { FontAwesomeIcon, diff --git a/src/view/com/util/fab/FAB.web.tsx b/src/view/com/util/fab/FAB.web.tsx index 601d505a8..b9f3a0b07 100644 --- a/src/view/com/util/fab/FAB.web.tsx +++ b/src/view/com/util/fab/FAB.web.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {View} from 'react-native' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 48e0005bc..77e283625 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,4 +1,4 @@ -import React, {ComponentProps} from 'react' +import {ComponentProps} from 'react' import {StyleSheet, TouchableWithoutFeedback} from 'react-native' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx index 9df53f116..594bb48f6 100644 --- a/src/view/com/util/forms/DateInput.tsx +++ b/src/view/com/util/forms/DateInput.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import DatePicker from 'react-native-date-picker' import { diff --git a/src/view/com/util/forms/DateInput.web.tsx b/src/view/com/util/forms/DateInput.web.tsx index ea6102356..988d8aee6 100644 --- a/src/view/com/util/forms/DateInput.web.tsx +++ b/src/view/com/util/forms/DateInput.web.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useState} from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' // @ts-ignore types not available -prf import {unstable_createElement} from 'react-native-web' diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx index 22237f5e1..8fc9be6da 100644 --- a/src/view/com/util/forms/NativeDropdown.tsx +++ b/src/view/com/util/forms/NativeDropdown.tsx @@ -5,10 +5,9 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as DropdownMenu from 'zeego/dropdown-menu' import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' -import {HITSLOP_10} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {useTheme} from '#/lib/ThemeContext' -import {isIOS, isWeb} from '#/platform/detection' +import {isIOS} from '#/platform/detection' import {Portal} from '#/components/Portal' // Custom Dropdown Menu Components @@ -30,31 +29,18 @@ export const DropdownMenuTrigger = DropdownMenu.create( (props: TriggerProps) => { const theme = useTheme() const defaultCtrlColor = theme.palette.default.postCtrl - const ref = React.useRef<View>(null) - - // HACK - // fire a click event on the keyboard press to trigger the dropdown - // -prf - const onPress = isWeb - ? (evt: any) => { - if (evt instanceof KeyboardEvent) { - // @ts-ignore web only -prf - ref.current?.click() - } - } - : undefined return ( + // This Pressable doesn't actually do anything other than + // provide the "pressed state" visual feedback. <Pressable testID={props.testID} accessibilityRole="button" accessibilityLabel={props.accessibilityLabel} accessibilityHint={props.accessibilityHint} - style={({pressed}) => [{opacity: pressed ? 0.5 : 1}]} - hitSlop={HITSLOP_10} - onPress={onPress}> + style={({pressed}) => [{opacity: pressed ? 0.8 : 1}]}> <DropdownMenu.Trigger action="press"> - <View ref={ref}> + <View> {props.children ? ( props.children ) : ( diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 22751d8bf..fd577605a 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,83 +1,27 @@ -import React, {memo, useCallback} from 'react' +import React, {memo, useMemo, useState} from 'react' import { - Platform, Pressable, type PressableProps, type StyleProp, type ViewStyle, } from 'react-native' -import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedThreadgate, - AtUri, RichText as RichTextAPI, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/native' -import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {getCurrentRoute} from '#/lib/routes/helpers' -import {makeProfileLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' -import {shareUrl} from '#/lib/sharing' -import {logEvent} from '#/lib/statsig/statsig' -import {richTextToString} from '#/lib/strings/rich-text-helpers' -import {toShareUrl} from '#/lib/strings/url-helpers' import {useTheme} from '#/lib/ThemeContext' -import {getTranslatorLink} from '#/locale/helpers' -import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {Shadow} from '#/state/cache/post-shadow' -import {useFeedFeedbackContext} from '#/state/feed-feedback' -import {useLanguagePrefs} from '#/state/preferences' -import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' -import {usePinnedPostMutation} from '#/state/queries/pinned-post' -import { - usePostDeleteMutation, - useThreadMuteMutationQueue, -} from '#/state/queries/post' -import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' -import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' -import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' -import {useSession} from '#/state/session' -import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' -import {EmbedDialog} from '#/components/dialogs/Embed' -import { - PostInteractionSettingsDialog, - usePrefetchPostInteractionSettings, -} from '#/components/dialogs/PostInteractionSettingsDialog' -import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' -import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' +import {atoms as a, useTheme as useAlf} from '#/alf' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' -import { - EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, - EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, -} from '#/components/icons/Emoji' -import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' -import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' -import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' -import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' -import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' -import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' -import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' -import {Loader} from '#/components/Loader' +import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' -import * as Prompt from '#/components/Prompt' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {EventStopper} from '../EventStopper' -import * as Toast from '../Toast' +import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' let PostDropdownBtn = ({ testID, @@ -102,266 +46,27 @@ let PostDropdownBtn = ({ timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { - const {hasSession, currentAccount} = useSession() const theme = useTheme() const alf = useAlf() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl - const langPrefs = useLanguagePrefs() - const {mutateAsync: deletePostMutate} = usePostDeleteMutation() - const {mutateAsync: pinPostMutate, isPending: isPinPending} = - usePinnedPostMutation() - const hiddenPosts = useHiddenPosts() - const {hidePost} = useHiddenPostsApi() - const feedFeedback = useFeedFeedbackContext() - const openLink = useOpenLink() - const navigation = useNavigation<NavigationProp>() - const {mutedWordsDialogControl} = useGlobalDialogsControlContext() - const reportDialogControl = useReportDialogControl() - const deletePromptControl = useDialogControl() - const hidePromptControl = useDialogControl() - const loggedOutWarningPromptControl = useDialogControl() - const embedPostControl = useDialogControl() - const sendViaChatControl = useDialogControl() - const postInteractionSettingsDialogControl = useDialogControl() - const quotePostDetachConfirmControl = useDialogControl() - const hideReplyConfirmControl = useDialogControl() - const {mutateAsync: toggleReplyVisibility} = - useToggleReplyVisibilityMutation() - - const postUri = post.uri - const postCid = post.cid - const postAuthor = post.author - const quoteEmbed = React.useMemo(() => { - if (!currentAccount || !post.embed) return - return getMaybeDetachedQuoteEmbed({ - viewerDid: currentAccount.did, - post, - }) - }, [post, currentAccount]) - - const rootUri = record.reply?.root?.uri || postUri - const isReply = Boolean(record.reply) - const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( - post, - rootUri, - ) - const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) - const isAuthor = postAuthor.did === currentAccount?.did - const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ - threadgateRecord, - }) - const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) - const isPinned = post.viewer?.pinned - - const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = - useToggleQuoteDetachmentMutation() - - const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ - postUri: post.uri, - rootPostUri: rootUri, - }) - - const href = React.useMemo(() => { - const urip = new AtUri(postUri) - return makeProfileLink(postAuthor, 'post', urip.rkey) - }, [postUri, postAuthor]) - - const translatorUrl = getTranslatorLink( - record.text, - langPrefs.primaryLanguage, - ) - - const onDeletePost = React.useCallback(() => { - deletePostMutate({uri: postUri}).then( - () => { - Toast.show(_(msg`Post deleted`)) - - const route = getCurrentRoute(navigation.getState()) - if (route.name === 'PostThread') { - const params = route.params as CommonNavigatorParams['PostThread'] - if ( - currentAccount && - isAuthor && - (params.name === currentAccount.handle || - params.name === currentAccount.did) - ) { - const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) - if (currentHref === href && navigation.canGoBack()) { - navigation.goBack() - } - } - } - }, - e => { - logger.error('Failed to delete post', {message: e}) - Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') + const menuControl = useMenuControl() + const [hasBeenOpen, setHasBeenOpen] = useState(false) + const lazyMenuControl = useMemo( + () => ({ + ...menuControl, + open() { + setHasBeenOpen(true) + // HACK. We need the state update to be flushed by the time + // menuControl.open() fires but RN doesn't expose flushSync. + setTimeout(menuControl.open) }, - ) - }, [ - navigation, - postUri, - deletePostMutate, - postAuthor, - currentAccount, - isAuthor, - href, - _, - ]) - - const onToggleThreadMute = React.useCallback(() => { - try { - if (isThreadMuted) { - unmuteThread() - Toast.show(_(msg`You will now receive notifications for this thread`)) - } else { - muteThread() - Toast.show( - _(msg`You will no longer receive notifications for this thread`), - ) - } - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to toggle thread mute', {message: e}) - Toast.show( - _(msg`Failed to toggle thread mute, please try again`), - 'xmark', - ) - } - } - }, [isThreadMuted, unmuteThread, _, muteThread]) - - const onCopyPostText = React.useCallback(() => { - const str = richTextToString(richText, true) - - Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') - }, [_, richText]) - - const onPressTranslate = React.useCallback(async () => { - await openLink(translatorUrl) - }, [openLink, translatorUrl]) - - const onHidePost = React.useCallback(() => { - hidePost({uri: postUri}) - }, [postUri, hidePost]) - - const hideInPWI = React.useMemo(() => { - return !!postAuthor.labels?.find( - label => label.val === '!no-unauthenticated', - ) - }, [postAuthor]) - - const showLoggedOutWarning = - postAuthor.did !== currentAccount?.did && hideInPWI - - const onSharePost = React.useCallback(() => { - const url = toShareUrl(href) - shareUrl(url) - }, [href]) - - const onPressShowMore = React.useCallback(() => { - feedFeedback.sendInteraction({ - event: 'app.bsky.feed.defs#requestMore', - item: postUri, - feedContext: postFeedContext, - }) - Toast.show(_(msg`Feedback sent!`)) - }, [feedFeedback, postUri, postFeedContext, _]) - - const onPressShowLess = React.useCallback(() => { - feedFeedback.sendInteraction({ - event: 'app.bsky.feed.defs#requestLess', - item: postUri, - feedContext: postFeedContext, - }) - Toast.show(_(msg`Feedback sent!`)) - }, [feedFeedback, postUri, postFeedContext, _]) - - const onSelectChatToShareTo = React.useCallback( - (conversation: string) => { - navigation.navigate('MessagesConversation', { - conversation, - embed: postUri, - }) - }, - [navigation, postUri], + }), + [menuControl, setHasBeenOpen], ) - - const onToggleQuotePostAttachment = React.useCallback(async () => { - if (!quoteEmbed) return - - const action = quoteEmbed.isDetached ? 'reattach' : 'detach' - const isDetach = action === 'detach' - - try { - await toggleQuoteDetachment({ - post, - quoteUri: quoteEmbed.uri, - action: quoteEmbed.isDetached ? 'reattach' : 'detach', - }) - Toast.show( - isDetach - ? _(msg`Quote post was successfully detached`) - : _(msg`Quote post was re-attached`), - ) - } catch (e: any) { - Toast.show(_(msg`Updating quote attachment failed`)) - logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) - } - }, [_, quoteEmbed, post, toggleQuoteDetachment]) - - const canHidePostForMe = !isAuthor && !isPostHidden - const canEmbed = isWeb && gtMobile && !hideInPWI - const canHideReplyForEveryone = - !isAuthor && isRootPostAuthor && !isPostHidden && isReply - const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer - - const onToggleReplyVisibility = React.useCallback(async () => { - // TODO no threadgate? - if (!canHideReplyForEveryone) return - - const action = isReplyHiddenByThreadgate ? 'show' : 'hide' - const isHide = action === 'hide' - - try { - await toggleReplyVisibility({ - postUri: rootUri, - replyUri: postUri, - action, - }) - Toast.show( - isHide - ? _(msg`Reply was successfully hidden`) - : _(msg`Reply visibility updated`), - ) - } catch (e: any) { - Toast.show(_(msg`Updating reply visibility failed`)) - logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) - } - }, [ - _, - isReplyHiddenByThreadgate, - rootUri, - postUri, - canHideReplyForEveryone, - toggleReplyVisibility, - ]) - - const onPressPin = useCallback(() => { - logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) - pinPostMutate({ - postUri, - postCid, - action: isPinned ? 'unpin' : 'pin', - }) - }, [isPinned, pinPostMutate, postCid, postUri]) - return ( <EventStopper onKeyDown={false}> - <Menu.Root> + <Menu.Root control={lazyMenuControl}> <Menu.Trigger label={_(msg`Open post options menu`)}> {({props, state}) => { return ( @@ -385,366 +90,19 @@ let PostDropdownBtn = ({ ) }} </Menu.Trigger> - - <Menu.Outer> - {isAuthor && ( - <> - <Menu.Group> - <Menu.Item - testID="pinPostBtn" - label={ - isPinned - ? _(msg`Unpin from profile`) - : _(msg`Pin to your profile`) - } - disabled={isPinPending} - onPress={onPressPin}> - <Menu.ItemText> - {isPinned - ? _(msg`Unpin from profile`) - : _(msg`Pin to your profile`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isPinPending ? Loader : PinIcon} - position="right" - /> - </Menu.Item> - </Menu.Group> - <Menu.Divider /> - </> - )} - - <Menu.Group> - {(!hideInPWI || hasSession) && ( - <> - <Menu.Item - testID="postDropdownTranslateBtn" - label={_(msg`Translate`)} - onPress={onPressTranslate}> - <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> - <Menu.ItemIcon icon={Translate} position="right" /> - </Menu.Item> - - <Menu.Item - testID="postDropdownCopyTextBtn" - label={_(msg`Copy post text`)} - onPress={onCopyPostText}> - <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> - <Menu.ItemIcon icon={ClipboardIcon} position="right" /> - </Menu.Item> - </> - )} - - {hasSession && ( - <Menu.Item - testID="postDropdownSendViaDMBtn" - label={_(msg`Send via direct message`)} - onPress={() => sendViaChatControl.open()}> - <Menu.ItemText> - <Trans>Send via direct message</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={Send} position="right" /> - </Menu.Item> - )} - - <Menu.Item - testID="postDropdownShareBtn" - label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - onPress={() => { - if (showLoggedOutWarning) { - loggedOutWarningPromptControl.open() - } else { - onSharePost() - } - }}> - <Menu.ItemText> - {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - </Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - - {canEmbed && ( - <Menu.Item - testID="postDropdownEmbedBtn" - label={_(msg`Embed post`)} - onPress={() => embedPostControl.open()}> - <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> - <Menu.ItemIcon icon={CodeBrackets} position="right" /> - </Menu.Item> - )} - </Menu.Group> - - {hasSession && feedFeedback.enabled && ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="postDropdownShowMoreBtn" - label={_(msg`Show more like this`)} - onPress={onPressShowMore}> - <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> - <Menu.ItemIcon icon={EmojiSmile} position="right" /> - </Menu.Item> - - <Menu.Item - testID="postDropdownShowLessBtn" - label={_(msg`Show less like this`)} - onPress={onPressShowLess}> - <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> - <Menu.ItemIcon icon={EmojiSad} position="right" /> - </Menu.Item> - </Menu.Group> - </> - )} - - {hasSession && ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="postDropdownMuteThreadBtn" - label={ - isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) - } - onPress={onToggleThreadMute}> - <Menu.ItemText> - {isThreadMuted - ? _(msg`Unmute thread`) - : _(msg`Mute thread`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isThreadMuted ? Unmute : Mute} - position="right" - /> - </Menu.Item> - - <Menu.Item - testID="postDropdownMuteWordsBtn" - label={_(msg`Mute words & tags`)} - onPress={() => mutedWordsDialogControl.open()}> - <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> - <Menu.ItemIcon icon={Filter} position="right" /> - </Menu.Item> - </Menu.Group> - </> - )} - - {hasSession && - (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( - <> - <Menu.Divider /> - <Menu.Group> - {canHidePostForMe && ( - <Menu.Item - testID="postDropdownHideBtn" - label={ - isReply - ? _(msg`Hide reply for me`) - : _(msg`Hide post for me`) - } - onPress={() => hidePromptControl.open()}> - <Menu.ItemText> - {isReply - ? _(msg`Hide reply for me`) - : _(msg`Hide post for me`)} - </Menu.ItemText> - <Menu.ItemIcon icon={EyeSlash} position="right" /> - </Menu.Item> - )} - {canHideReplyForEveryone && ( - <Menu.Item - testID="postDropdownHideBtn" - label={ - isReplyHiddenByThreadgate - ? _(msg`Show reply for everyone`) - : _(msg`Hide reply for everyone`) - } - onPress={ - isReplyHiddenByThreadgate - ? onToggleReplyVisibility - : () => hideReplyConfirmControl.open() - }> - <Menu.ItemText> - {isReplyHiddenByThreadgate - ? _(msg`Show reply for everyone`) - : _(msg`Hide reply for everyone`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} - position="right" - /> - </Menu.Item> - )} - - {canDetachQuote && ( - <Menu.Item - disabled={isDetachPending} - testID="postDropdownHideBtn" - label={ - quoteEmbed.isDetached - ? _(msg`Re-attach quote`) - : _(msg`Detach quote`) - } - onPress={ - quoteEmbed.isDetached - ? onToggleQuotePostAttachment - : () => quotePostDetachConfirmControl.open() - }> - <Menu.ItemText> - {quoteEmbed.isDetached - ? _(msg`Re-attach quote`) - : _(msg`Detach quote`)} - </Menu.ItemText> - <Menu.ItemIcon - icon={ - isDetachPending - ? Loader - : quoteEmbed.isDetached - ? Eye - : EyeSlash - } - position="right" - /> - </Menu.Item> - )} - </Menu.Group> - </> - )} - - {hasSession && ( - <> - <Menu.Divider /> - <Menu.Group> - {!isAuthor && ( - <Menu.Item - testID="postDropdownReportBtn" - label={_(msg`Report post`)} - onPress={() => reportDialogControl.open()}> - <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> - <Menu.ItemIcon icon={Warning} position="right" /> - </Menu.Item> - )} - - {isAuthor && ( - <> - <Menu.Item - testID="postDropdownEditPostInteractions" - label={_(msg`Edit interaction settings`)} - onPress={() => - postInteractionSettingsDialogControl.open() - } - {...(isAuthor - ? Platform.select({ - web: { - onHoverIn: prefetchPostInteractionSettings, - }, - native: { - onPressIn: prefetchPostInteractionSettings, - }, - }) - : {})}> - <Menu.ItemText> - {_(msg`Edit interaction settings`)} - </Menu.ItemText> - <Menu.ItemIcon icon={Gear} position="right" /> - </Menu.Item> - <Menu.Item - testID="postDropdownDeleteBtn" - label={_(msg`Delete post`)} - onPress={() => deletePromptControl.open()}> - <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> - <Menu.ItemIcon icon={Trash} position="right" /> - </Menu.Item> - </> - )} - </Menu.Group> - </> - )} - </Menu.Outer> - </Menu.Root> - - <Prompt.Basic - control={deletePromptControl} - title={_(msg`Delete this post?`)} - description={_( - msg`If you remove this post, you won't be able to recover it.`, - )} - onConfirm={onDeletePost} - confirmButtonCta={_(msg`Delete`)} - confirmButtonColor="negative" - /> - - <Prompt.Basic - control={hidePromptControl} - title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} - description={_( - msg`This post will be hidden from feeds and threads. This cannot be undone.`, + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + <PostDropdownMenuItems + testID={testID} + post={post} + postFeedContext={postFeedContext} + record={record} + richText={richText} + timestamp={timestamp} + threadgateRecord={threadgateRecord} + /> )} - onConfirm={onHidePost} - confirmButtonCta={_(msg`Hide`)} - /> - - <ReportDialog - control={reportDialogControl} - params={{ - type: 'post', - uri: postUri, - cid: postCid, - }} - /> - - <Prompt.Basic - control={loggedOutWarningPromptControl} - title={_(msg`Note about sharing`)} - description={_( - msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`, - )} - onConfirm={onSharePost} - confirmButtonCta={_(msg`Share anyway`)} - /> - - {canEmbed && ( - <EmbedDialog - control={embedPostControl} - postCid={postCid} - postUri={postUri} - record={record} - postAuthor={postAuthor} - timestamp={timestamp} - /> - )} - - <SendViaChatDialog - control={sendViaChatControl} - onSelectChat={onSelectChatToShareTo} - /> - - <PostInteractionSettingsDialog - control={postInteractionSettingsDialogControl} - postUri={post.uri} - rootPostUri={rootUri} - initialThreadgateView={post.threadgate} - /> - - <Prompt.Basic - control={quotePostDetachConfirmControl} - title={_(msg`Detach quote post?`)} - description={_( - msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, - )} - onConfirm={onToggleQuotePostAttachment} - confirmButtonCta={_(msg`Yes, detach`)} - /> - - <Prompt.Basic - control={hideReplyConfirmControl} - title={_(msg`Hide this reply?`)} - description={_( - msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, - )} - onConfirm={onToggleReplyVisibility} - confirmButtonCta={_(msg`Yes, hide`)} - /> + </Menu.Root> </EventStopper> ) } diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx new file mode 100644 index 000000000..149bb9ad2 --- /dev/null +++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx @@ -0,0 +1,751 @@ +import React, {memo, useCallback} from 'react' +import { + Platform, + type PressableProps, + type StyleProp, + type ViewStyle, +} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getCurrentRoute} from '#/lib/routes/helpers' +import {makeProfileLink} from '#/lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' +import {logEvent} from '#/lib/statsig/statsig' +import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {getTranslatorLink} from '#/locale/helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {Shadow} from '#/state/cache/post-shadow' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useFeedFeedbackContext} from '#/state/feed-feedback' +import {useLanguagePrefs} from '#/state/preferences' +import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' +import {usePinnedPostMutation} from '#/state/queries/pinned-post' +import { + usePostDeleteMutation, + useThreadMuteMutationQueue, +} from '#/state/queries/post' +import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' +import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' +import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' +import {useSession} from '#/state/session' +import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' +import {useBreakpoints} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {EmbedDialog} from '#/components/dialogs/Embed' +import { + PostInteractionSettingsDialog, + usePrefetchPostInteractionSettings, +} from '#/components/dialogs/PostInteractionSettingsDialog' +import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' +import { + EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, +} from '#/components/icons/Emoji' +import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import * as Toast from '../Toast' + +let PostDropdownMenuItems = ({ + post, + postFeedContext, + record, + richText, + timestamp, + threadgateRecord, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + postFeedContext: string | undefined + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp<ViewStyle> + hitSlop?: PressableProps['hitSlop'] + size?: 'lg' | 'md' | 'sm' + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const {mutateAsync: deletePostMutate} = usePostDeleteMutation() + const {mutateAsync: pinPostMutate, isPending: isPinPending} = + usePinnedPostMutation() + const hiddenPosts = useHiddenPosts() + const {hidePost} = useHiddenPostsApi() + const feedFeedback = useFeedFeedbackContext() + const openLink = useOpenLink() + const navigation = useNavigation<NavigationProp>() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const blockPromptControl = useDialogControl() + const reportDialogControl = useReportDialogControl() + const deletePromptControl = useDialogControl() + const hidePromptControl = useDialogControl() + const loggedOutWarningPromptControl = useDialogControl() + const embedPostControl = useDialogControl() + const sendViaChatControl = useDialogControl() + const postInteractionSettingsDialogControl = useDialogControl() + const quotePostDetachConfirmControl = useDialogControl() + const hideReplyConfirmControl = useDialogControl() + const {mutateAsync: toggleReplyVisibility} = + useToggleReplyVisibilityMutation() + + const postUri = post.uri + const postCid = post.cid + const postAuthor = useProfileShadow(post.author) + const quoteEmbed = React.useMemo(() => { + if (!currentAccount || !post.embed) return + return getMaybeDetachedQuoteEmbed({ + viewerDid: currentAccount.did, + post, + }) + }, [post, currentAccount]) + + const rootUri = record.reply?.root?.uri || postUri + const isReply = Boolean(record.reply) + const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( + post, + rootUri, + ) + const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) + const isAuthor = postAuthor.did === currentAccount?.did + const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ + threadgateRecord, + }) + const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) + const isPinned = post.viewer?.pinned + + const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = + useToggleQuoteDetachmentMutation() + + const [queueBlock] = useProfileBlockMutationQueue(postAuthor) + + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ + postUri: post.uri, + rootPostUri: rootUri, + }) + + const href = React.useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const translatorUrl = getTranslatorLink( + record.text, + langPrefs.primaryLanguage, + ) + + const onDeletePost = React.useCallback(() => { + deletePostMutate({uri: postUri}).then( + () => { + Toast.show(_(msg`Post deleted`)) + + const route = getCurrentRoute(navigation.getState()) + if (route.name === 'PostThread') { + const params = route.params as CommonNavigatorParams['PostThread'] + if ( + currentAccount && + isAuthor && + (params.name === currentAccount.handle || + params.name === currentAccount.did) + ) { + const currentHref = makeProfileLink(postAuthor, 'post', params.rkey) + if (currentHref === href && navigation.canGoBack()) { + navigation.goBack() + } + } + } + }, + e => { + logger.error('Failed to delete post', {message: e}) + Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') + }, + ) + }, [ + navigation, + postUri, + deletePostMutate, + postAuthor, + currentAccount, + isAuthor, + href, + _, + ]) + + const onToggleThreadMute = React.useCallback(() => { + try { + if (isThreadMuted) { + unmuteThread() + Toast.show(_(msg`You will now receive notifications for this thread`)) + } else { + muteThread() + Toast.show( + _(msg`You will no longer receive notifications for this thread`), + ) + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to toggle thread mute', {message: e}) + Toast.show( + _(msg`Failed to toggle thread mute, please try again`), + 'xmark', + ) + } + } + }, [isThreadMuted, unmuteThread, _, muteThread]) + + const onCopyPostText = React.useCallback(() => { + const str = richTextToString(richText, true) + + Clipboard.setStringAsync(str) + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') + }, [_, richText]) + + const onPressTranslate = React.useCallback(async () => { + await openLink(translatorUrl, true) + }, [openLink, translatorUrl]) + + const onHidePost = React.useCallback(() => { + hidePost({uri: postUri}) + }, [postUri, hidePost]) + + const hideInPWI = React.useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + const showLoggedOutWarning = + postAuthor.did !== currentAccount?.did && hideInPWI + + const onSharePost = React.useCallback(() => { + const url = toShareUrl(href) + shareUrl(url) + }, [href]) + + const onPressShowMore = React.useCallback(() => { + feedFeedback.sendInteraction({ + event: 'app.bsky.feed.defs#requestMore', + item: postUri, + feedContext: postFeedContext, + }) + Toast.show(_(msg`Feedback sent!`)) + }, [feedFeedback, postUri, postFeedContext, _]) + + const onPressShowLess = React.useCallback(() => { + feedFeedback.sendInteraction({ + event: 'app.bsky.feed.defs#requestLess', + item: postUri, + feedContext: postFeedContext, + }) + Toast.show(_(msg`Feedback sent!`)) + }, [feedFeedback, postUri, postFeedContext, _]) + + const onSelectChatToShareTo = React.useCallback( + (conversation: string) => { + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + }, + [navigation, postUri], + ) + + const onToggleQuotePostAttachment = React.useCallback(async () => { + if (!quoteEmbed) return + + const action = quoteEmbed.isDetached ? 'reattach' : 'detach' + const isDetach = action === 'detach' + + try { + await toggleQuoteDetachment({ + post, + quoteUri: quoteEmbed.uri, + action: quoteEmbed.isDetached ? 'reattach' : 'detach', + }) + Toast.show( + isDetach + ? _(msg`Quote post was successfully detached`) + : _(msg`Quote post was re-attached`), + ) + } catch (e: any) { + Toast.show(_(msg`Updating quote attachment failed`)) + logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) + } + }, [_, quoteEmbed, post, toggleQuoteDetachment]) + + const canHidePostForMe = !isAuthor && !isPostHidden + const canEmbed = isWeb && gtMobile && !hideInPWI + const canHideReplyForEveryone = + !isAuthor && isRootPostAuthor && !isPostHidden && isReply + const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer + + const onToggleReplyVisibility = React.useCallback(async () => { + // TODO no threadgate? + if (!canHideReplyForEveryone) return + + const action = isReplyHiddenByThreadgate ? 'show' : 'hide' + const isHide = action === 'hide' + + try { + await toggleReplyVisibility({ + postUri: rootUri, + replyUri: postUri, + action, + }) + Toast.show( + isHide + ? _(msg`Reply was successfully hidden`) + : _(msg`Reply visibility updated`), + ) + } catch (e: any) { + Toast.show(_(msg`Updating reply visibility failed`)) + logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) + } + }, [ + _, + isReplyHiddenByThreadgate, + rootUri, + postUri, + canHideReplyForEveryone, + toggleReplyVisibility, + ]) + + const onPressPin = useCallback(() => { + logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) + pinPostMutate({ + postUri, + postCid, + action: isPinned ? 'unpin' : 'pin', + }) + }, [isPinned, pinPostMutate, postCid, postUri]) + + const onBlockAuthor = useCallback(async () => { + try { + await queueBlock() + Toast.show(_(msg`Account blocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + } + } + }, [_, queueBlock]) + + return ( + <> + <Menu.Outer> + {isAuthor && ( + <> + <Menu.Group> + <Menu.Item + testID="pinPostBtn" + label={ + isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`) + } + disabled={isPinPending} + onPress={onPressPin}> + <Menu.ItemText> + {isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isPinPending ? Loader : PinIcon} + position="right" + /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + </> + )} + + <Menu.Group> + {(!hideInPWI || hasSession) && ( + <> + <Menu.Item + testID="postDropdownTranslateBtn" + label={_(msg`Translate`)} + onPress={onPressTranslate}> + <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> + <Menu.ItemIcon icon={Translate} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownCopyTextBtn" + label={_(msg`Copy post text`)} + onPress={onCopyPostText}> + <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </> + )} + + {hasSession && ( + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => sendViaChatControl.open()}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Send} position="right" /> + </Menu.Item> + )} + + <Menu.Item + testID="postDropdownShareBtn" + label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + onPress={() => { + if (showLoggedOutWarning) { + loggedOutWarningPromptControl.open() + } else { + onSharePost() + } + }}> + <Menu.ItemText> + {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Share} position="right" /> + </Menu.Item> + + {canEmbed && ( + <Menu.Item + testID="postDropdownEmbedBtn" + label={_(msg`Embed post`)} + onPress={() => embedPostControl.open()}> + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> + <Menu.ItemIcon icon={CodeBrackets} position="right" /> + </Menu.Item> + )} + </Menu.Group> + + {hasSession && feedFeedback.enabled && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postDropdownShowMoreBtn" + label={_(msg`Show more like this`)} + onPress={onPressShowMore}> + <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> + <Menu.ItemIcon icon={EmojiSmile} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownShowLessBtn" + label={_(msg`Show less like this`)} + onPress={onPressShowLess}> + <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> + <Menu.ItemIcon icon={EmojiSad} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postDropdownMuteThreadBtn" + label={ + isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) + } + onPress={onToggleThreadMute}> + <Menu.ItemText> + {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isThreadMuted ? Unmute : Mute} + position="right" + /> + </Menu.Item> + + <Menu.Item + testID="postDropdownMuteWordsBtn" + label={_(msg`Mute words & tags`)} + onPress={() => mutedWordsDialogControl.open()}> + <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> + <Menu.ItemIcon icon={Filter} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + + {hasSession && + (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( + <> + <Menu.Divider /> + <Menu.Group> + {canHidePostForMe && ( + <Menu.Item + testID="postDropdownHideBtn" + label={ + isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`) + } + onPress={() => hidePromptControl.open()}> + <Menu.ItemText> + {isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`)} + </Menu.ItemText> + <Menu.ItemIcon icon={EyeSlash} position="right" /> + </Menu.Item> + )} + {canHideReplyForEveryone && ( + <Menu.Item + testID="postDropdownHideBtn" + label={ + isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`) + } + onPress={ + isReplyHiddenByThreadgate + ? onToggleReplyVisibility + : () => hideReplyConfirmControl.open() + }> + <Menu.ItemText> + {isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} + position="right" + /> + </Menu.Item> + )} + + {canDetachQuote && ( + <Menu.Item + disabled={isDetachPending} + testID="postDropdownHideBtn" + label={ + quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`) + } + onPress={ + quoteEmbed.isDetached + ? onToggleQuotePostAttachment + : () => quotePostDetachConfirmControl.open() + }> + <Menu.ItemText> + {quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={ + isDetachPending + ? Loader + : quoteEmbed.isDetached + ? Eye + : EyeSlash + } + position="right" + /> + </Menu.Item> + )} + </Menu.Group> + </> + )} + + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + {!isAuthor && ( + <> + {!postAuthor.viewer?.blocking && ( + <Menu.Item + testID="postDropdownBlockBtn" + label={_(msg`Block account`)} + onPress={() => blockPromptControl.open()}> + <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> + <Menu.ItemIcon icon={PersonX} position="right" /> + </Menu.Item> + )} + <Menu.Item + testID="postDropdownReportBtn" + label={_(msg`Report post`)} + onPress={() => reportDialogControl.open()}> + <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Warning} position="right" /> + </Menu.Item> + </> + )} + + {isAuthor && ( + <> + <Menu.Item + testID="postDropdownEditPostInteractions" + label={_(msg`Edit interaction settings`)} + onPress={() => postInteractionSettingsDialogControl.open()} + {...(isAuthor + ? Platform.select({ + web: { + onHoverIn: prefetchPostInteractionSettings, + }, + native: { + onPressIn: prefetchPostInteractionSettings, + }, + }) + : {})}> + <Menu.ItemText> + {_(msg`Edit interaction settings`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Gear} position="right" /> + </Menu.Item> + <Menu.Item + testID="postDropdownDeleteBtn" + label={_(msg`Delete post`)} + onPress={() => deletePromptControl.open()}> + <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + </> + )} + </Menu.Group> + </> + )} + </Menu.Outer> + + <Prompt.Basic + control={deletePromptControl} + title={_(msg`Delete this post?`)} + description={_( + msg`If you remove this post, you won't be able to recover it.`, + )} + onConfirm={onDeletePost} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> + + <Prompt.Basic + control={hidePromptControl} + title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} + description={_( + msg`This post will be hidden from feeds and threads. This cannot be undone.`, + )} + onConfirm={onHidePost} + confirmButtonCta={_(msg`Hide`)} + /> + + <ReportDialog + control={reportDialogControl} + params={{ + type: 'post', + uri: postUri, + cid: postCid, + }} + /> + + <Prompt.Basic + control={loggedOutWarningPromptControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`, + )} + onConfirm={onSharePost} + confirmButtonCta={_(msg`Share anyway`)} + /> + + {canEmbed && ( + <EmbedDialog + control={embedPostControl} + postCid={postCid} + postUri={postUri} + record={record} + postAuthor={postAuthor} + timestamp={timestamp} + /> + )} + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + + <PostInteractionSettingsDialog + control={postInteractionSettingsDialogControl} + postUri={post.uri} + rootPostUri={rootUri} + initialThreadgateView={post.threadgate} + /> + + <Prompt.Basic + control={quotePostDetachConfirmControl} + title={_(msg`Detach quote post?`)} + description={_( + msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, + )} + onConfirm={onToggleQuotePostAttachment} + confirmButtonCta={_(msg`Yes, detach`)} + /> + + <Prompt.Basic + control={hideReplyConfirmControl} + title={_(msg`Hide this reply?`)} + description={_( + msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, + )} + onConfirm={onToggleReplyVisibility} + confirmButtonCta={_(msg`Yes, hide`)} + /> + + <Prompt.Basic + control={blockPromptControl} + title={_(msg`Block Account?`)} + description={_( + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + )} + onConfirm={onBlockAuthor} + confirmButtonCta={_(msg`Block`)} + confirmButtonColor="negative" + /> + </> + ) +} +PostDropdownMenuItems = memo(PostDropdownMenuItems) +export {PostDropdownMenuItems} diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx index e2bf3c9ac..7cf0f2d73 100644 --- a/src/view/com/util/forms/RadioButton.tsx +++ b/src/view/com/util/forms/RadioButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {choose} from '#/lib/functions' diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx index c6cf63930..e2a26dc49 100644 --- a/src/view/com/util/forms/RadioGroup.tsx +++ b/src/view/com/util/forms/RadioGroup.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState} from 'react' import {View} from 'react-native' import {s} from '#/lib/styles' diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx index 1d74b935a..76161b433 100644 --- a/src/view/com/util/forms/SelectableBtn.tsx +++ b/src/view/com/util/forms/SelectableBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native' import {usePalette} from '#/lib/hooks/usePalette' diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 706796fc4..31222aafe 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {choose} from '#/lib/functions' diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index fe8911e31..617b9bec4 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,11 +1,11 @@ -import React from 'react' +import React, {useRef} from 'react' import {DimensionValue, Pressable, View} from 'react-native' -import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' import type {Dimensions} from '#/lib/media/types' import {isNative} from '#/platform/detection' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' @@ -68,26 +68,27 @@ export function AutoSizedImage({ image: AppBskyEmbedImages.ViewImage crop?: 'none' | 'square' | 'constrained' hideBadge?: boolean - onPress?: ( - containerRef: AnimatedRef<React.Component<{}, {}, any>>, - fetchedDims: Dimensions | null, - ) => void + onPress?: (containerRef: HandleRef, fetchedDims: Dimensions | null) => void onLongPress?: () => void onPressIn?: () => void }) { const t = useTheme() const {_} = useLingui() const largeAlt = useLargeAltBadgeEnabled() - const containerRef = useAnimatedRef() + const containerRef = useHandleRef() + const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) - const [fetchedDims, setFetchedDims] = React.useState<Dimensions | null>(null) - const dims = fetchedDims ?? image.aspectRatio let aspectRatio: number | undefined + const dims = image.aspectRatio if (dims) { aspectRatio = dims.width / dims.height if (Number.isNaN(aspectRatio)) { aspectRatio = undefined } + } else { + // If we don't know it synchronously, treat it like a square. + // We won't use fetched dimensions to avoid a layout shift. + aspectRatio = 1 } let constrained: number | undefined @@ -105,7 +106,7 @@ export function AutoSizedImage({ const hasAlt = !!image.alt const contents = ( - <Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}> + <View ref={containerRef} collapsable={false} style={{flex: 1}}> <Image style={[a.w_full, a.h_full]} source={image.thumb} @@ -113,13 +114,12 @@ export function AutoSizedImage({ accessibilityIgnoresInvertColors accessibilityLabel={image.alt} accessibilityHint="" - onLoad={ - fetchedDims - ? undefined - : e => { - setFetchedDims({width: e.source.width, height: e.source.height}) - } - } + onLoad={e => { + fetchedDimsRef.current = { + width: e.source.width, + height: e.source.height, + } + }} /> <MediaInsetBorder /> @@ -185,13 +185,13 @@ export function AutoSizedImage({ )} </View> ) : null} - </Animated.View> + </View> ) if (cropDisabled) { return ( <Pressable - onPress={() => onPress?.(containerRef, fetchedDims)} + onPress={() => onPress?.(containerRef, fetchedDimsRef.current)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use @@ -213,7 +213,7 @@ export function AutoSizedImage({ fullBleed={crop === 'square'} aspectRatio={constrained ?? 1}> <Pressable - onPress={() => onPress?.(containerRef, fetchedDims)} + onPress={() => onPress?.(containerRef, fetchedDimsRef.current)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 9d0817bd2..cc3eda68d 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,11 +1,11 @@ import React from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {AnimatedRef} from 'react-native-reanimated' import {Image, ImageStyle} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HandleRef} from '#/lib/hooks/useHandleRef' import {Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' @@ -20,7 +20,7 @@ interface Props { index: number onPress?: ( index: number, - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], + containerRefs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => void onLongPress?: EventFunction @@ -28,7 +28,7 @@ interface Props { imageStyle?: StyleProp<ImageStyle> viewContext?: PostEmbedViewContext insetBorderStyle?: StyleProp<ViewStyle> - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[] + containerRefs: HandleRef[] thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]> } @@ -52,10 +52,7 @@ export function GalleryItem({ const hideBadges = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia return ( - <Animated.View - style={a.flex_1} - ref={containerRefs[index]} - collapsable={false}> + <View style={a.flex_1} ref={containerRefs[index]} collapsable={false}> <Pressable onPress={ onPress @@ -118,6 +115,6 @@ export function GalleryItem({ </Text> </View> ) : null} - </Animated.View> + </View> ) } diff --git a/src/view/com/util/images/Image.tsx b/src/view/com/util/images/Image.tsx index e779fa378..94563ef9c 100644 --- a/src/view/com/util/images/Image.tsx +++ b/src/view/com/util/images/Image.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {Image, ImageProps, ImageSource} from 'expo-image' interface HighPriorityImageProps extends ImageProps { diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index dcc330dac..16ea9d453 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,8 +1,8 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {AppBskyEmbedImages} from '@atproto/api' +import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useBreakpoints} from '#/alf' import {Dimensions} from '../../lightbox/ImageViewing/@types' @@ -12,7 +12,7 @@ interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], + containerRefs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void @@ -43,7 +43,7 @@ interface ImageLayoutGridInnerProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRefs: AnimatedRef<React.Component<{}, {}, any>>[], + containerRefs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void @@ -56,10 +56,10 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const gap = props.gap const count = props.images.length - const containerRef1 = useAnimatedRef() - const containerRef2 = useAnimatedRef() - const containerRef3 = useAnimatedRef() - const containerRef4 = useAnimatedRef() + const containerRef1 = useHandleRef() + const containerRef2 = useHandleRef() + const containerRef3 = useHandleRef() + const containerRef4 = useHandleRef() const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) switch (count) { diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index 2310b1f27..d98aa0fa7 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' diff --git a/src/view/com/util/numeric/__tests__/format-test.ts b/src/view/com/util/numeric/__tests__/format-test.ts new file mode 100644 index 000000000..74df4be4c --- /dev/null +++ b/src/view/com/util/numeric/__tests__/format-test.ts @@ -0,0 +1,92 @@ +import {describe, expect, it} from '@jest/globals' + +import {APP_LANGUAGES} from '#/locale/languages' +import {formatCount} from '../format' + +const formatCountRound = (locale: string, num: number) => { + const options: Intl.NumberFormatOptions = { + notation: 'compact', + maximumFractionDigits: 1, + } + return new Intl.NumberFormat(locale, options).format(num) +} + +const formatCountTrunc = (locale: string, num: number) => { + const options: Intl.NumberFormatOptions = { + notation: 'compact', + maximumFractionDigits: 1, + // @ts-ignore + roundingMode: 'trunc', + } + return new Intl.NumberFormat(locale, options).format(num) +} + +// prettier-ignore +const testNums = [ + 1, + 5, + 9, + 11, + 55, + 99, + 111, + 555, + 999, + 1111, + 5555, + 9999, + 11111, + 55555, + 99999, + 111111, + 555555, + 999999, + 1111111, + 5555555, + 9999999, + 11111111, + 55555555, + 99999999, + 111111111, + 555555555, + 999999999, + 1111111111, + 5555555555, + 9999999999, + 11111111111, + 55555555555, + 99999999999, + 111111111111, + 555555555555, + 999999999999, + 1111111111111, + 5555555555555, + 9999999999999, + 11111111111111, + 55555555555555, + 99999999999999, + 111111111111111, + 555555555555555, + 999999999999999, + 1111111111111111, + 5555555555555555, +] + +describe('formatCount', () => { + for (const appLanguage of APP_LANGUAGES) { + const locale = appLanguage.code2 + it('truncates for ' + locale, () => { + const mockI8nn = { + locale, + number(num: number) { + return formatCountRound(locale, num) + }, + } + for (const num of testNums) { + const formatManual = formatCount(mockI8nn as any, num) + const formatOriginal = formatCountTrunc(locale, num) + expect(formatManual).toEqual(formatOriginal) + } + }) + } +}) diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts index cca9fc7e7..0c3d24957 100644 --- a/src/view/com/util/numeric/format.ts +++ b/src/view/com/util/numeric/format.ts @@ -1,12 +1,47 @@ -import type {I18n} from '@lingui/core' +import {I18n} from '@lingui/core' + +const truncateRounding = (num: number, factors: Array<number>): number => { + for (let i = factors.length - 1; i >= 0; i--) { + let factor = factors[i] + if (num >= 10 ** factor) { + if (factor === 10) { + // CA and ES abruptly jump from "9999,9 M" to "10 mil M" + factor-- + } + const precision = 1 + const divisor = 10 ** (factor - precision) + return Math.floor(num / divisor) * divisor + } + } + return num +} + +const koFactors = [3, 4, 8, 12] +const hiFactors = [3, 5, 7, 9, 11, 13] +const esCaFactors = [3, 6, 10, 12] +const itDeFactors = [6, 9, 12] +const jaZhFactors = [4, 8, 12] +const restFactors = [3, 6, 9, 12] export const formatCount = (i18n: I18n, num: number) => { - return i18n.number(num, { + const locale = i18n.locale + let truncatedNum: number + if (locale === 'hi') { + truncatedNum = truncateRounding(num, hiFactors) + } else if (locale === 'ko') { + truncatedNum = truncateRounding(num, koFactors) + } else if (locale === 'es' || locale === 'ca') { + truncatedNum = truncateRounding(num, esCaFactors) + } else if (locale === 'ja' || locale === 'zh-CN' || locale === 'zh-TW') { + truncatedNum = truncateRounding(num, jaZhFactors) + } else if (locale === 'it' || locale === 'de') { + truncatedNum = truncateRounding(num, itDeFactors) + } else { + truncatedNum = truncateRounding(num, restFactors) + } + return i18n.number(truncatedNum, { notation: 'compact', maximumFractionDigits: 1, - // `1,953` shouldn't be rounded up to 2k, it should be truncated. - // @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode - roundingMode: 'trunc', + // Ideally we'd use roundingMode: 'trunc' but it isn't supported on RN. }) } diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 28889429f..06b1fcaf6 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -1,6 +1,6 @@ import React, {memo, useCallback} from 'react' import {View} from 'react-native' -import {msg, plural} from '@lingui/macro' +import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {POST_CTRL_HITSLOP} from '#/lib/constants' @@ -36,16 +36,12 @@ let RepostButton = ({ const requireAuth = useRequireAuth() const dialogControl = Dialog.useDialogControl() const playHaptic = useHaptics() - const color = React.useMemo( () => ({ color: isReposted ? t.palette.positive_600 : t.palette.contrast_500, }), [t, isReposted], ) - - const close = useCallback(() => dialogControl.close(), [dialogControl]) - return ( <> <Button @@ -92,84 +88,124 @@ let RepostButton = ({ control={dialogControl} nativeOptions={{preventExpansion: true}}> <Dialog.Handle /> - <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}> - <View style={a.gap_xl}> - <View style={a.gap_xs}> - <Button - style={[a.justify_start, a.px_md]} - label={ - isReposted - ? _(msg`Remove repost`) - : _(msg({message: `Repost`, context: 'action'})) - } - onPress={() => { - if (!isReposted) playHaptic() - - dialogControl.close(() => { - onRepost() - }) - }} - size="large" - variant="ghost" - color="primary"> - <Repost size="lg" fill={t.palette.primary_500} /> - <Text style={[a.font_bold, a.text_xl]}> - {isReposted - ? _(msg`Remove repost`) - : _(msg({message: `Repost`, context: 'action'}))} - </Text> - </Button> - <Button - disabled={embeddingDisabled} - testID="quoteBtn" - style={[a.justify_start, a.px_md]} - label={ - embeddingDisabled - ? _(msg`Quote posts disabled`) - : _(msg`Quote post`) - } - onPress={() => { - playHaptic() - dialogControl.close(() => { - onQuote() - }) - }} - size="large" - variant="ghost" - color="primary"> - <Quote - size="lg" - fill={ - embeddingDisabled - ? t.atoms.text_contrast_low.color - : t.palette.primary_500 - } - /> - <Text - style={[ - a.font_bold, - a.text_xl, - embeddingDisabled && t.atoms.text_contrast_low, - ]}> - {embeddingDisabled - ? _(msg`Quote posts disabled`) - : _(msg`Quote post`)} - </Text> - </Button> - </View> - <Button - label={_(msg`Cancel quote post`)} - onPress={close} - size="large" - variant="solid" - color="primary"> - <ButtonText>{_(msg`Cancel`)}</ButtonText> - </Button> - </View> - </Dialog.ScrollableInner> + <RepostButtonDialogInner + isReposted={isReposted} + onRepost={onRepost} + onQuote={onQuote} + embeddingDisabled={embeddingDisabled} + /> </Dialog.Outer> </> ) } RepostButton = memo(RepostButton) export {RepostButton} + +let RepostButtonDialogInner = ({ + isReposted, + onRepost, + onQuote, + embeddingDisabled, +}: { + isReposted: boolean + onRepost: () => void + onQuote: () => void + embeddingDisabled: boolean +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const playHaptic = useHaptics() + const control = Dialog.useDialogContext() + + const onPressRepost = useCallback(() => { + if (!isReposted) playHaptic() + + control.close(() => { + onRepost() + }) + }, [control, isReposted, onRepost, playHaptic]) + + const onPressQuote = useCallback(() => { + playHaptic() + control.close(() => { + onQuote() + }) + }, [control, onQuote, playHaptic]) + + const onPressClose = useCallback(() => control.close(), [control]) + + return ( + <Dialog.ScrollableInner label={_(msg`Repost or quote post`)}> + <View style={a.gap_xl}> + <View style={a.gap_xs}> + <Button + style={[a.justify_start, a.px_md]} + label={ + isReposted + ? _(msg`Remove repost`) + : _(msg({message: `Repost`, context: 'action'})) + } + onPress={onPressRepost} + size="large" + variant="ghost" + color="primary"> + <Repost size="lg" fill={t.palette.primary_500} /> + <Text style={[a.font_bold, a.text_xl]}> + {isReposted ? ( + <Trans>Remove repost</Trans> + ) : ( + <Trans context="action">Repost</Trans> + )} + </Text> + </Button> + <Button + disabled={embeddingDisabled} + testID="quoteBtn" + style={[a.justify_start, a.px_md]} + label={ + embeddingDisabled + ? _(msg`Quote posts disabled`) + : _(msg`Quote post`) + } + onPress={onPressQuote} + size="large" + variant="ghost" + color="primary"> + <Quote + size="lg" + fill={ + embeddingDisabled + ? t.atoms.text_contrast_low.color + : t.palette.primary_500 + } + /> + <Text + style={[ + a.font_bold, + a.text_xl, + embeddingDisabled && t.atoms.text_contrast_low, + ]}> + {embeddingDisabled ? ( + <Trans>Quote posts disabled</Trans> + ) : ( + <Trans>Quote post</Trans> + )} + </Text> + </Button> + </View> + <Button + label={_(msg`Cancel quote post`)} + onPress={onPressClose} + size="large" + variant="outline" + color="primary"> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + </View> + </Dialog.ScrollableInner> + ) +} +RepostButtonDialogInner = memo(RepostButtonDialogInner) +export {RepostButtonDialogInner} diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 111b41dd7..54119b532 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -104,9 +104,7 @@ export const RepostButton = ({ label={_(msg`Repost or quote post`)} style={{padding: 0}} hoverStyle={t.atoms.bg_contrast_25} - shape="round" - variant="ghost" - color="secondary"> + shape="round"> <RepostInner isReposted={isReposted} color={color} diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx index 6db4d6fef..39c1d109e 100644 --- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -1,16 +1,11 @@ import React from 'react' -import { - ActivityIndicator, - GestureResponderEvent, - LayoutChangeEvent, - Pressable, -} from 'react-native' -import {Image, ImageLoadEventData} from 'expo-image' +import {ActivityIndicator, GestureResponderEvent, Pressable} from 'react-native' +import {Image} from 'expo-image' import {AppBskyEmbedExternal} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {EmbedPlayerParams, getGifDims} from '#/lib/strings/embed-player' +import {EmbedPlayerParams} from '#/lib/strings/embed-player' import {isIOS, isNative, isWeb} from '#/platform/detection' import {useExternalEmbedsPrefs} from '#/state/preferences' import {atoms as a, useTheme} from '#/alf' @@ -28,20 +23,15 @@ export function ExternalGifEmbed({ }) { const t = useTheme() const externalEmbedsPrefs = useExternalEmbedsPrefs() - const {_} = useLingui() const consentDialogControl = useDialogControl() - const thumbHasLoaded = React.useRef(false) - const viewWidth = React.useRef(0) - // Tracking if the placer has been activated const [isPlayerActive, setIsPlayerActive] = React.useState(false) // Tracking whether the gif has been loaded yet const [isPrefetched, setIsPrefetched] = React.useState(false) // Tracking whether the image is animating const [isAnimating, setIsAnimating] = React.useState(true) - const [imageDims, setImageDims] = React.useState({height: 100, width: 1}) // Used for controlling animation const imageRef = React.useRef<Image>(null) @@ -93,16 +83,6 @@ export function ExternalGifEmbed({ ], ) - const onLoad = React.useCallback((e: ImageLoadEventData) => { - if (thumbHasLoaded.current) return - setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current)) - thumbHasLoaded.current = true - }, []) - - const onLayout = React.useCallback((e: LayoutChangeEvent) => { - viewWidth.current = e.nativeEvent.layout.width - }, []) - return ( <> <EmbedConsentDialog @@ -113,7 +93,7 @@ export function ExternalGifEmbed({ <Pressable style={[ - {height: imageDims.height}, + {height: 300}, a.w_full, a.overflow_hidden, { @@ -122,7 +102,6 @@ export function ExternalGifEmbed({ }, ]} onPress={onPlayPress} - onLayout={onLayout} accessibilityRole="button" accessibilityHint={_(msg`Plays the GIF`)} accessibilityLabel={_(msg`Play ${link.title}`)}> @@ -135,7 +114,6 @@ export function ExternalGifEmbed({ }} // Web uses the thumb to control playback style={{flex: 1}} ref={imageRef} - onLoad={onLoad} autoplay={isAnimating} contentFit="contain" accessibilityIgnoresInvertColors diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 24802d188..f268bf8db 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useState} from 'react' -import {View} from 'react-native' +import {ActivityIndicator, View} from 'react-native' import {ImageBackground} from 'expo-image' import {AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -10,7 +10,6 @@ import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner import {atoms as a} from '#/alf' import {Button} from '#/components/Button' import {useThrottledValue} from '#/components/hooks/useThrottledValue' -import {Loader} from '#/components/Loader' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {ErrorBoundary} from '../ErrorBoundary' import * as VideoFallback from './VideoEmbedInner/VideoFallback' @@ -89,12 +88,9 @@ function InnerWrapper({embed}: Props) { source={{uri: embed.thumbnail}} accessibilityIgnoresInvertColors style={[ + a.absolute, + a.inset_0, { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here, // the play button won't show up on the first render on android 🥴😮💨 display: showOverlay ? 'flex' : 'none', @@ -102,27 +98,29 @@ function InnerWrapper({embed}: Props) { ]} cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android > - <Button - style={[a.flex_1, a.align_center, a.justify_center]} - onPress={() => { - ref.current?.togglePlayback() - }} - label={_(msg`Play video`)} - color="secondary"> - {showSpinner ? ( - <View - style={[ - a.rounded_full, - a.p_xs, - a.align_center, - a.justify_center, - ]}> - <Loader size="2xl" style={{color: 'white'}} /> - </View> - ) : ( - <PlayButtonIcon /> - )} - </Button> + {showOverlay && ( + <Button + style={[a.flex_1, a.align_center, a.justify_center]} + onPress={() => { + ref.current?.togglePlayback() + }} + label={_(msg`Play video`)} + color="secondary"> + {showSpinner ? ( + <View + style={[ + a.rounded_full, + a.p_xs, + a.align_center, + a.justify_center, + ]}> + <ActivityIndicator size="large" color="white" /> + </View> + ) : ( + <PlayButtonIcon /> + )} + </Button> + )} </ImageBackground> </> ) diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index 3180dd99e..a1f4652ac 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -24,6 +24,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { useActiveVideoWeb() const [onScreen, setOnScreen] = useState(false) const [isFullscreen] = useFullscreen() + const lastKnownTime = useRef<number | undefined>() useEffect(() => { if (!ref.current) return @@ -82,6 +83,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { active={active} setActive={setActive} onScreen={onScreen} + lastKnownTime={lastKnownTime} /> </ViewportObserver> </ErrorBoundary> diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx index 66e1df50d..75e544aca 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx @@ -1,8 +1,9 @@ -import React from 'react' import {StyleProp, ViewStyle} from 'react-native' -import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {atoms as a, native, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' /** @@ -17,6 +18,7 @@ export function TimeIndicator({ style?: StyleProp<ViewStyle> }) { const t = useTheme() + const {_} = useLingui() if (isNaN(time)) { return null @@ -26,10 +28,10 @@ export function TimeIndicator({ const seconds = String(time % 60).padStart(2, '0') return ( - <Animated.View - entering={native(FadeInDown.duration(300))} - exiting={native(FadeOutDown.duration(500))} + <View pointerEvents="none" + accessibilityLabel={_(msg`Time remaining: ${time} seconds`)} + accessibilityHint="" style={[ { backgroundColor: 'rgba(0, 0, 0, 0.5)', @@ -52,6 +54,6 @@ export function TimeIndicator({ ]}> {`${minutes}:${seconds}`} </Text> - </Animated.View> + </View> ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 21db54322..215e4c406 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,6 +1,5 @@ import React, {useRef} from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {FadeInDown} from 'react-native-reanimated' import {AppBskyEmbedVideo} from '@atproto/api' import {BlueskyVideoView} from '@haileyok/bluesky-video' import {msg} from '@lingui/macro' @@ -182,8 +181,7 @@ function ControlButton({ style?: StyleProp<ViewStyle> }) { return ( - <Animated.View - entering={FadeInDown.duration(300)} + <View style={[ a.absolute, a.rounded_full, @@ -207,6 +205,6 @@ function ControlButton({ hitSlop={HITSLOP_30}> {children} </Pressable> - </Animated.View> + </View> ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index ef989c4a4..e6882a2f6 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,6 +1,8 @@ import React, {useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' import {AppBskyEmbedVideo} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import type * as HlsTypes from 'hls.js' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -13,11 +15,13 @@ export function VideoEmbedInnerWeb({ active, setActive, onScreen, + lastKnownTime, }: { embed: AppBskyEmbedVideo.View active: boolean setActive: () => void onScreen: boolean + lastKnownTime: React.MutableRefObject<number | undefined> }) { const containerRef = useRef<HTMLDivElement>(null) const videoRef = useRef<HTMLVideoElement>(null) @@ -25,6 +29,7 @@ export function VideoEmbedInnerWeb({ const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const [hlsLoading, setHlsLoading] = React.useState(false) const figId = useId() + const {_} = useLingui() // send error up to error boundary const [error, setError] = useState<Error | null>(null) @@ -40,8 +45,17 @@ export function VideoEmbedInnerWeb({ setHlsLoading, }) + useEffect(() => { + if (lastKnownTime.current && videoRef.current) { + videoRef.current.currentTime = lastKnownTime.current + } + }, [lastKnownTime]) + return ( - <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}> + <View + style={[a.flex_1, a.rounded_md, a.overflow_hidden]} + accessibilityLabel={_(msg`Embedded video player`)} + accessibilityHint=""> <div ref={containerRef} style={{height: '100%', width: '100%'}}> <figure style={{margin: 0, position: 'absolute', inset: 0}}> <video @@ -52,6 +66,9 @@ export function VideoEmbedInnerWeb({ preload="none" muted={!focused} aria-labelledby={embed.alt ? figId : undefined} + onTimeUpdate={e => { + lastKnownTime.current = e.currentTarget.currentTime + }} /> {embed.alt && ( <figcaption diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx index 8ffe482a8..651046445 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx @@ -23,7 +23,8 @@ export function ControlButton({ return ( <PressableWithHover accessibilityRole="button" - accessibilityHint={active ? activeLabel : inactiveLabel} + accessibilityLabel={active ? activeLabel : inactiveLabel} + accessibilityHint="" onPress={onPress} style={[ a.p_xs, @@ -32,9 +33,9 @@ export function ControlButton({ ]} hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}> {active ? ( - <ActiveIcon fill={t.palette.white} width={20} /> + <ActiveIcon fill={t.palette.white} width={20} aria-hidden /> ) : ( - <InactiveIcon fill={t.palette.white} width={20} /> + <InactiveIcon fill={t.palette.white} width={20} aria-hidden /> )} </PressableWithHover> ) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx index 44978ad51..74aad64e1 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx @@ -186,7 +186,9 @@ export function Scrubber({ </View> <div ref={circleRef} - aria-label={_(msg`Seek slider`)} + aria-label={_( + msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`, + )} role="slider" aria-valuemax={duration} aria-valuemin={0} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx index acd4d1aae..8e134d221 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx @@ -313,13 +313,14 @@ export function Controls({ onPointerEnter={onPointerMoveEmptySpace} onPointerMove={onPointerMoveEmptySpace} onPointerLeave={onPointerLeaveEmptySpace} - accessibilityHint={_( + accessibilityLabel={_( !focused ? msg`Unmute video` : playing ? msg`Pause video` : msg`Play video`, )} + accessibilityHint="" style={[ a.flex_1, web({cursor: showCursor || !playing ? 'pointer' : 'none'}), @@ -401,7 +402,7 @@ export function Controls({ <ControlButton active={isFullscreen} activeLabel={_(msg`Exit fullscreen`)} - inactiveLabel={_(msg`Fullscreen`)} + inactiveLabel={_(msg`Enter fullscreen`)} activeIcon={ArrowsInIcon} inactiveIcon={ArrowsOutIcon} onPress={onPressFullscreen} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx index 63ac32b10..90ffb9e6b 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -77,6 +77,7 @@ export function VolumeControl({ min={0} max={100} value={sliderVolume} + aria-label={_(msg`Volume`)} style={ // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'} diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 1351a2cbc..9dc43da8e 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -6,13 +6,7 @@ import { View, ViewStyle, } from 'react-native' -import { - AnimatedRef, - measure, - MeasuredDimensions, - runOnJS, - runOnUI, -} from 'react-native-reanimated' +import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' import {Image} from 'expo-image' import { AppBskyEmbedExternal, @@ -27,6 +21,7 @@ import { ModerationDecision, } from '@atproto/api' +import {HandleRef, measureHandle} from '#/lib/hooks/useHandleRef' import {usePalette} from '#/lib/hooks/usePalette' import {useLightboxControls} from '#/state/lightbox' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -163,12 +158,13 @@ export function PostEmbeds({ } const onPress = ( index: number, - refs: AnimatedRef<React.Component<{}, {}, any>>[], + refs: HandleRef[], fetchedDims: (Dimensions | null)[], ) => { + const handles = refs.map(r => r.current) runOnUI(() => { 'worklet' - const rects = refs.map(ref => (ref ? measure(ref) : null)) + const rects = handles.map(measureHandle) runOnJS(_openLightbox)(index, rects, fetchedDims) })() } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx deleted file mode 100644 index a4cf517a4..000000000 --- a/src/view/com/util/text/RichText.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react' -import {StyleProp, TextStyle} from 'react-native' -import {AppBskyRichtextFacet, RichText as RichTextObj} from '@atproto/api' - -import {usePalette} from '#/lib/hooks/usePalette' -import {makeTagLink} from '#/lib/routes/links' -import {toShortUrl} from '#/lib/strings/url-helpers' -import {lh} from '#/lib/styles' -import {TypographyVariant, useTheme} from '#/lib/ThemeContext' -import {isNative} from '#/platform/detection' -import {TagMenu, useTagMenuControl} from '#/components/TagMenu' -import {TextLink} from '../Link' -import {Text} from './Text' - -const WORD_WRAP = {wordWrap: 1} - -/** - * @deprecated use `#/components/RichText` - */ -export function RichText({ - testID, - type = 'md', - richText, - lineHeight = 1.2, - style, - numberOfLines, - selectable, - noLinks, -}: { - testID?: string - type?: TypographyVariant - richText?: RichTextObj - lineHeight?: number - style?: StyleProp<TextStyle> - numberOfLines?: number - selectable?: boolean - noLinks?: boolean -}) { - const theme = useTheme() - const pal = usePalette('default') - const lineHeightStyle = lh(theme, type, lineHeight) - - if (!richText) { - return null - } - - const {text, facets} = richText - if (!facets?.length) { - if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) { - style = { - fontSize: 26, - lineHeight: 30, - } - return ( - // @ts-ignore web only -prf - <Text - testID={testID} - style={[style, pal.text]} - dataSet={WORD_WRAP} - selectable={selectable}> - {text} - </Text> - ) - } - return ( - <Text - testID={testID} - type={type} - style={[style, pal.text, lineHeightStyle]} - numberOfLines={numberOfLines} - // @ts-ignore web only -prf - dataSet={WORD_WRAP} - selectable={selectable}> - {text} - </Text> - ) - } - if (!style) { - style = [] - } else if (!Array.isArray(style)) { - style = [style] - } - - const els = [] - let key = 0 - for (const segment of richText.segments()) { - const link = segment.link - const mention = segment.mention - const tag = segment.tag - if ( - !noLinks && - mention && - AppBskyRichtextFacet.validateMention(mention).success - ) { - els.push( - <TextLink - key={key} - type={type} - text={segment.text} - href={`/profile/${mention.did}`} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - selectable={selectable} - />, - ) - } else if (link && AppBskyRichtextFacet.validateLink(link).success) { - if (noLinks) { - els.push(toShortUrl(segment.text)) - } else { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={link.uri} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - selectable={selectable} - />, - ) - } - } else if ( - !noLinks && - tag && - AppBskyRichtextFacet.validateTag(tag).success - ) { - els.push( - <RichTextTag - key={key} - text={segment.text} - type={type} - style={style} - lineHeightStyle={lineHeightStyle} - selectable={selectable} - />, - ) - } else { - els.push(segment.text) - } - key++ - } - return ( - <Text - testID={testID} - type={type} - style={[style, pal.text, lineHeightStyle]} - numberOfLines={numberOfLines} - // @ts-ignore web only -prf - dataSet={WORD_WRAP} - selectable={selectable}> - {els} - </Text> - ) -} - -function RichTextTag({ - text: tag, - type, - style, - lineHeightStyle, - selectable, -}: { - text: string - type?: TypographyVariant - style?: StyleProp<TextStyle> - lineHeightStyle?: TextStyle - selectable?: boolean -}) { - const pal = usePalette('default') - const control = useTagMenuControl() - - const open = React.useCallback(() => { - control.open() - }, [control]) - - return ( - <React.Fragment> - <TagMenu control={control} tag={tag}> - {isNative ? ( - <TextLink - type={type} - text={tag} - // segment.text has the leading "#" while tag.tag does not - href={makeTagLink(tag)} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - selectable={selectable} - onPress={open} - /> - ) : ( - <Text - selectable={selectable} - type={type} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}> - {tag} - </Text> - )} - </TagMenu> - </React.Fragment> - ) -} diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index dbf5e2e13..f05274f44 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleSheet, Text as RNText, TextProps} from 'react-native' +import {StyleSheet, TextProps} from 'react-native' import {UITextView} from 'react-native-uitextview' import {lh, s} from '#/lib/styles' @@ -9,10 +9,9 @@ import {isIOS, isWeb} from '#/platform/detection' import {applyFonts, useAlf} from '#/alf' import { childHasEmoji, - childIsString, renderChildrenWithEmoji, StringChild, -} from '#/components/Typography' +} from '#/alf/typography' import {IS_DEV} from '#/env' export type CustomTextProps = Omit<TextProps, 'children'> & { @@ -32,7 +31,11 @@ export type CustomTextProps = Omit<TextProps, 'children'> & { } ) -export function Text({ +export {Text_DEPRECATED as Text} +/** + * @deprecated use Text from Typography instead. + */ +function Text_DEPRECATED({ type = 'md', children, emoji, @@ -52,10 +55,6 @@ export function Text({ `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`, ) } - - if (emoji && !childIsString(children)) { - logger.error('Text: when <Text emoji />, children can only be strings.') - } } const textProps = React.useMemo(() => { @@ -103,19 +102,9 @@ export function Text({ type, ]) - if (selectable && isIOS) { - return ( - <UITextView {...textProps}> - {isIOS && emoji - ? renderChildrenWithEmoji(children, textProps) - : children} - </UITextView> - ) - } - return ( - <RNText {...textProps}> - {isIOS && emoji ? renderChildrenWithEmoji(children, textProps) : children} - </RNText> + <UITextView {...textProps}> + {renderChildrenWithEmoji(children, textProps, emoji ?? false)} + </UITextView> ) } |