diff options
Diffstat (limited to 'src/view/com/util')
32 files changed, 1235 insertions, 260 deletions
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 76d493886..221879df7 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -22,7 +22,7 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) { label: _(msg`Remove account`), onPress: () => { removeAccount(account) - Toast.show('Account removed from quick access') + Toast.show(_(msg`Account removed from quick access`)) }, icon: { ios: { diff --git a/src/view/com/util/BlurView.android.tsx b/src/view/com/util/BlurView.android.tsx new file mode 100644 index 000000000..eee1d9d86 --- /dev/null +++ b/src/view/com/util/BlurView.android.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import {StyleSheet, View, ViewProps} from 'react-native' +import {addStyle} from 'lib/styles' + +type BlurViewProps = ViewProps & { + blurType?: 'dark' | 'light' + blurAmount?: number +} + +export const BlurView = ({ + style, + blurType, + ...props +}: React.PropsWithChildren<BlurViewProps>) => { + if (blurType === 'dark') { + style = addStyle(style, styles.dark) + } else { + style = addStyle(style, styles.light) + } + return <View style={style} {...props} /> +} + +const styles = StyleSheet.create({ + dark: { + backgroundColor: '#0008', + }, + light: { + backgroundColor: '#fff8', + }, +}) diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 397588cfb..5ec1d0014 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -2,6 +2,7 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' import {CenteredView} from './Views' import {t} from '@lingui/macro' +import {logger} from '#/logger' interface Props { children?: ReactNode @@ -23,7 +24,7 @@ export class ErrorBoundary extends Component<Props, State> { } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('Uncaught error:', error, errorInfo) + logger.error(error, {errorInfo}) } public render() { diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index dcbec7cb4..db26258d6 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -1,6 +1,5 @@ import React, {ComponentProps, memo, useMemo} from 'react' import { - Linking, GestureResponderEvent, Platform, StyleProp, @@ -31,6 +30,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url' import {PressableWithHover} from './PressableWithHover' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' import {useModalControls} from '#/state/modals' +import {useOpenLink} from '#/state/preferences/in-app-browser' type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> @@ -65,6 +65,7 @@ export const Link = memo(function Link({ const {closeModal} = useModalControls() const navigation = useNavigation<NavigationProp>() const anchorHref = asAnchor ? sanitizeUrl(href) : undefined + const openLink = useOpenLink() const onPress = React.useCallback( (e?: Event) => { @@ -74,11 +75,12 @@ export const Link = memo(function Link({ navigation, sanitizeUrl(href), navigationAction, + openLink, e, ) } }, - [closeModal, navigation, navigationAction, href], + [closeModal, navigation, navigationAction, href, openLink], ) if (noFeedback) { @@ -172,6 +174,7 @@ export const TextLink = memo(function TextLink({ const {...props} = useLinkProps({to: sanitizeUrl(href)}) const navigation = useNavigation<NavigationProp>() const {openModal, closeModal} = useModalControls() + const openLink = useOpenLink() if (warnOnMismatchingLabel && typeof text !== 'string') { console.error('Unable to detect mismatching label') @@ -200,6 +203,7 @@ export const TextLink = memo(function TextLink({ navigation, sanitizeUrl(href), navigationAction, + openLink, e, ) }, @@ -212,6 +216,7 @@ export const TextLink = memo(function TextLink({ text, warnOnMismatchingLabel, navigationAction, + openLink, ], ) const hrefAttrs = useMemo(() => { @@ -301,6 +306,8 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ ) }) +const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/'] + // NOTE // we can't use the onPress given by useLinkProps because it will // match most paths to the HomeTab routes while we actually want to @@ -317,6 +324,7 @@ function onPressInner( navigation: NavigationProp, href: string, navigationAction: 'push' | 'replace' | 'navigate' = 'push', + openLink: (href: string) => void, e?: Event, ) { let shouldHandle = false @@ -344,8 +352,13 @@ function onPressInner( if (shouldHandle) { href = convertBskyAppUrlIfNeeded(href) - if (newTab || href.startsWith('http') || href.startsWith('mailto')) { - Linking.openURL(href) + if ( + newTab || + href.startsWith('http') || + href.startsWith('mailto') || + EXEMPT_PATHS.some(path => href.startsWith(path)) + ) { + openLink(href) } else { closeModal() // close any active modals diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 9abd7d35a..d30a9d805 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,4 +1,4 @@ -import React, {memo, startTransition} from 'react' +import React, {memo} from 'react' import {FlatListProps, RefreshControl} from 'react-native' import {FlatList_INTERNAL} from './Views' import {addStyle} from 'lib/styles' @@ -39,9 +39,7 @@ function ListImpl<ItemT>( const pal = usePalette('default') function handleScrolledDownChange(didScrollDown: boolean) { - startTransition(() => { - onScrolledDownChange?.(didScrollDown) - }) + onScrolledDownChange?.(didScrollDown) } const scrollHandler = useAnimatedScrollHandler({ diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx new file mode 100644 index 000000000..3e81a8c37 --- /dev/null +++ b/src/view/com/util/List.web.tsx @@ -0,0 +1,341 @@ +import React, {isValidElement, memo, useRef, startTransition} from 'react' +import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' +import {addStyle} from 'lib/styles' +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' + +export type ListMethods = any // TODO: Better types. +export type ListProps<ItemT> = Omit< + FlatListProps<ItemT>, + | 'onScroll' // Use ScrollContext instead. + | 'refreshControl' // Pass refreshing and/or onRefresh instead. + | 'contentOffset' // Pass headerOffset instead. +> & { + onScrolledDownChange?: (isScrolledDown: boolean) => void + headerOffset?: number + refreshing?: boolean + onRefresh?: () => void + desktopFixedHeight: any // TODO: Better types. +} +export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. + +function ListImpl<ItemT>( + { + ListHeaderComponent, + ListFooterComponent, + contentContainerStyle, + data, + desktopFixedHeight, + headerOffset, + keyExtractor, + refreshing: _unsupportedRefreshing, + onEndReached, + onEndReachedThreshold = 0, + onRefresh: _unsupportedOnRefresh, + onScrolledDownChange, + onContentSizeChange, + renderItem, + extraData, + style, + ...props + }: ListProps<ItemT>, + ref: React.Ref<ListMethods>, +) { + const contextScrollHandlers = useScrollHandlers() + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + if (!isMobile) { + contentContainerStyle = addStyle( + contentContainerStyle, + styles.containerScroll, + ) + } + + let header: JSX.Element | null = null + if (ListHeaderComponent != null) { + if (isValidElement(ListHeaderComponent)) { + header = ListHeaderComponent + } else { + // @ts-ignore Nah it's fine. + header = <ListHeaderComponent /> + } + } + + let footer: JSX.Element | null = null + if (ListFooterComponent != null) { + if (isValidElement(ListFooterComponent)) { + footer = ListFooterComponent + } else { + // @ts-ignore Nah it's fine. + footer = <ListFooterComponent /> + } + } + + if (headerOffset != null) { + style = addStyle(style, { + paddingTop: headerOffset, + }) + } + + const nativeRef = React.useRef(null) + React.useImperativeHandle( + ref, + () => + ({ + scrollToTop() { + window.scrollTo({top: 0}) + }, + scrollToOffset({ + animated, + offset, + }: { + animated: boolean + offset: number + }) { + window.scrollTo({ + left: 0, + top: offset, + behavior: animated ? 'smooth' : 'instant', + }) + }, + } as any), // TODO: Better types. + [], + ) + + // --- onContentSizeChange --- + 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, + ) + } + }) + 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) + return () => { + window.removeEventListener('scroll', handleWindowScroll) + } + }, [isInsideVisibleTree, handleWindowScroll]) + + // --- onScrolledDownChange --- + const isScrolledDown = useRef(false) + function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) { + const didScrollDown = !isAboveTheFold + if (isScrolledDown.current !== didScrollDown) { + isScrolledDown.current = didScrollDown + startTransition(() => { + onScrolledDownChange?.(didScrollDown) + }) + } + } + + // --- onEndReached --- + const onTailVisibilityChange = useNonReactiveCallback( + (isTailVisible: boolean) => { + if (isTailVisible) { + onEndReached?.({ + distanceFromEnd: onEndReachedThreshold || 0, + }) + } + }, + ) + + return ( + <View {...props} style={style} ref={nativeRef}> + <Visibility + onVisibleChange={setIsInsideVisibleTree} + style={ + // This has position: fixed, so it should always report as visible + // unless we're within a display: none tree (like a hidden tab). + styles.parentTreeVisibilityDetector + } + /> + <View + ref={containerRef} + style={[ + styles.contentContainer, + contentContainerStyle, + desktopFixedHeight ? styles.minHeightViewport : null, + pal.border, + ]}> + <Visibility + onVisibleChange={handleAboveTheFoldVisibleChange} + style={[styles.aboveTheFoldDetector, {height: headerOffset}]} + /> + {header} + {(data as Array<ItemT>).map((item, index) => ( + <Row<ItemT> + key={keyExtractor!(item, index)} + item={item} + index={index} + renderItem={renderItem} + extraData={extraData} + /> + ))} + {onEndReached && ( + <Visibility + topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} + onVisibleChange={onTailVisibilityChange} + /> + )} + {footer} + </View> + </View> + ) +} + +function useResizeObserver( + ref: React.RefObject<Element>, + onResize: undefined | ((w: number, h: number) => void), +) { + const handleResize = useNonReactiveCallback(onResize ?? (() => {})) + const isActive = !!onResize + React.useEffect(() => { + if (!isActive) { + return + } + const resizeObserver = new ResizeObserver(entries => { + batchedUpdates(() => { + for (let entry of entries) { + const rect = entry.contentRect + handleResize(rect.width, rect.height) + } + }) + }) + const node = ref.current! + resizeObserver.observe(node) + return () => { + resizeObserver.unobserve(node) + } + }, [handleResize, isActive, ref]) +} + +let Row = function RowImpl<ItemT>({ + item, + index, + renderItem, + extraData: _unused, +}: { + item: ItemT + index: number + renderItem: + | null + | undefined + | ((data: {index: number; item: any; separators: any}) => React.ReactNode) + extraData: any +}): React.ReactNode { + if (!renderItem) { + return null + } + return ( + <View style={styles.row}> + {renderItem({item, index, separators: null as any})} + </View> + ) +} +Row = React.memo(Row) + +let Visibility = ({ + topMargin = '0px', + onVisibleChange, + style, +}: { + topMargin?: string + onVisibleChange: (isVisible: boolean) => void + style?: ViewProps['style'] +}): React.ReactNode => { + const tailRef = React.useRef(null) + const isIntersecting = React.useRef(false) + + const handleIntersection = useNonReactiveCallback( + (entries: IntersectionObserverEntry[]) => { + batchedUpdates(() => { + entries.forEach(entry => { + if (entry.isIntersecting !== isIntersecting.current) { + isIntersecting.current = entry.isIntersecting + onVisibleChange(entry.isIntersecting) + } + }) + }) + }, + ) + + React.useEffect(() => { + const observer = new IntersectionObserver(handleIntersection, { + rootMargin: `${topMargin} 0px 0px 0px`, + }) + const tail: Element | null = tailRef.current! + observer.observe(tail) + return () => { + observer.unobserve(tail) + } + }, [handleIntersection, topMargin]) + + return ( + <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> + ) +} +Visibility = React.memo(Visibility) + +export const List = memo(React.forwardRef(ListImpl)) as <ItemT>( + props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>}, +) => React.ReactElement + +const styles = StyleSheet.create({ + contentContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + containerScroll: { + width: '100%', + maxWidth: 600, + marginLeft: 'auto', + marginRight: 'auto', + }, + row: { + // @ts-ignore web only + contentVisibility: 'auto', + }, + minHeightViewport: { + // @ts-ignore web only + minHeight: '100vh', + }, + parentTreeVisibilityDetector: { + // @ts-ignore web only + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + aboveTheFoldDetector: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + // Bottom is dynamic. + }, + visibilityDetector: { + pointerEvents: 'none', + zIndex: -1, + }, +}) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 31a4ef0c8..2c90e33ff 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -1,11 +1,14 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useEffect} from 'react' +import EventEmitter from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' import {NativeScrollEvent} from 'react-native' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' -import {isWeb} from 'platform/detection' +import {isNative, isWeb} from 'platform/detection' import {useSharedValue, interpolate} from 'react-native-reanimated' +const WEB_HIDE_SHELL_THRESHOLD = 200 + function clamp(num: number, min: number, max: number) { 'worklet' return Math.min(Math.max(num, min), max) @@ -18,11 +21,22 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const startDragOffset = useSharedValue<number | null>(null) const startMode = useSharedValue<number | null>(null) + useEffect(() => { + if (isWeb) { + return listenToForcedWindowScroll(() => { + startDragOffset.value = null + startMode.value = null + }) + } + }) + const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' - startDragOffset.value = e.contentOffset.y - startMode.value = mode.value + if (isNative) { + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } }, [mode, startDragOffset, startMode], ) @@ -30,14 +44,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onEndDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' - 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 (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) + } } }, [startDragOffset, startMode, setMode, mode, headerHeight], @@ -46,41 +62,40 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onScroll = useCallback( (e: NativeScrollEvent) => { 'worklet' - if (startDragOffset.value === null || startMode.value === null) { - if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { - // If we're close enough to the top, always show the shell. - // Even if we're not dragging. - setMode(false) + if (isNative) { + if (startDragOffset.value === null || startMode.value === null) { + if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { + // If we're close enough to the top, always show the shell. + // Even if we're not dragging. + setMode(false) + } return } - if (isWeb) { - // On the web, there is no concept of "starting" the drag. - // When we get the first scroll event, we consider that the start. - startDragOffset.value = e.contentOffset.y - startMode.value = mode.value - } - return - } - // 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 dProgress = interpolate( - dy, - [-headerHeight.value, headerHeight.value], - [-1, 1], - ) - const newValue = clamp(startMode.value + dProgress, 0, 1) - if (newValue !== mode.value) { - // Manually adjust the value. This won't be (and shouldn't be) animated. - mode.value = newValue - } - if (isWeb) { - // On the web, there is no concept of "starting" the drag, - // so we don't have any specific anchor point to calculate the distance. - // Instead, update it continuosly along the way and diff with the last event. + // 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 dProgress = interpolate( + dy, + [-headerHeight.value, headerHeight.value], + [-1, 1], + ) + const newValue = clamp(startMode.value + dProgress, 0, 1) + if (newValue !== mode.value) { + // Manually adjust the value. This won't be (and shouldn't be) animated. + mode.value = newValue + } + } else { + // 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 - startMode.value = mode.value + + if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) { + setMode(false) + } else if (dy > 0) { + setMode(true) + } } }, [headerHeight, mode, setMode, startDragOffset, startMode], @@ -95,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { </ScrollProvider> ) } + +const emitter = new EventEmitter() + +if (isWeb) { + const originalScroll = window.scroll + window.scroll = function () { + emitter.emit('forced-scroll') + return originalScroll.apply(this, arguments as any) + } + + const originalScrollTo = window.scrollTo + window.scrollTo = function () { + emitter.emit('forced-scroll') + return originalScrollTo.apply(this, arguments as any) + } +} + +function listenToForcedWindowScroll(listener: () => void) { + emitter.addListener('forced-scroll', listener) + return () => { + emitter.removeListener('forced-scroll', listener) + } +} diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index 223a069c8..66e363cd4 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -2,6 +2,8 @@ import React, {createRef, useState, useMemo, useRef} from 'react' import {Animated, Pressable, StyleSheet, View} from 'react-native' import {Text} from './text/Text' import {usePalette} from 'lib/hooks/usePalette' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' interface Layout { x: number @@ -19,6 +21,7 @@ export function Selector({ panX: Animated.Value onSelect?: (index: number) => void }) { + const {_} = useLingui() const containerRef = useRef<View>(null) const pal = usePalette('default') const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( @@ -100,8 +103,8 @@ export function Selector({ testID={`selector-${i}`} key={item} onPress={() => onPressItem(i)} - accessibilityLabel={`Select ${item}`} - accessibilityHint={`Select option ${i} of ${numItems}`}> + accessibilityLabel={_(msg`Select ${item}`)} + accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}> <View style={styles.item} ref={itemRefs[i]}> <Text style={ diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx index e86e37565..814b2fb15 100644 --- a/src/view/com/util/SimpleViewHeader.tsx +++ b/src/view/com/util/SimpleViewHeader.tsx @@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' import {useSetDrawerOpen} from '#/state/shell' +import {isWeb} from '#/platform/detection' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -47,7 +48,14 @@ export function SimpleViewHeader({ const Container = isMobile ? View : CenteredView return ( - <Container style={[styles.header, isMobile && styles.headerMobile, style]}> + <Container + style={[ + styles.header, + isMobile && styles.headerMobile, + isWeb && styles.headerWeb, + pal.view, + style, + ]}> {showBackButton ? ( <TouchableOpacity testID="viewHeaderDrawerBtn" @@ -89,6 +97,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 10, }, + headerWeb: { + // @ts-ignore web-only + position: 'sticky', + top: 0, + zIndex: 1, + }, backBtn: { width: 30, height: 30, diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index beb67c30c..d5a843541 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { const styles = StyleSheet.create({ container: { - position: 'absolute', + // @ts-ignore web only + position: 'fixed', left: 20, bottom: 20, // @ts-ignore web only diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 082cae59c..1ccfcf56c 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -11,6 +11,8 @@ import {NavigationProp} from 'lib/routes/types' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import Animated from 'react-native-reanimated' import {useSetDrawerOpen} from '#/state/shell' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} @@ -32,6 +34,7 @@ export function ViewHeader({ renderButton?: () => JSX.Element }) { const pal = usePalette('default') + const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() @@ -75,9 +78,9 @@ export function ViewHeader({ hitSlop={BACK_HITSLOP} style={canGoBack ? styles.backBtn : styles.backBtnWide} accessibilityRole="button" - accessibilityLabel={canGoBack ? 'Back' : 'Menu'} + accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} accessibilityHint={ - canGoBack ? '' : 'Access navigation links and settings' + canGoBack ? '' : _(msg`Access navigation links and settings`) }> {canGoBack ? ( <FontAwesomeIcon diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index b4adbb557..a4238b8a4 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -53,7 +53,9 @@ export function ErrorMessage({ onPress={onPressTryAgain} accessibilityRole="button" accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries the last action, which errored out"> + accessibilityHint={_( + msg`Retries the last action, which errored out`, + )}> <FontAwesomeIcon icon="arrows-rotate" style={{color: theme.palette.error.icon}} diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index 4cd6dd4b4..45444331c 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -63,14 +63,16 @@ export function ErrorScreen({ style={[styles.btn]} onPress={onPressTryAgain} accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries the last action, which errored out"> + accessibilityHint={_( + msg`Retries the last action, which errored out`, + )}> <FontAwesomeIcon icon="arrows-rotate" style={pal.link as FontAwesomeIconStyle} size={16} /> <Text type="button" style={[styles.btnText, pal.link]}> - <Trans>Try again</Trans> + <Trans context="action">Try again</Trans> </Text> </Button> </View> diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 9787d92fb..27a16117b 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {clamp} from 'lib/numbers' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {isWeb} from '#/platform/detection' import Animated from 'react-native-reanimated' export interface FABProps @@ -64,7 +65,8 @@ const styles = StyleSheet.create({ borderRadius: 35, }, outer: { - position: 'absolute', + // @ts-ignore web-only + position: isWeb ? 'fixed' : 'absolute', zIndex: 1, }, inner: { diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx index 4aa5cb610..c5f0afc8f 100644 --- a/src/view/com/util/forms/DateInput.tsx +++ b/src/view/com/util/forms/DateInput.tsx @@ -13,6 +13,9 @@ import {Text} from '../text/Text' import {TypographyVariant} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {getLocales} from 'expo-localization' + +const LOCALE = getLocales()[0] interface Props { testID?: string @@ -25,6 +28,7 @@ interface Props { accessibilityLabel: string accessibilityHint: string accessibilityLabelledBy?: string + handleAsUTC?: boolean } export function DateInput(props: Props) { @@ -32,6 +36,12 @@ export function DateInput(props: Props) { const theme = useTheme() const pal = usePalette('default') + const formatter = React.useMemo(() => { + return new Intl.DateTimeFormat(LOCALE.languageTag, { + timeZone: props.handleAsUTC ? 'UTC' : undefined, + }) + }, [props.handleAsUTC]) + const onChangeInternal = useCallback( (event: DateTimePickerEvent, date: Date | undefined) => { setShow(false) @@ -64,7 +74,7 @@ export function DateInput(props: Props) { <Text type={props.buttonLabelType} style={[pal.text, props.buttonLabelStyle]}> - {props.value.toLocaleDateString()} + {formatter.format(props.value)} </Text> </View> </Button> @@ -73,6 +83,7 @@ export function DateInput(props: Props) { <DateTimePicker testID={props.testID ? `${props.testID}-datepicker` : undefined} mode="date" + timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined} display="spinner" // @ts-ignore applies in iOS only -prf themeVariant={theme.colorScheme} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index ad8f50f5e..411b77484 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -75,6 +75,8 @@ export function DropdownButton({ bottomOffset = 0, accessibilityLabel, }: PropsWithChildren<DropdownButtonProps>) { + const {_} = useLingui() + const ref1 = useRef<TouchableOpacity>(null) const ref2 = useRef<View>(null) @@ -141,7 +143,9 @@ export function DropdownButton({ hitSlop={HITSLOP_10} ref={ref1} accessibilityRole="button" - accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`} + accessibilityLabel={ + accessibilityLabel || _(msg`Opens ${numItems} options`) + } accessibilityHint=""> {children} </TouchableOpacity> @@ -247,7 +251,7 @@ const DropdownItems = ({ onPress={() => onPressItem(index)} accessibilityRole="button" accessibilityLabel={item.label} - accessibilityHint={`Option ${index + 1} of ${numItems}`}> + accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}> {item.icon && ( <FontAwesomeIcon style={styles.icon} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx new file mode 100644 index 000000000..9e9888ad8 --- /dev/null +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {Pressable, StyleSheet, View, Text} from 'react-native' +import {IconProp} from '@fortawesome/fontawesome-svg-core' +import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {HITSLOP_10} from 'lib/constants' + +// Custom Dropdown Menu Components +// == +export const DropdownMenuRoot = DropdownMenu.Root +export const DropdownMenuContent = DropdownMenu.Content + +type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']> +export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { + const theme = useTheme() + const [focused, setFocused] = React.useState(false) + const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001' + + return ( + <DropdownMenu.Item + {...props} + style={StyleSheet.flatten([ + styles.item, + focused && {backgroundColor: backgroundColor}, + ])} + onFocus={() => { + setFocused(true) + }} + onBlur={() => { + setFocused(false) + }} + /> + ) +} + +// Types for Dropdown Menu and Items +export type DropdownItem = { + label: string | 'separator' + onPress?: () => void + testID?: string + icon?: { + ios: MenuItemCommonProps['ios'] + android: string + web: IconProp + } +} +type Props = { + items: DropdownItem[] + testID?: string + accessibilityLabel?: string + accessibilityHint?: string +} + +export function NativeDropdown({ + items, + children, + testID, + accessibilityLabel, + accessibilityHint, +}: 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) { + const t = e.target + + if (!open) return + if (!t) return + if (!buttonRef.current || !menuRef.current) return + + if ( + t !== buttonRef.current && + !buttonRef.current.contains(t as Node) && + t !== menuRef.current && + !menuRef.current.contains(t as Node) + ) { + // prevent clicking through to links beneath dropdown + // only applies to mobile web + e.preventDefault() + e.stopPropagation() + + // close menu + setOpen(false) + } + } + + function keydownHandler(e: KeyboardEvent) { + if (e.key === 'Escape' && open) { + setOpen(false) + } + } + + document.addEventListener('click', clickHandler, true) + window.addEventListener('keydown', keydownHandler, true) + return () => { + document.removeEventListener('click', clickHandler, true) + window.removeEventListener('keydown', keydownHandler, true) + } + }, [open, setOpen]) + + return ( + <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}> + <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}> + <Pressable + ref={buttonRef as unknown as React.Ref<View>} + testID={testID} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint} + onPress={() => setOpen(o => !o)} + hitSlop={HITSLOP_10}> + {children} + </Pressable> + </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 ( + <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> + </DropdownMenu.Portal> + </DropdownMenuRoot> + ) +} + +const getKey = (label: string, index: number, id?: string) => { + if (id) { + return id + } + return `${label}_${index}` +} + +const styles = StyleSheet.create({ + separator: { + height: 1, + marginTop: 4, + marginBottom: 4, + }, + content: { + backgroundColor: '#f0f0f0', + borderRadius: 8, + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 4, + paddingRight: 4, + marginTop: 6, + + // @ts-ignore web only -prf + boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px', + }, + item: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + columnGap: 20, + // @ts-ignore -web + cursor: 'pointer', + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 12, + paddingRight: 12, + borderRadius: 8, + }, + itemTitle: { + fontSize: 16, + fontWeight: '500', + paddingRight: 10, + }, +}) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1f2e067c2..b21caf2e7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -2,7 +2,12 @@ import React, {memo} from 'react' import {Linking, StyleProp, View, ViewStyle} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyFeedPost, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' import {toShareUrl} from 'lib/strings/url-helpers' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' @@ -24,6 +29,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' import {isWeb} from '#/platform/detection' +import {richTextToString} from '#/lib/strings/rich-text-helpers' let PostDropdownBtn = ({ testID, @@ -31,6 +37,7 @@ let PostDropdownBtn = ({ postCid, postUri, record, + richText, style, showAppealLabelItem, }: { @@ -39,6 +46,7 @@ let PostDropdownBtn = ({ postCid: string postUri: string record: AppBskyFeedPost.Record + richText: RichTextAPI style?: StyleProp<ViewStyle> showAppealLabelItem?: boolean }): React.ReactNode => { @@ -71,32 +79,36 @@ let PostDropdownBtn = ({ const onDeletePost = React.useCallback(() => { postDeleteMutation.mutateAsync({uri: postUri}).then( () => { - Toast.show('Post deleted') + Toast.show(_(msg`Post deleted`)) }, e => { logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') + Toast.show(_(msg`Failed to delete post, please try again`)) }, ) - }, [postUri, postDeleteMutation]) + }, [postUri, postDeleteMutation, _]) const onToggleThreadMute = React.useCallback(() => { try { const muted = toggleThreadMute(rootUri) if (muted) { - Toast.show('You will no longer receive notifications for this thread') + Toast.show( + _(msg`You will no longer receive notifications for this thread`), + ) } else { - Toast.show('You will now receive notifications for this thread') + Toast.show(_(msg`You will now receive notifications for this thread`)) } } catch (e) { logger.error('Failed to toggle thread mute', {error: e}) } - }, [rootUri, toggleThreadMute]) + }, [rootUri, toggleThreadMute, _]) const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) + const str = richTextToString(richText, true) + + Clipboard.setString(str) + Toast.show(_(msg`Copied to clipboard`)) + }, [_, richText]) const onOpenTranslate = React.useCallback(() => { Linking.openURL(translatorUrl) @@ -253,7 +265,7 @@ let PostDropdownBtn = ({ <NativeDropdown testID={testID} items={dropdownItems} - accessibilityLabel="More post options" + accessibilityLabel={_(msg`More post options`)} accessibilityHint=""> <View style={style}> <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx index 02b462b55..a78d23c9b 100644 --- a/src/view/com/util/forms/SearchInput.tsx +++ b/src/view/com/util/forms/SearchInput.tsx @@ -11,6 +11,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {HITSLOP_10} from 'lib/constants' import {MagnifyingGlassIcon} from 'lib/icons' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' @@ -49,7 +50,7 @@ export function SearchInput({ <TextInput testID="searchTextInput" ref={textInput} - placeholder="Search" + placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} selectTextOnFocus returnKeyType="search" @@ -71,7 +72,8 @@ export function SearchInput({ onPress={onPressCancelSearchInner} accessibilityRole="button" accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint=""> + accessibilityHint="" + hitSlop={HITSLOP_10}> <FontAwesomeIcon icon="xmark" size={16} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 6f203bf06..61cb6f69f 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -4,6 +4,8 @@ import {Image} from 'expo-image' import {clamp} from 'lib/numbers' import {Dimensions} from 'lib/media/types' import * as imageSizes from 'lib/media/image-sizes' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const MIN_ASPECT_RATIO = 0.33 // 1/3 const MAX_ASPECT_RATIO = 10 // 10/1 @@ -29,6 +31,7 @@ export function AutoSizedImage({ style, children = null, }: Props) { + const {_} = useLingui() const [dim, setDim] = React.useState<Dimensions | undefined>( dimensionsHint || imageSizes.get(uri), ) @@ -64,7 +67,7 @@ export function AutoSizedImage({ accessible={true} // Must set for `accessibilityLabel` to work accessibilityIgnoresInvertColors accessibilityLabel={alt} - accessibilityHint="Tap to view fully" + accessibilityHint={_(msg`Tap to view fully`)} /> {children} </Pressable> diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 094b0c56c..e7110372c 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -2,6 +2,8 @@ import {AppBskyEmbedImages} from '@atproto/api' import React, {ComponentProps, FC} from 'react' import {StyleSheet, Text, Pressable, View} from 'react-native' import {Image} from 'expo-image' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type EventFunction = (index: number) => void @@ -22,6 +24,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onPressIn, onLongPress, }) => { + const {_} = useLingui() const image = images[index] return ( <View style={styles.fullWidth}> @@ -31,7 +34,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({ onLongPress={onLongPress ? () => onLongPress(index) : undefined} style={styles.fullWidth} accessibilityRole="button" - accessibilityLabel={image.alt || 'Image'} + accessibilityLabel={image.alt || _(msg`Image`)} accessibilityHint=""> <Image source={{uri: image.thumb}} diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 1269b7ebf..b3a563116 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -63,7 +63,9 @@ export function ContentHider({ } }} accessibilityRole="button" - accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } accessibilityLabel="" style={[ styles.cover, @@ -92,7 +94,7 @@ export function ContentHider({ <ShieldExclamation size={18} style={pal.textLight} /> )} </Pressable> - <Text type="md" style={pal.text}> + <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}> {desc.name} </Text> <View style={styles.showBtn}> @@ -129,7 +131,7 @@ const styles = StyleSheet.create({ cover: { flexDirection: 'row', alignItems: 'center', - gap: 4, + gap: 6, borderRadius: 8, marginTop: 4, paddingVertical: 14, diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index bffb7ea1a..b1fa71d4a 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -9,7 +9,7 @@ import {addStyle} from 'lib/styles' import {describeModerationCause} from 'lib/moderation' import {ShieldExclamation} from 'lib/icons' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import {Trans, msg} from '@lingui/macro' import {useModalControls} from '#/state/modals' interface Props extends ComponentProps<typeof Link> { @@ -57,7 +57,9 @@ export function PostHider({ } }} accessibilityRole="button" - accessibilityHint={override ? 'Hide the content' : 'Show the content'} + accessibilityHint={ + override ? _(msg`Hide the content`) : _(msg`Show the content`) + } accessibilityLabel="" style={[ styles.description, @@ -103,7 +105,7 @@ export function PostHider({ </Text> {!moderation.noOverride && ( <Text type="sm" style={[styles.showBtn, pal.link]}> - {override ? 'Hide' : 'Show'} + {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>} </Text> )} </Pressable> diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index a50b52175..50ef8a875 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,7 +6,11 @@ import { View, ViewStyle, } from 'react-native' -import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + RichText as RichTextAPI, +} from '@atproto/api' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' @@ -26,11 +30,14 @@ import { import {useComposerControls} from '#/state/shell/composer' import {Shadow} from '#/state/cache/types' import {useRequireAuth} from '#/state/session' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' let PostCtrls = ({ big, post, record, + richText, showAppealLabelItem, style, onPressReply, @@ -38,11 +45,13 @@ let PostCtrls = ({ big?: boolean post: Shadow<AppBskyFeedDefs.PostView> record: AppBskyFeedPost.Record + richText: RichTextAPI showAppealLabelItem?: boolean style?: StyleProp<ViewStyle> onPressReply: () => void }): React.ReactNode => { const theme = useTheme() + const {_} = useLingui() const {openComposer} = useComposerControls() const {closeModal} = useModalControls() const postLikeMutation = usePostLikeMutation() @@ -176,9 +185,9 @@ let PostCtrls = ({ requireAuth(() => onPressToggleLike()) }} accessibilityRole="button" - accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${ - post.likeCount - } ${pluralize(post.likeCount || 0, 'like')})`} + accessibilityLabel={`${ + post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`) + } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> {post.viewer?.like ? ( @@ -209,6 +218,7 @@ let PostCtrls = ({ postCid={post.cid} postUri={post.uri} record={record} + richText={richText} showAppealLabelItem={showAppealLabelItem} style={styles.ctrlPad} /> diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 620852d8e..d45bf1d87 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -8,6 +8,8 @@ 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 {useLingui} from '@lingui/react' interface Props { isReposted: boolean @@ -25,6 +27,7 @@ let RepostButton = ({ onQuote, }: Props): React.ReactNode => { const theme = useTheme() + const {_} = useLingui() const {openModal} = useModalControls() const requireAuth = useRequireAuth() @@ -53,7 +56,9 @@ let RepostButton = ({ style={[styles.control, !big && styles.controlPad]} accessibilityRole="button" accessibilityLabel={`${ - isReposted ? 'Undo repost' : 'Repost' + isReposted + ? _(msg`Undo repost`) + : _(msg({message: 'Repost', context: 'action'})) } (${repostCount} ${pluralize(repostCount || 0, 'repost')})`} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx new file mode 100644 index 000000000..f06c8b794 --- /dev/null +++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx @@ -0,0 +1,170 @@ +import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player' +import React from 'react' +import {Image, ImageLoadEventData} from 'expo-image' +import { + ActivityIndicator, + GestureResponderEvent, + LayoutChangeEvent, + Pressable, + StyleSheet, + View, +} from 'react-native' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {AppBskyEmbedExternal} from '@atproto/api' + +export function ExternalGifEmbed({ + link, + params, +}: { + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams +}) { + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() + const {_} = useLingui() + + 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) + + const load = React.useCallback(() => { + setIsPlayerActive(true) + Image.prefetch(params.playerUri).then(() => { + // Replace the image once it's fetched + setIsPrefetched(true) + }) + }, [params.playerUri]) + + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Don't propagate on web + event.preventDefault() + + // Show consent if this is the first load + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: load, + }) + return + } + // If the player isn't active, we want to activate it and prefetch the gif + if (!isPlayerActive) { + load() + return + } + // Control animation on native + setIsAnimating(prev => { + if (prev) { + if (isNative) { + imageRef.current?.stopAnimating() + } + return false + } else { + if (isNative) { + imageRef.current?.startAnimating() + } + return true + } + }) + }, + [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source], + ) + + 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 ( + <Pressable + style={[ + {height: imageDims.height}, + styles.topRadius, + styles.gifContainer, + ]} + onPress={onPlayPress} + onLayout={onLayout} + accessibilityRole="button" + accessibilityHint={_(msg`Plays the GIF`)} + accessibilityLabel={_(msg`Play ${link.title}`)}> + {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay + <View style={[styles.layer, styles.overlayLayer]}> + <View style={[styles.overlayContainer, styles.topRadius]}> + {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active + <FontAwesomeIcon icon="play" size={42} color="white" /> + ) : ( + // Activity indicator while gif loads + <ActivityIndicator size="large" color="white" /> + )} + </View> + </View> + )} + <Image + source={{ + uri: + !isPrefetched || (isWeb && !isAnimating) + ? link.thumb + : params.playerUri, + }} // Web uses the thumb to control playback + style={{flex: 1}} + ref={imageRef} + onLoad={onLoad} + autoplay={isAnimating} + contentFit="contain" + accessibilityIgnoresInvertColors + accessibilityLabel={link.title} + accessibilityHint={link.title} + cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios + /> + </Pressable> + ) +} + +const styles = StyleSheet.create({ + topRadius: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + }, + layer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + overlayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + overlayLayer: { + zIndex: 2, + }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, +}) diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 27aa804d3..aaa98a41f 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api' import {toNiceDomain} from 'lib/strings/url-helpers' import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed' +import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed' +import {useExternalEmbedsPrefs} from 'state/preferences' export const ExternalLinkEmbed = ({ link, @@ -16,69 +18,47 @@ export const ExternalLinkEmbed = ({ }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const externalEmbedPrefs = useExternalEmbedsPrefs() - const embedPlayerParams = React.useMemo( - () => parseEmbedPlayerFromUrl(link.uri), - [link.uri], - ) + const embedPlayerParams = React.useMemo(() => { + const params = parseEmbedPlayerFromUrl(link.uri) + + if (params && externalEmbedPrefs?.[params.source] !== 'hide') { + return params + } + }, [link.uri, externalEmbedPrefs]) return ( - <View - style={{ - flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column', - }}> + <View style={styles.container}> {link.thumb && !embedPlayerParams ? ( - <View - style={ - !isMobile - ? { - borderTopLeftRadius: 6, - borderBottomLeftRadius: 6, - width: 120, - aspectRatio: 1, - overflow: 'hidden', - } - : { - borderTopLeftRadius: 6, - borderTopRightRadius: 6, - width: '100%', - height: 200, - overflow: 'hidden', - } - }> - <Image - style={styles.extImage} - source={{uri: link.thumb}} - accessibilityIgnoresInvertColors - /> - </View> + <Image + style={{aspectRatio: 1.91}} + source={{uri: link.thumb}} + accessibilityIgnoresInvertColors + /> ) : undefined} - {embedPlayerParams && ( - <ExternalPlayer link={link} params={embedPlayerParams} /> - )} - <View - style={{ - paddingHorizontal: isMobile ? 10 : 14, - paddingTop: 8, - paddingBottom: 10, - flex: !isMobile ? 1 : undefined, - }}> + {(embedPlayerParams?.isGif && ( + <ExternalGifEmbed link={link} params={embedPlayerParams} /> + )) || + (embedPlayerParams && ( + <ExternalPlayer link={link} params={embedPlayerParams} /> + ))} + <View style={[styles.info, {paddingHorizontal: isMobile ? 10 : 14}]}> <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.extUri]}> {toNiceDomain(link.uri)} </Text> - <Text - type="lg-bold" - numberOfLines={isMobile ? 4 : 2} - style={[pal.text]}> - {link.title || link.uri} - </Text> - {link.description ? ( + {!embedPlayerParams?.isGif && ( + <Text type="lg-bold" numberOfLines={3} style={[pal.text]}> + {link.title || link.uri} + </Text> + )} + {link.description && !embedPlayerParams?.hideDetails ? ( <Text type="md" - numberOfLines={isMobile ? 4 : 2} + numberOfLines={link.thumb ? 2 : 4} style={[pal.text, styles.extDescription]}> {link.description} </Text> @@ -89,9 +69,16 @@ export const ExternalLinkEmbed = ({ } const styles = StyleSheet.create({ - extImage: { + container: { + flexDirection: 'column', + borderRadius: 6, + overflow: 'hidden', + }, + info: { width: '100%', - height: 200, + bottom: 0, + paddingTop: 8, + paddingBottom: 10, }, extUri: { marginTop: 2, diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx index 580cf363a..8b0858b69 100644 --- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx @@ -1,22 +1,32 @@ import React from 'react' import { ActivityIndicator, - Dimensions, GestureResponderEvent, Pressable, StyleSheet, + useWindowDimensions, View, } from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, +} from 'react-native-reanimated' import {Image} from 'expo-image' import {WebView} from 'react-native-webview' -import YoutubePlayer from 'react-native-youtube-iframe' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {AppBskyEmbedExternal} from '@atproto/api' import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player' import {EventStopper} from '../EventStopper' -import {AppBskyEmbedExternal} from '@atproto/api' import {isNative} from 'platform/detection' -import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' +import {useExternalEmbedsPrefs} from 'state/preferences' +import {useModalControls} from 'state/modals' interface ShouldStartLoadRequest { url: string @@ -32,6 +42,8 @@ function PlaceholderOverlay({ isPlayerActive: boolean onPress: (event: GestureResponderEvent) => void }) { + const {_} = useLingui() + // If the player is active and not loading, we don't want to show the overlay. if (isPlayerActive && !isLoading) return null @@ -39,8 +51,8 @@ function PlaceholderOverlay({ <View style={[styles.layer, styles.overlayLayer]}> <Pressable accessibilityRole="button" - accessibilityLabel="Play Video" - accessibilityHint="" + accessibilityLabel={_(msg`Play Video`)} + accessibilityHint={_(msg`Play Video`)} onPress={onPress} style={[styles.overlayContainer, styles.topRadius]}> {!isPlayerActive ? ( @@ -77,31 +89,21 @@ function Player({ return ( <View style={[styles.layer, styles.playerLayer]}> <EventStopper> - {isNative && params.type === 'youtube_video' ? ( - <YoutubePlayer - videoId={params.videoId} - play - height={height} - onReady={onLoad} - webViewStyle={[styles.webview, styles.topRadius]} + <View style={{height, width: '100%'}}> + <WebView + javaScriptEnabled={true} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + mediaPlaybackRequiresUserAction={false} + allowsInlineMediaPlayback + bounces={false} + allowsFullscreenVideo + nestedScrollEnabled + source={{uri: params.playerUri}} + onLoad={onLoad} + setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) + style={[styles.webview, styles.topRadius]} /> - ) : ( - <View style={{height, width: '100%'}}> - <WebView - javaScriptEnabled={true} - onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} - mediaPlaybackRequiresUserAction={false} - allowsInlineMediaPlayback - bounces={false} - allowsFullscreenVideo - nestedScrollEnabled - source={{uri: params.playerUri}} - onLoad={onLoad} - setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads) - style={[styles.webview, styles.topRadius]} - /> - </View> - )} + </View> </EventStopper> </View> ) @@ -116,6 +118,10 @@ export function ExternalPlayer({ params: EmbedPlayerParams }) { const navigation = useNavigation<NavigationProp>() + const insets = useSafeAreaInsets() + const windowDims = useWindowDimensions() + const externalEmbedsPrefs = useExternalEmbedsPrefs() + const {openModal} = useModalControls() const [isPlayerActive, setPlayerActive] = React.useState(false) const [isLoading, setIsLoading] = React.useState(true) @@ -124,34 +130,51 @@ export function ExternalPlayer({ height: 0, }) - const viewRef = React.useRef<View>(null) + const viewRef = useAnimatedRef() + + const frameCallback = useFrameCallback(() => { + const measurement = measure(viewRef) + if (!measurement) return + + const {height: winHeight, width: winWidth} = windowDims + + // Get the proper screen height depending on what is going on + const realWinHeight = isNative // If it is native, we always want the larger number + ? winHeight > winWidth + ? winHeight + : winWidth + : winHeight // On web, we always want the actual screen height + + const top = measurement.pageY + const bot = measurement.pageY + measurement.height + + // We can use the same logic on all platforms against the screenHeight that we get above + const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top + + if (!isVisible) { + runOnJS(setPlayerActive)(false) + } + }, false) // False here disables autostarting the callback // watch for leaving the viewport due to scrolling React.useEffect(() => { + // We don't want to do anything if the player isn't active + if (!isPlayerActive) return + // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will // continue playing. We need to watch for the blur event const unsubscribe = navigation.addListener('blur', () => { setPlayerActive(false) }) - const interval = setInterval(() => { - viewRef.current?.measure((x, y, w, h, pageX, pageY) => { - const window = Dimensions.get('window') - const top = pageY - const bot = pageY + h - const isVisible = isNative - ? top >= 0 && bot <= window.height - : !(top >= window.height || bot <= 0) - if (!isVisible) { - setPlayerActive(false) - } - }) - }, 1e3) + // Start watching for changes + frameCallback.setActive(true) + return () => { unsubscribe() - clearInterval(interval) + frameCallback.setActive(false) } - }, [viewRef, navigation]) + }, [navigation, isPlayerActive, frameCallback]) // calculate height for the player and the screen size const height = React.useMemo( @@ -168,12 +191,26 @@ export function ExternalPlayer({ setIsLoading(false) }, []) - const onPlayPress = React.useCallback((event: GestureResponderEvent) => { - // Prevent this from propagating upward on web - event.preventDefault() + const onPlayPress = React.useCallback( + (event: GestureResponderEvent) => { + // Prevent this from propagating upward on web + event.preventDefault() - setPlayerActive(true) - }, []) + if (externalEmbedsPrefs?.[params.source] === undefined) { + openModal({ + name: 'embed-consent', + source: params.source, + onAccept: () => { + setPlayerActive(true) + }, + }) + return + } + + setPlayerActive(true) + }, + [externalEmbedsPrefs, openModal, params.source], + ) // measure the layout to set sizing const onLayout = React.useCallback( @@ -187,7 +224,7 @@ export function ExternalPlayer({ ) return ( - <View + <Animated.View ref={viewRef} style={{height}} collapsable={false} @@ -205,7 +242,6 @@ export function ExternalPlayer({ accessibilityIgnoresInvertColors /> )} - <PlaceholderOverlay isLoading={isLoading} isPlayerActive={isPlayerActive} @@ -217,7 +253,7 @@ export function ExternalPlayer({ height={height} onLoad={onLoad} /> - </View> + </Animated.View> ) } @@ -248,4 +284,8 @@ const styles = StyleSheet.create({ webview: { backgroundColor: 'transparent', }, + gifContainer: { + width: '100%', + overflow: 'hidden', + }, }) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e793f983e..256817bba 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -6,6 +6,8 @@ import { AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, ModerationUI, + AppBskyEmbedExternal, + RichText as RichTextAPI, } from '@atproto/api' import {AtUri} from '@atproto/api' import {PostMeta} from '../PostMeta' @@ -17,6 +19,8 @@ import {PostEmbeds} from '.' import {PostAlerts} from '../moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' import {InfoCircleIcon} from 'lib/icons' +import {Trans} from '@lingui/macro' +import {RichText} from 'view/com/util/text/RichText' export function MaybeQuoteEmbed({ embed, @@ -41,6 +45,7 @@ export function MaybeQuoteEmbed({ uri: embed.record.uri, indexedAt: embed.record.indexedAt, text: embed.record.value.text, + facets: embed.record.value.facets, embeds: embed.record.embeds, }} moderation={moderation} @@ -52,7 +57,7 @@ export function MaybeQuoteEmbed({ <View style={[styles.errorContainer, pal.borderDark]}> <InfoCircleIcon size={18} style={pal.text} /> <Text type="lg" style={pal.text}> - Blocked + <Trans>Blocked</Trans> </Text> </View> ) @@ -61,7 +66,7 @@ export function MaybeQuoteEmbed({ <View style={[styles.errorContainer, pal.borderDark]}> <InfoCircleIcon size={18} style={pal.text} /> <Text type="lg" style={pal.text}> - Deleted + <Trans>Deleted</Trans> </Text> </View> ) @@ -82,22 +87,30 @@ export function QuoteEmbed({ const itemUrip = new AtUri(quote.uri) const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) const itemTitle = `Post by ${quote.author.handle}` - const isEmpty = React.useMemo( - () => quote.text.trim().length === 0, - [quote.text], - ) - const imagesEmbed = React.useMemo( + const richText = React.useMemo( () => - quote.embeds?.find( - embed => - AppBskyEmbedImages.isView(embed) || - AppBskyEmbedRecordWithMedia.isView(embed), - ), - [quote.embeds], + quote.text.trim() + ? new RichTextAPI({text: quote.text, facets: quote.facets}) + : undefined, + [quote.text, quote.facets], ) + const embed = React.useMemo(() => { + const e = quote.embeds?.[0] + + if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) { + return e + } else if ( + AppBskyEmbedRecordWithMedia.isView(e) && + (AppBskyEmbedImages.isView(e.media) || + AppBskyEmbedExternal.isView(e.media)) + ) { + return e.media + } + }, [quote.embeds]) return ( <Link style={[styles.container, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}} href={itemHref} title={itemTitle}> <PostMeta @@ -110,17 +123,16 @@ export function QuoteEmbed({ {moderation ? ( <PostAlerts moderation={moderation} style={styles.alert} /> ) : null} - {!isEmpty ? ( - <Text type="post-text" style={pal.text} numberOfLines={6}> - {quote.text} - </Text> + {richText ? ( + <RichText + richText={richText} + type="post-text" + style={pal.text} + numberOfLines={20} + noLinks + /> ) : null} - {AppBskyEmbedImages.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed} moderation={{}} /> - )} - {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && ( - <PostEmbeds embed={imagesEmbed.media} moderation={{}} /> - )} + {embed && <PostEmbeds embed={embed} moderation={{}} />} </Link> ) } diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index c94ce9684..6f168a293 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -22,7 +22,6 @@ import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' @@ -51,7 +50,6 @@ export function PostEmbeds({ }) { const pal = usePalette('default') const {openLightbox} = useLightboxControls() - const {isMobile} = useWebMediaQueries() // quote post with media // = @@ -63,7 +61,7 @@ export function PostEmbeds({ const mediaModeration = isModOnQuote ? {} : moderation const quoteModeration = isModOnQuote ? moderation : {} return ( - <View style={[styles.stackContainer, style]}> + <View style={style}> <PostEmbeds embed={embed.media} moderation={mediaModeration} /> <ContentHider moderation={quoteModeration}> <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} /> @@ -129,10 +127,7 @@ export function PostEmbeds({ dimensionsHint={aspectRatio} onPress={() => _openLightbox(0)} onPressIn={() => onPressIn(0)} - style={[ - styles.singleImage, - isMobile && styles.singleImageMobile, - ]}> + style={[styles.singleImage]}> {alt === '' ? null : ( <View style={styles.altContainer}> <Text style={styles.alt} accessible={false}> @@ -151,11 +146,7 @@ export function PostEmbeds({ images={embed.images} onPress={_openLightbox} onPressIn={onPressIn} - style={ - embed.images.length === 1 - ? [styles.singleImage, isMobile && styles.singleImageMobile] - : undefined - } + style={embed.images.length === 1 ? [styles.singleImage] : undefined} /> </View> ) @@ -168,11 +159,14 @@ export function PostEmbeds({ const link = embed.external return ( - <View style={[styles.extOuter, pal.view, pal.border, style]}> - <Link asAnchor href={link.uri}> - <ExternalLinkEmbed link={link} /> - </Link> - </View> + <Link + asAnchor + anchorNoUnderline + href={link.uri} + style={[styles.extOuter, pal.view, pal.borderDark, style]} + hoverStyle={{borderColor: pal.colors.borderLinkHover}}> + <ExternalLinkEmbed link={link} /> + </Link> ) } @@ -180,18 +174,11 @@ export function PostEmbeds({ } const styles = StyleSheet.create({ - stackContainer: { - gap: 6, - }, imagesContainer: { marginTop: 8, }, singleImage: { borderRadius: 8, - maxHeight: 1000, - }, - singleImageMobile: { - maxHeight: 500, }, extOuter: { borderWidth: 1, diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 99062e848..e910127fe 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -17,6 +17,8 @@ export function RichText({ lineHeight = 1.2, style, numberOfLines, + selectable, + noLinks, }: { testID?: string type?: TypographyVariant @@ -24,6 +26,8 @@ export function RichText({ lineHeight?: number style?: StyleProp<TextStyle> numberOfLines?: number + selectable?: boolean + noLinks?: boolean }) { const theme = useTheme() const pal = usePalette('default') @@ -42,7 +46,11 @@ export function RichText({ } return ( // @ts-ignore web only -prf - <Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}> + <Text + testID={testID} + style={[style, pal.text]} + dataSet={WORD_WRAP} + selectable={selectable}> {text} </Text> ) @@ -54,7 +62,8 @@ export function RichText({ style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines} // @ts-ignore web only -prf - dataSet={WORD_WRAP}> + dataSet={WORD_WRAP} + selectable={selectable}> {text} </Text> ) @@ -70,7 +79,11 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention - if (mention && AppBskyRichtextFacet.validateMention(mention).success) { + if ( + !noLinks && + mention && + AppBskyRichtextFacet.validateMention(mention).success + ) { els.push( <TextLink key={key} @@ -79,20 +92,26 @@ export function RichText({ href={`/profile/${mention.did}`} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} + selectable={selectable} />, ) } else if (link && AppBskyRichtextFacet.validateLink(link).success) { - els.push( - <TextLink - key={key} - type={type} - text={toShortUrl(segment.text)} - href={link.uri} - style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} - dataSet={WORD_WRAP} - warnOnMismatchingLabel - />, - ) + 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} + warnOnMismatchingLabel + selectable={selectable} + />, + ) + } } else { els.push(segment.text) } @@ -105,7 +124,8 @@ export function RichText({ style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines} // @ts-ignore web only -prf - dataSet={WORD_WRAP}> + dataSet={WORD_WRAP} + selectable={selectable}> {els} </Text> ) diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index ea97d59fe..ccb51bfca 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -2,12 +2,15 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' import {s, lh} from 'lib/styles' import {useTheme, TypographyVariant} from 'lib/ThemeContext' +import {isIOS} from 'platform/detection' +import {UITextView} from 'react-native-ui-text-view' export type CustomTextProps = TextProps & { type?: TypographyVariant lineHeight?: number title?: string dataSet?: Record<string, string | number> + selectable?: boolean } export function Text({ @@ -17,16 +20,29 @@ export function Text({ style, title, dataSet, + selectable, ...props }: React.PropsWithChildren<CustomTextProps>) { const theme = useTheme() const typography = theme.typography[type] const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined + + if (selectable && isIOS) { + return ( + <UITextView + style={[s.black, typography, lineHeightStyle, style]} + {...props}> + {children} + </UITextView> + ) + } + return ( <RNText style={[s.black, typography, lineHeightStyle, style]} // @ts-ignore web only -esb dataSet={Object.assign({tooltip: title}, dataSet || {})} + selectable={selectable} {...props}> {children} </RNText> |