diff options
Diffstat (limited to 'src/view/com/util')
-rw-r--r-- | src/view/com/util/List.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/List.web.tsx | 201 | ||||
-rw-r--r-- | src/view/com/util/MainScrollProvider.tsx | 59 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 65 | ||||
-rw-r--r-- | src/view/com/util/TimeElapsed.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/Views.jsx | 10 | ||||
-rw-r--r-- | src/view/com/util/forms/NativeDropdown.web.tsx | 150 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/images/Gallery.tsx | 5 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/ExternalLinkEmbed.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/GifEmbed.tsx | 75 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 2 |
12 files changed, 440 insertions, 167 deletions
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 5729a43a5..84b401e63 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -5,15 +5,17 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {usePalette} from '#/lib/hooks/usePalette' import {useScrollHandlers} from '#/lib/ScrollContext' -import {useGate} from 'lib/statsig/statsig' import {addStyle} from 'lib/styles' -import {isWeb} from 'platform/detection' import {FlatList_INTERNAL} from './Views' export type ListMethods = FlatList_INTERNAL export type ListProps<ItemT> = Omit< FlatListProps<ItemT>, + | 'onMomentumScrollBegin' // Use ScrollContext instead. + | 'onMomentumScrollEnd' // Use ScrollContext instead. | 'onScroll' // Use ScrollContext instead. + | 'onScrollBeginDrag' // Use ScrollContext instead. + | 'onScrollEndDrag' // Use ScrollContext instead. | 'refreshControl' // Pass refreshing and/or onRefresh instead. | 'contentOffset' // Pass headerOffset instead. > & { @@ -21,6 +23,7 @@ export type ListProps<ItemT> = Omit< headerOffset?: number refreshing?: boolean onRefresh?: () => void + containWeb?: boolean } export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> @@ -40,7 +43,6 @@ function ListImpl<ItemT>( const isScrolledDown = useSharedValue(false) const contextScrollHandlers = useScrollHandlers() const pal = usePalette('default') - const gate = useGate() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -64,6 +66,11 @@ function ListImpl<ItemT>( } } }, + // Note: adding onMomentumBegin here makes simulator scroll + // lag on Android. So either don't add it, or figure out why. + onMomentumEnd(e, ctx) { + contextScrollHandlers.onMomentumEnd?.(e, ctx) + }, }) let refreshControl @@ -97,9 +104,6 @@ function ListImpl<ItemT>( scrollEventThrottle={1} style={style} ref={ref} - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> ) } diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 936bac198..9bea2d795 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -1,11 +1,13 @@ -import React, {isValidElement, memo, useRef, startTransition} from 'react' +import React, {isValidElement, memo, startTransition, useRef} from 'react' import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' -import {addStyle} from 'lib/styles' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' + +import {batchedUpdates} from '#/lib/batchedUpdates' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useScrollHandlers} from '#/lib/ScrollContext' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useScrollHandlers} from '#/lib/ScrollContext' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {batchedUpdates} from '#/lib/batchedUpdates' +import {addStyle} from 'lib/styles' export type ListMethods = any // TODO: Better types. export type ListProps<ItemT> = Omit< @@ -19,6 +21,7 @@ export type ListProps<ItemT> = Omit< refreshing?: boolean onRefresh?: () => void desktopFixedHeight: any // TODO: Better types. + containWeb?: boolean } export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. @@ -26,12 +29,15 @@ function ListImpl<ItemT>( { ListHeaderComponent, ListFooterComponent, + containWeb, contentContainerStyle, data, desktopFixedHeight, headerOffset, keyExtractor, refreshing: _unsupportedRefreshing, + onStartReached, + onStartReachedThreshold = 0, onEndReached, onEndReachedThreshold = 0, onRefresh: _unsupportedOnRefresh, @@ -80,14 +86,88 @@ function ListImpl<ItemT>( }) } - const nativeRef = React.useRef(null) + const getScrollableNode = React.useCallback(() => { + if (containWeb) { + const element = nativeRef.current as HTMLDivElement | null + if (!element) return + + return { + get scrollWidth() { + return element.scrollWidth + }, + get scrollHeight() { + return element.scrollHeight + }, + get clientWidth() { + return element.clientWidth + }, + get clientHeight() { + return element.clientHeight + }, + get scrollY() { + return element.scrollTop + }, + get scrollX() { + return element.scrollLeft + }, + scrollTo(options?: ScrollToOptions) { + element.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + element.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + element.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + element.removeEventListener(event, handler) + }, + } + } else { + return { + get scrollWidth() { + return document.documentElement.scrollWidth + }, + get scrollHeight() { + return document.documentElement.scrollHeight + }, + get clientWidth() { + return window.innerWidth + }, + get clientHeight() { + return window.innerHeight + }, + get scrollY() { + return window.scrollY + }, + get scrollX() { + return window.scrollX + }, + scrollTo(options: ScrollToOptions) { + window.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + window.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + window.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + window.removeEventListener(event, handler) + }, + } + } + }, [containWeb]) + + const nativeRef = React.useRef<HTMLDivElement>(null) React.useImperativeHandle( ref, () => ({ scrollToTop() { - window.scrollTo({top: 0}) + getScrollableNode()?.scrollTo({top: 0}) }, + scrollToOffset({ animated, offset, @@ -95,46 +175,74 @@ function ListImpl<ItemT>( animated: boolean offset: number }) { - window.scrollTo({ + getScrollableNode()?.scrollTo({ left: 0, top: offset, behavior: animated ? 'smooth' : 'instant', }) }, + scrollToEnd({animated = true}: {animated?: boolean}) { + const element = getScrollableNode() + element?.scrollTo({ + left: 0, + top: element.scrollHeight, + behavior: animated ? 'smooth' : 'instant', + }) + }, } as any), // TODO: Better types. - [], + [getScrollableNode], ) - // --- onContentSizeChange --- + // --- onContentSizeChange, maintainVisibleContentPosition --- const containerRef = useRef(null) useResizeObserver(containerRef, onContentSizeChange) // --- onScroll --- const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) - const handleWindowScroll = useNonReactiveCallback(() => { - if (isInsideVisibleTree) { - contextScrollHandlers.onScroll?.( - { - contentOffset: { - x: Math.max(0, window.scrollX), - y: Math.max(0, window.scrollY), - }, - } as any, // TODO: Better types. - null as any, - ) - } + const handleScroll = useNonReactiveCallback(() => { + if (!isInsideVisibleTree) return + + const element = getScrollableNode() + contextScrollHandlers.onScroll?.( + { + contentOffset: { + x: Math.max(0, element?.scrollX ?? 0), + y: Math.max(0, element?.scrollY ?? 0), + }, + layoutMeasurement: { + width: element?.clientWidth, + height: element?.clientHeight, + }, + contentSize: { + width: element?.scrollWidth, + height: element?.scrollHeight, + }, + } as Exclude< + ReanimatedScrollEvent, + | 'velocity' + | 'eventName' + | 'zoomScale' + | 'targetContentOffset' + | 'contentInset' + >, + null as any, + ) }) + React.useEffect(() => { if (!isInsideVisibleTree) { // Prevents hidden tabs from firing scroll events. // Only one list is expected to be firing these at a time. return } - window.addEventListener('scroll', handleWindowScroll) + + const element = getScrollableNode() + + element?.addEventListener('scroll', handleScroll) return () => { - window.removeEventListener('scroll', handleWindowScroll) + element?.removeEventListener('scroll', handleScroll) } - }, [isInsideVisibleTree, handleWindowScroll]) + }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) // --- onScrolledDownChange --- const isScrolledDown = useRef(false) @@ -148,6 +256,17 @@ function ListImpl<ItemT>( } } + // --- onStartReached --- + const onHeadVisibilityChange = useNonReactiveCallback( + (isHeadVisible: boolean) => { + if (isHeadVisible) { + onStartReached?.({ + distanceFromStart: onStartReachedThreshold || 0, + }) + } + }, + ) + // --- onEndReached --- const onTailVisibilityChange = useNonReactiveCallback( (isTailVisible: boolean) => { @@ -160,7 +279,17 @@ function ListImpl<ItemT>( ) return ( - <View {...props} style={style} ref={nativeRef}> + <View + {...props} + style={[ + style, + containWeb && { + flex: 1, + // @ts-expect-error web only + 'overflow-y': 'scroll', + }, + ]} + ref={nativeRef as any}> <Visibility onVisibleChange={setIsInsideVisibleTree} style={ @@ -178,9 +307,17 @@ function ListImpl<ItemT>( pal.border, ]}> <Visibility + root={containWeb ? nativeRef : null} onVisibleChange={handleAboveTheFoldVisibleChange} style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> + {onStartReached && ( + <Visibility + root={containWeb ? nativeRef : null} + onVisibleChange={onHeadVisibilityChange} + topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} + /> + )} {header} {(data as Array<ItemT>).map((item, index) => ( <Row<ItemT> @@ -193,8 +330,9 @@ function ListImpl<ItemT>( ))} {onEndReached && ( <Visibility - topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} + root={containWeb ? nativeRef : null} onVisibleChange={onTailVisibilityChange} + bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} /> )} {footer} @@ -255,11 +393,15 @@ let Row = function RowImpl<ItemT>({ Row = React.memo(Row) let Visibility = ({ + root, topMargin = '0px', + bottomMargin = '0px', onVisibleChange, style, }: { + root?: React.RefObject<HTMLDivElement> | null topMargin?: string + bottomMargin?: string onVisibleChange: (isVisible: boolean) => void style?: ViewProps['style'] }): React.ReactNode => { @@ -281,14 +423,15 @@ let Visibility = ({ React.useEffect(() => { const observer = new IntersectionObserver(handleIntersection, { - rootMargin: `${topMargin} 0px 0px 0px`, + root: root?.current ?? null, + rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, }) const tail: Element | null = tailRef.current! observer.observe(tail) return () => { observer.unobserve(tail) } - }, [handleIntersection, topMargin]) + }, [bottomMargin, handleIntersection, topMargin, root]) return ( <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 01b8a954d..f45229dc4 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -1,11 +1,12 @@ import React, {useCallback, useEffect} from 'react' +import {NativeScrollEvent} from 'react-native' +import {interpolate, useSharedValue} from 'react-native-reanimated' import EventEmitter from 'eventemitter3' + import {ScrollProvider} from '#/lib/ScrollContext' -import {NativeScrollEvent} from 'react-native' -import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' +import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' import {isNative, isWeb} from 'platform/detection' -import {useSharedValue, interpolate} from 'react-native-reanimated' const WEB_HIDE_SHELL_THRESHOLD = 200 @@ -32,6 +33,31 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { } }) + const snapToClosestState = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + if (isNative) { + if (startDragOffset.value === null) { + return + } + const didScrollDown = e.contentOffset.y > startDragOffset.value + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value) { + // If we're close to the top, show the shell. + setMode(false) + } else if (didScrollDown) { + // Showing the bar again on scroll down feels annoying, so don't. + setMode(true) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } + } + }, + [startDragOffset, startMode, setMode, mode, headerHeight], + ) + const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' @@ -47,18 +73,24 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - startDragOffset.value = null - startMode.value = null - if (e.contentOffset.y < headerHeight.value / 2) { - // If we're close to the top, show the shell. - setMode(false) - } else { - // Snap to whichever state is the closest. - setMode(Math.round(mode.value) === 1) + if (e.velocity && e.velocity.y !== 0) { + // If we detect a velocity, wait for onMomentumEnd to snap. + return } + snapToClosestState(e) } }, - [startDragOffset, startMode, setMode, mode, headerHeight], + [snapToClosestState], + ) + + const onMomentumEnd = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + if (isNative) { + snapToClosestState(e) + } + }, + [snapToClosestState], ) const onScroll = useCallback( @@ -119,7 +151,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { <ScrollProvider onBeginDrag={onBeginDrag} onEndDrag={onEndDrag} - onScroll={onScroll}> + onScroll={onScroll} + onMomentumEnd={onMomentumEnd}> {children} </ScrollProvider> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index db16ff066..e7ce18535 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {niceDate} from 'lib/strings/time' import {TypographyVariant} from 'lib/ThemeContext' import {isAndroid, isWeb} from 'platform/detection' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {TextLinkOnWebOnly} from './Link' import {Text} from './text/Text' import {TimeElapsed} from './TimeElapsed' @@ -58,37 +59,39 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { /> </View> )} - <Text - numberOfLines={1} - style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}> - <TextLinkOnWebOnly - type={opts.displayNameType || 'lg-bold'} - style={[pal.text]} - lineHeight={1.2} - disableMismatchWarning - text={ - <> - {sanitizeDisplayName( - displayName, - opts.moderation?.ui('displayName'), - )} - </> - } - href={profileLink} - onBeforePress={onBeforePress} - onPointerEnter={onPointerEnter} - /> - <TextLinkOnWebOnly - type="md" - disableMismatchWarning - style={[pal.textLight, {flexShrink: 4}]} - text={'\xa0' + sanitizeHandle(handle, '@')} - href={profileLink} - onBeforePress={onBeforePress} - onPointerEnter={onPointerEnter} - anchorNoUnderline - /> - </Text> + <ProfileHoverCard inline did={opts.author.did}> + <Text + numberOfLines={1} + style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}> + <TextLinkOnWebOnly + type={opts.displayNameType || 'lg-bold'} + style={[pal.text]} + lineHeight={1.2} + disableMismatchWarning + text={ + <> + {sanitizeDisplayName( + displayName, + opts.moderation?.ui('displayName'), + )} + </> + } + href={profileLink} + onBeforePress={onBeforePress} + onPointerEnter={onPointerEnter} + /> + <TextLinkOnWebOnly + type="md" + disableMismatchWarning + style={[pal.textLight, {flexShrink: 4}]} + text={'\xa0' + sanitizeHandle(handle, '@')} + href={profileLink} + onBeforePress={onBeforePress} + onPointerEnter={onPointerEnter} + anchorNoUnderline + /> + </Text> + </ProfileHoverCard> {!isAndroid && ( <Text type="md" diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index 6ea41b82b..a5d3a5372 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -3,21 +3,25 @@ import React from 'react' import {useTickEveryMinute} from '#/state/shell' import {ago} from 'lib/strings/time' -// FIXME(dan): Figure out why the false positives - export function TimeElapsed({ timestamp, children, + timeToString = ago, }: { timestamp: string children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element + timeToString?: (timeElapsed: string) => string }) { const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => ago(timestamp)) + const [timeElapsed, setTimeAgo] = React.useState(() => + timeToString(timestamp), + ) - React.useEffect(() => { - setTimeAgo(ago(timestamp)) - }, [timestamp, setTimeAgo, tick]) + const [prevTick, setPrevTick] = React.useState(tick) + if (prevTick !== tick) { + setPrevTick(tick) + setTimeAgo(timeToString(timestamp)) + } return children({timeElapsed}) } diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx index 75f2b5081..2984a2d2d 100644 --- a/src/view/com/util/Views.jsx +++ b/src/view/com/util/Views.jsx @@ -2,19 +2,11 @@ import React from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' -import {useGate} from 'lib/statsig/statsig' - export const FlatList_INTERNAL = Animated.FlatList export function CenteredView(props) { return <View {...props} /> } export function ScrollView(props) { - const gate = useGate() - return ( - <Animated.ScrollView - {...props} - showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')} - /> - ) + return <Animated.ScrollView {...props} /> } diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx index 94591d393..6668ac211 100644 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -1,12 +1,13 @@ import React from 'react' +import {Pressable, StyleSheet, Text, View, ViewStyle} from 'react-native' +import {IconProp} from '@fortawesome/fontawesome-svg-core' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' -import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native' -import {IconProp} from '@fortawesome/fontawesome-svg-core' 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 {HITSLOP_10} from 'lib/constants' // Custom Dropdown Menu Components // == @@ -64,15 +65,9 @@ export function NativeDropdown({ accessibilityHint, triggerStyle, }: React.PropsWithChildren<Props>) { - const pal = usePalette('default') - const theme = useTheme() - const dropDownBackgroundColor = - theme.colorScheme === 'dark' ? pal.btn : pal.view const [open, setOpen] = React.useState(false) const buttonRef = React.useRef<HTMLButtonElement>(null) const menuRef = React.useRef<HTMLDivElement>(null) - const {borderColor: separatorColor} = - theme.colorScheme === 'dark' ? pal.borderDark : pal.border React.useEffect(() => { function clickHandler(e: MouseEvent) { @@ -114,14 +109,27 @@ export function NativeDropdown({ return ( <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}> - <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}> + <DropdownMenu.Trigger asChild> <Pressable ref={buttonRef as unknown as React.Ref<View>} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} - onPress={() => setOpen(o => !o)} + onPointerDown={e => { + // Prevent false positive that interpret mobile scroll as a tap. + // This requires the custom onPress handler below to compensate. + // https://github.com/radix-ui/primitives/issues/1912 + e.preventDefault() + }} + onPress={() => { + if (window.event instanceof KeyboardEvent) { + // The onPointerDown hack above is not relevant to this press, so don't do anything. + return + } + // Compensate for the disabled onPointerDown above by triggering it manually. + setOpen(o => !o) + }} hitSlop={HITSLOP_10} style={triggerStyle}> {children} @@ -129,53 +137,53 @@ export function NativeDropdown({ </DropdownMenu.Trigger> <DropdownMenu.Portal> - <DropdownMenu.Content - ref={menuRef} - style={ - StyleSheet.flatten([ - styles.content, - dropDownBackgroundColor, - ]) as React.CSSProperties - } - loop> - {items.map((item, index) => { - if (item.label === 'separator') { - return ( - <DropdownMenu.Separator - key={getKey(item.label, index, item.testID)} - style={ - StyleSheet.flatten([ - styles.separator, - {backgroundColor: separatorColor}, - ]) as React.CSSProperties - } - /> - ) - } - if (index > 1 && items[index - 1].label === 'separator') { - return ( - <DropdownMenu.Group - key={getKey(item.label, index, item.testID)}> - <DropdownMenuItem - key={getKey(item.label, index, item.testID)} - onSelect={item.onPress}> - <Text - selectable={false} - style={[pal.text, styles.itemTitle]}> - {item.label} - </Text> - {item.icon && ( - <FontAwesomeIcon - icon={item.icon.web} - size={20} - color={pal.colors.textLight} - /> - )} - </DropdownMenuItem> - </DropdownMenu.Group> - ) - } - return ( + <DropdownContent items={items} menuRef={menuRef} /> + </DropdownMenu.Portal> + </DropdownMenuRoot> + ) +} + +function DropdownContent({ + items, + menuRef, +}: { + items: DropdownItem[] + menuRef: React.RefObject<HTMLDivElement> +}) { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + const {borderColor: separatorColor} = + theme.colorScheme === 'dark' ? pal.borderDark : pal.border + + return ( + <DropdownMenu.Content + ref={menuRef} + style={ + StyleSheet.flatten([ + styles.content, + dropDownBackgroundColor, + ]) as React.CSSProperties + } + loop> + {items.map((item, index) => { + if (item.label === 'separator') { + return ( + <DropdownMenu.Separator + key={getKey(item.label, index, item.testID)} + style={ + StyleSheet.flatten([ + styles.separator, + {backgroundColor: separatorColor}, + ]) as React.CSSProperties + } + /> + ) + } + if (index > 1 && items[index - 1].label === 'separator') { + return ( + <DropdownMenu.Group key={getKey(item.label, index, item.testID)}> <DropdownMenuItem key={getKey(item.label, index, item.testID)} onSelect={item.onPress}> @@ -190,11 +198,27 @@ export function NativeDropdown({ /> )} </DropdownMenuItem> - ) - })} - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenuRoot> + </DropdownMenu.Group> + ) + } + return ( + <DropdownMenuItem + key={getKey(item.label, index, item.testID)} + onSelect={item.onPress}> + <Text selectable={false} style={[pal.text, styles.itemTitle]}> + {item.label} + </Text> + {item.icon && ( + <FontAwesomeIcon + icon={item.icon.web} + size={20} + color={pal.colors.textLight} + /> + )} + </DropdownMenuItem> + ) + })} + </DropdownMenu.Content> ) } diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 32520182e..ac97f3da2 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,6 +1,6 @@ import React, {memo} from 'react' import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native' -import {setStringAsync} from 'expo-clipboard' +import * as Clipboard from 'expo-clipboard' import { AppBskyActorDefs, AppBskyFeedPost, @@ -160,7 +160,7 @@ let PostDropdownBtn = ({ const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) - setStringAsync(str) + Clipboard.setStringAsync(str) Toast.show(_(msg`Copied to clipboard`)) }, [_, richText]) diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 7de3b093a..f6d2c7a1b 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,9 +1,10 @@ -import {AppBskyEmbedImages} from '@atproto/api' import React, {ComponentProps, FC} from 'react' -import {StyleSheet, Text, Pressable, View} from 'react-native' +import {Pressable, StyleSheet, Text, View} from 'react-native' import {Image} from 'expo-image' +import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' + import {isWeb} from 'platform/detection' type EventFunction = (index: number) => void diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 1fe75c44e..b84c04b83 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -20,9 +20,11 @@ import {Text} from '../text/Text' export const ExternalLinkEmbed = ({ link, style, + hideAlt, }: { link: AppBskyEmbedExternal.ViewExternal style?: StyleProp<ViewStyle> + hideAlt?: boolean }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() @@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({ }, [link.uri, externalEmbedPrefs]) if (embedPlayerParams?.source === 'tenor') { - return <GifEmbed params={embedPlayerParams} link={link} /> + return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} /> } return ( diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index 5d21ce064..286b57992 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -1,14 +1,18 @@ import React from 'react' -import {Pressable, View} from 'react-native' +import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native' import {AppBskyEmbedExternal} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HITSLOP_10} from '#/lib/constants' +import {isWeb} from '#/platform/detection' import {EmbedPlayerParams} from 'lib/strings/embed-player' import {useAutoplayDisabled} from 'state/preferences' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' import {GifView} from '../../../../../modules/expo-bluesky-gif-view' import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' @@ -82,9 +86,11 @@ function PlaybackControls({ export function GifEmbed({ params, link, + hideAlt, }: { params: EmbedPlayerParams link: AppBskyEmbedExternal.ViewExternal + hideAlt?: boolean }) { const {_} = useLingui() const autoplayDisabled = useAutoplayDisabled() @@ -111,7 +117,8 @@ export function GifEmbed({ }, []) return ( - <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}> + <View + style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}> <View style={[ a.rounded_sm, @@ -133,9 +140,69 @@ export function GifEmbed({ onPlayerStateChange={onPlayerStateChange} ref={playerRef} accessibilityHint={_(msg`Animated GIF`)} - accessibilityLabel={link.description.replace('ALT: ', '')} + accessibilityLabel={link.description.replace('Alt text: ', '')} /> + + {!hideAlt && link.description.startsWith('Alt text: ') && ( + <AltText text={link.description.replace('Alt text: ', '')} /> + )} </View> </View> ) } + +function AltText({text}: {text: string}) { + const control = Prompt.usePromptControl() + + const {_} = useLingui() + return ( + <> + <TouchableOpacity + testID="altTextButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Show alt text`)} + accessibilityHint="" + hitSlop={HITSLOP_10} + onPress={control.open} + style={styles.altContainer}> + <Text style={styles.alt} accessible={false}> + <Trans>ALT</Trans> + </Text> + </TouchableOpacity> + + <Prompt.Outer control={control}> + <Prompt.TitleText> + <Trans>Alt Text</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> + <Prompt.Actions> + <Prompt.Action + onPress={control.close} + cta={_(msg`Close`)} + color="secondary" + /> + </Prompt.Actions> + </Prompt.Outer> + </> + ) +} + +const styles = StyleSheet.create({ + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + // Related to margin/gap hack. This keeps the alt label in the same position + // on all platforms + left: isWeb ? 8 : 5, + bottom: isWeb ? 8 : 5, + zIndex: 2, + }, + alt: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, +}) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e0178f34b..0e19a6ccd 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -25,7 +25,7 @@ import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' import {s} from '#/lib/styles' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {InfoCircleIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' |