diff options
Diffstat (limited to 'src/view/com/util')
-rw-r--r-- | src/view/com/util/List.tsx | 7 | ||||
-rw-r--r-- | src/view/com/util/List.web.tsx | 201 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 23 | ||||
-rw-r--r-- | src/view/com/util/UserBanner.tsx | 43 | ||||
-rw-r--r-- | src/view/com/util/Views.jsx | 10 | ||||
-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-ctrls/PostCtrls.tsx | 24 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/RepostButton.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 |
11 files changed, 311 insertions, 90 deletions
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index ff60e94cd..84b401e63 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -5,9 +5,7 @@ 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 @@ -25,6 +23,7 @@ export type ListProps<ItemT> = Omit< headerOffset?: number refreshing?: boolean onRefresh?: () => void + containWeb?: boolean } export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> @@ -44,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) @@ -106,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/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 118e2ce2b..45327669b 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -8,6 +8,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' +import {logger} from '#/logger' import {usePalette} from 'lib/hooks/usePalette' import { useCameraPermission, @@ -282,15 +283,21 @@ let EditableUserAvatar = ({ return } - const croppedImage = await openCropper({ - mediaType: 'photo', - cropperCircleOverlay: true, - height: item.height, - width: item.width, - path: item.path, - }) + try { + const croppedImage = await openCropper({ + mediaType: 'photo', + cropperCircleOverlay: true, + height: item.height, + width: item.width, + path: item.path, + }) - onSelectNewAvatar(croppedImage) + onSelectNewAvatar(croppedImage) + } catch (e: any) { + if (!String(e).includes('Canceled')) { + logger.error('Failed to crop banner', {error: e}) + } + } }, [onSelectNewAvatar, requestPhotoAccessIfNeeded]) const onRemoveAvatar = React.useCallback(() => { diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 4d73b853b..93ea32750 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,29 +1,30 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {ModerationUI} from '@atproto/api' +import {Image as RNImage} from 'react-native-image-crop-picker' import {Image} from 'expo-image' -import {useLingui} from '@lingui/react' +import {ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {colors} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {useTheme as useAlfTheme, tokens} from '#/alf' -import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' +import {logger} from '#/logger' +import {usePalette} from 'lib/hooks/usePalette' import { - usePhotoLibraryPermission, useCameraPermission, + usePhotoLibraryPermission, } from 'lib/hooks/usePermissions' -import {usePalette} from 'lib/hooks/usePalette' +import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' import {isAndroid, isNative} from 'platform/detection' -import {Image as RNImage} from 'react-native-image-crop-picker' import {EventStopper} from 'view/com/util/EventStopper' -import * as Menu from '#/components/Menu' +import {tokens, useTheme as useAlfTheme} from '#/alf' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Stroke2_Corner0_Rounded as Camera, } from '#/components/icons/Camera' import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import * as Menu from '#/components/Menu' +import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' export function UserBanner({ type, @@ -64,14 +65,20 @@ export function UserBanner({ return } - onSelectNewBanner?.( - await openCropper({ - mediaType: 'photo', - path: items[0].path, - width: 3000, - height: 1000, - }), - ) + try { + onSelectNewBanner?.( + await openCropper({ + mediaType: 'photo', + path: items[0].path, + width: 3000, + height: 1000, + }), + ) + } catch (e: any) { + if (!String(e).includes('Canceled')) { + logger.error('Failed to crop banner', {error: e}) + } + } }, [onSelectNewBanner, requestPhotoAccessIfNeeded]) const onRemoveBanner = React.useCallback(() => { 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/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-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index cb50ee6dc..7ebcde9a0 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -12,14 +12,13 @@ import { AtUri, RichText as RichTextAPI, } from '@atproto/api' -import {msg} from '@lingui/macro' +import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import {HITSLOP_10, HITSLOP_20} from '#/lib/constants' import {CommentBottomArrow, HeartIcon, HeartIconSolid} from '#/lib/icons' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' -import {pluralize} from '#/lib/strings/helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {s} from '#/lib/styles' import {useTheme} from '#/lib/ThemeContext' @@ -159,9 +158,10 @@ let PostCtrls = ({ } }} accessibilityRole="button" - accessibilityLabel={`Reply (${post.replyCount} ${ - post.replyCount === 1 ? 'reply' : 'replies' - })`} + accessibilityLabel={plural(post.replyCount || 0, { + one: 'Reply (# reply)', + other: 'Reply (# replies)', + })} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> <CommentBottomArrow @@ -193,9 +193,17 @@ let PostCtrls = ({ requireAuth(() => onPressToggleLike()) }} accessibilityRole="button" - accessibilityLabel={`${ - post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`) - } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`} + accessibilityLabel={ + post.viewer?.like + ? plural(post.likeCount || 0, { + one: 'Unlike (# like)', + other: 'Unlike (# likes)', + }) + : plural(post.likeCount || 0, { + one: 'Like (# like)', + other: 'Like (# likes)', + }) + } accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> {post.viewer?.like ? ( diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index cc3db50c8..c1af39a5d 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -4,11 +4,10 @@ import {RepostIcon} from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' -import {pluralize} from 'lib/strings/helpers' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' import {useModalControls} from '#/state/modals' import {useRequireAuth} from '#/state/session' -import {msg} from '@lingui/macro' +import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' interface Props { @@ -59,7 +58,7 @@ let RepostButton = ({ isReposted ? _(msg`Undo repost`) : _(msg({message: 'Repost', context: 'action'})) - } (${repostCount} ${pluralize(repostCount || 0, 'repost')})`} + } (${plural(repostCount || 0, {one: '# repost', other: '# reposts'})})`} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> <RepostIcon 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', + }, +}) |