diff options
Diffstat (limited to 'src/view')
31 files changed, 1010 insertions, 397 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 0ac4ac56e..f472bb2e2 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -59,6 +59,7 @@ import * as Toast from '../util/Toast' import {UserAvatar} from '../util/UserAvatar' import {CharProgress} from './char-progress/CharProgress' import {ExternalEmbed} from './ExternalEmbed' +import {GifAltText} from './GifAltText' import {LabelsBtn} from './labels/LabelsBtn' import {Gallery} from './photos/Gallery' import {OpenCameraBtn} from './photos/OpenCameraBtn' @@ -327,7 +328,7 @@ export const ComposePost = observer(function ComposePost({ image: gif.media_formats.preview.url, likelyType: LikelyType.HTML, title: gif.content_description, - description: `ALT: ${gif.content_description}`, + description: '', }, }) setExtGif(gif) @@ -335,6 +336,26 @@ export const ComposePost = observer(function ComposePost({ [setExtLink], ) + const handleChangeGifAltText = useCallback( + (altText: string) => { + setExtLink(ext => + ext && ext.meta + ? { + ...ext, + meta: { + ...ext?.meta, + description: + altText.trim().length === 0 + ? '' + : `Alt text: ${altText.trim()}`, + }, + } + : ext, + ) + }, + [setExtLink], + ) + return ( <KeyboardAvoidingView testID="composePostView" @@ -474,14 +495,21 @@ export const ComposePost = observer(function ComposePost({ <Gallery gallery={gallery} /> {gallery.isEmpty && extLink && ( - <ExternalEmbed - link={extLink} - gif={extGif} - onRemove={() => { - setExtLink(undefined) - setExtGif(undefined) - }} - /> + <View style={a.relative}> + <ExternalEmbed + link={extLink} + gif={extGif} + onRemove={() => { + setExtLink(undefined) + setExtGif(undefined) + }} + /> + <GifAltText + link={extLink} + gif={extGif} + onSubmit={handleChangeGifAltText} + /> + </View> )} {quote ? ( <View style={[s.mt5, isWeb && s.mb10]}> diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 321e29b30..b81065e99 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -46,7 +46,12 @@ export const ExternalEmbed = ({ : undefined return ( - <View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}> + <View + style={[ + !gif && a.mb_xl, + a.overflow_hidden, + t.atoms.border_contrast_medium, + ]}> {link.isLoading ? ( <Container style={loadingStyle}> <Loader size="xl" /> @@ -62,7 +67,7 @@ export const ExternalEmbed = ({ </Container> ) : linkInfo ? ( <View style={{pointerEvents: !gif ? 'none' : 'auto'}}> - <ExternalLinkEmbed link={linkInfo} /> + <ExternalLinkEmbed link={linkInfo} hideAlt /> </View> ) : null} <TouchableOpacity diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx new file mode 100644 index 000000000..9e41a328f --- /dev/null +++ b/src/view/com/composer/GifAltText.tsx @@ -0,0 +1,177 @@ +import React, {useCallback, useState} from 'react' +import {TouchableOpacity, View} from 'react-native' +import {AppBskyEmbedExternal} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ExternalEmbedDraft} from '#/lib/api' +import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants' +import { + EmbedPlayerParams, + parseEmbedPlayerFromUrl, +} from '#/lib/strings/embed-player' +import {enforceLen} from '#/lib/strings/helpers' +import {isAndroid} from '#/platform/detection' +import {Gif} from '#/state/queries/tenor' +import {atoms as a, native, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Text} from '#/components/Typography' +import {GifEmbed} from '../util/post-embeds/GifEmbed' +import {AltTextReminder} from './photos/Gallery' + +export function GifAltText({ + link: linkProp, + gif, + onSubmit, +}: { + link: ExternalEmbedDraft + gif?: Gif + onSubmit: (alt: string) => void +}) { + const control = Dialog.useDialogControl() + const {_} = useLingui() + const t = useTheme() + + const {link, params} = React.useMemo(() => { + return { + link: { + title: linkProp.meta?.title ?? linkProp.uri, + uri: linkProp.uri, + description: linkProp.meta?.description ?? '', + thumb: linkProp.localThumb?.path, + }, + params: parseEmbedPlayerFromUrl(linkProp.uri), + } + }, [linkProp]) + + const onPressSubmit = useCallback( + (alt: string) => { + control.close(() => { + onSubmit(alt) + }) + }, + [onSubmit, control], + ) + + if (!gif || !params) return null + + return ( + <> + <TouchableOpacity + testID="altTextButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Add alt text`)} + accessibilityHint="" + hitSlop={HITSLOP_10} + onPress={control.open} + style={[ + a.absolute, + {top: 20, left: 12}, + {borderRadius: 6}, + a.pl_xs, + a.pr_sm, + a.py_2xs, + a.flex_row, + a.gap_xs, + a.align_center, + {backgroundColor: 'rgba(0, 0, 0, 0.75)'}, + ]}> + {link.description ? ( + <Check size="xs" fill={t.palette.white} style={a.ml_xs} /> + ) : ( + <Plus size="sm" fill={t.palette.white} /> + )} + <Text + style={[a.font_bold, {color: t.palette.white}]} + accessible={false}> + <Trans>ALT</Trans> + </Text> + </TouchableOpacity> + + <AltTextReminder /> + + <Dialog.Outer + control={control} + nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}> + <Dialog.Handle /> + <AltTextInner + onSubmit={onPressSubmit} + link={link} + params={params} + initalValue={link.description.replace('Alt text: ', '')} + key={link.uri} + /> + </Dialog.Outer> + </> + ) +} + +function AltTextInner({ + onSubmit, + link, + params, + initalValue, +}: { + onSubmit: (text: string) => void + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams + initalValue: string +}) { + const {_} = useLingui() + const [altText, setAltText] = useState(initalValue) + + const onPressSubmit = useCallback(() => { + onSubmit(altText) + }, [onSubmit, altText]) + + return ( + <Dialog.ScrollableInner label={_(msg`Add alt text`)}> + <View style={a.flex_col_reverse}> + <View style={[a.mt_md, a.gap_md]}> + <View> + <TextField.LabelText> + <Trans>Descriptive alt text</Trans> + </TextField.LabelText> + <TextField.Root> + <Dialog.Input + label={_(msg`Alt text`)} + placeholder={link.title} + onChangeText={text => + setAltText(enforceLen(text, MAX_ALT_TEXT)) + } + value={altText} + multiline + numberOfLines={3} + autoFocus + /> + </TextField.Root> + </View> + <Button + label={_(msg`Save`)} + size="medium" + color="primary" + variant="solid" + onPress={onPressSubmit}> + <ButtonText> + <Trans>Save</Trans> + </ButtonText> + </Button> + </View> + {/* below the text input to force tab order */} + <View> + <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}> + <Trans>Add ALT text</Trans> + </Text> + <View style={[a.w_full, a.align_center, native({maxHeight: 200})]}> + <GifEmbed link={link} params={params} hideAlt /> + </View> + </View> + </View> + <Dialog.Close /> + </Dialog.ScrollableInner> + ) +} diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 69c8debb0..7ff1b7b9a 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,19 +1,20 @@ import React, {useState} from 'react' import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' -import {GalleryModel} from 'state/models/media/gallery' -import {observer} from 'mobx-react-lite' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {s, colors} from 'lib/styles' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Image} from 'expo-image' -import {Text} from 'view/com/util/text/Text' -import {Dimensions} from 'lib/media/types' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {Trans, msg} from '@lingui/macro' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {observer} from 'mobx-react-lite' + import {useModalControls} from '#/state/modals' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Dimensions} from 'lib/media/types' +import {colors, s} from 'lib/styles' import {isNative} from 'platform/detection' +import {GalleryModel} from 'state/models/media/gallery' +import {Text} from 'view/com/util/text/Text' +import {useTheme} from '#/alf' const IMAGE_GAP = 8 @@ -49,10 +50,10 @@ const GalleryInner = observer(function GalleryImpl({ gallery, containerInfo, }: GalleryInnerProps) { - const pal = usePalette('default') const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() + const t = useTheme() let side: number @@ -126,16 +127,22 @@ const GalleryInner = observer(function GalleryImpl({ }) }} style={[styles.altTextControl, altTextControlStyle]}> - <Text style={styles.altTextControlLabel} accessible={false}> - <Trans>ALT</Trans> - </Text> {image.altText.length > 0 ? ( <FontAwesomeIcon icon="check" size={10} - style={{color: colors.green3}} + style={{color: t.palette.white}} + /> + ) : ( + <FontAwesomeIcon + icon="plus" + size={10} + style={{color: t.palette.white}} /> - ) : undefined} + )} + <Text style={styles.altTextControlLabel} accessible={false}> + <Trans>ALT</Trans> + </Text> </TouchableOpacity> <View style={imageControlsStyle}> <TouchableOpacity @@ -201,21 +208,28 @@ const GalleryInner = observer(function GalleryImpl({ </View> ))} </View> - <View style={[styles.reminder]}> - <View style={[styles.infoIcon, pal.viewLight]}> - <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} /> - </View> - <Text type="sm" style={[pal.textLight, s.flex1]}> - <Trans> - Alt text describes images for blind and low-vision users, and helps - give context to everyone. - </Trans> - </Text> - </View> + <AltTextReminder /> </> ) : null }) +export function AltTextReminder() { + const t = useTheme() + return ( + <View style={[styles.reminder]}> + <View style={[styles.infoIcon, t.atoms.bg_contrast_25]}> + <FontAwesomeIcon icon="info" size={12} color={t.atoms.text.color} /> + </View> + <Text type="sm" style={[t.atoms.text_contrast_medium, s.flex1]}> + <Trans> + Alt text describes images for blind and low-vision users, and helps + give context to everyone. + </Trans> + </Text> + </View> + ) +} + const styles = StyleSheet.create({ gallery: { flex: 1, @@ -244,6 +258,7 @@ const styles = StyleSheet.create({ paddingVertical: 3, flexDirection: 'row', alignItems: 'center', + gap: 4, }, altTextControlLabel: { color: 'white', diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 9300b4159..8a21d86ae 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,12 +6,11 @@ import {RichText} from '#/components/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -import {pluralize} from 'lib/strings/helpers' import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' -import {Trans, msg} from '@lingui/macro' +import {Trans, msg, Plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import { usePinFeedMutation, @@ -265,10 +264,11 @@ export function FeedSourceCardLoaded({ {showLikes && feed.type === 'feed' ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - <Trans> - Liked by {feed.likeCount || 0}{' '} - {pluralize(feed.likeCount || 0, 'user')} - </Trans> + <Plural + value={feed.likeCount || 0} + one="Liked by # user" + other="Liked by # users" + /> </Text> ) : null} </Pressable> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 79ff5a02a..10cae2f17 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -14,11 +14,13 @@ import {Dimensions} from 'lib/media/types' import {getDataUriSize} from 'lib/media/util' import {gradients, s} from 'lib/styles' import {Text} from 'view/com/util/text/Text' +import {calculateDimensions} from './cropImageUtil' enum AspectRatio { Square = 'square', Wide = 'wide', Tall = 'tall', + Custom = 'custom', } const DIMS: Record<string, Dimensions> = { @@ -31,17 +33,24 @@ export const snapPoints = ['0%'] export function Component({ uri, + dimensions, onSelect, }: { uri: string + dimensions?: Dimensions onSelect: (img?: RNImage) => void }) { const {closeModal} = useModalControls() const pal = usePalette('default') const {_} = useLingui() - const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square) + const defaultAspectStyle = dimensions + ? AspectRatio.Custom + : AspectRatio.Square + const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle) const [scale, setScale] = React.useState<number>(1) const editorRef = React.useRef<ImageEditor>(null) + const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width + const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height const doSetAs = (v: AspectRatio) => () => setAs(v) @@ -57,8 +66,8 @@ export function Component({ path: dataUri, mime: 'image/jpeg', size: getDataUriSize(dataUri), - width: DIMS[as].width, - height: DIMS[as].height, + width: imageEditorWidth, + height: imageEditorHeight, }) } else { onSelect(undefined) @@ -73,7 +82,18 @@ export function Component({ cropperStyle = styles.cropperWide } else if (as === AspectRatio.Tall) { cropperStyle = styles.cropperTall + } else if (as === AspectRatio.Custom) { + const cropperDimensions = calculateDimensions( + 550, + imageEditorHeight, + imageEditorWidth, + ) + cropperStyle = { + width: cropperDimensions.width, + height: cropperDimensions.height, + } } + return ( <View> <View style={[styles.cropper, pal.borderDark, cropperStyle]}> @@ -81,8 +101,8 @@ export function Component({ ref={editorRef} style={styles.imageEditor} image={uri} - width={DIMS[as].width} - height={DIMS[as].height} + width={imageEditorWidth} + height={imageEditorHeight} scale={scale} border={0} /> @@ -97,36 +117,40 @@ export function Component({ maximumValue={3} containerStyle={styles.slider} /> - <TouchableOpacity - onPress={doSetAs(AspectRatio.Wide)} - accessibilityRole="button" - accessibilityLabel={_(msg`Wide`)} - accessibilityHint={_(msg`Sets image aspect ratio to wide`)}> - <RectWideIcon - size={24} - style={as === AspectRatio.Wide ? s.blue3 : pal.text} - /> - </TouchableOpacity> - <TouchableOpacity - onPress={doSetAs(AspectRatio.Tall)} - accessibilityRole="button" - accessibilityLabel={_(msg`Tall`)} - accessibilityHint={_(msg`Sets image aspect ratio to tall`)}> - <RectTallIcon - size={24} - style={as === AspectRatio.Tall ? s.blue3 : pal.text} - /> - </TouchableOpacity> - <TouchableOpacity - onPress={doSetAs(AspectRatio.Square)} - accessibilityRole="button" - accessibilityLabel={_(msg`Square`)} - accessibilityHint={_(msg`Sets image aspect ratio to square`)}> - <SquareIcon - size={24} - style={as === AspectRatio.Square ? s.blue3 : pal.text} - /> - </TouchableOpacity> + {as === AspectRatio.Custom ? null : ( + <> + <TouchableOpacity + onPress={doSetAs(AspectRatio.Wide)} + accessibilityRole="button" + accessibilityLabel={_(msg`Wide`)} + accessibilityHint={_(msg`Sets image aspect ratio to wide`)}> + <RectWideIcon + size={24} + style={as === AspectRatio.Wide ? s.blue3 : pal.text} + /> + </TouchableOpacity> + <TouchableOpacity + onPress={doSetAs(AspectRatio.Tall)} + accessibilityRole="button" + accessibilityLabel={_(msg`Tall`)} + accessibilityHint={_(msg`Sets image aspect ratio to tall`)}> + <RectTallIcon + size={24} + style={as === AspectRatio.Tall ? s.blue3 : pal.text} + /> + </TouchableOpacity> + <TouchableOpacity + onPress={doSetAs(AspectRatio.Square)} + accessibilityRole="button" + accessibilityLabel={_(msg`Square`)} + accessibilityHint={_(msg`Sets image aspect ratio to square`)}> + <SquareIcon + size={24} + style={as === AspectRatio.Square ? s.blue3 : pal.text} + /> + </TouchableOpacity> + </> + )} </View> <View style={styles.btns}> <TouchableOpacity diff --git a/src/view/com/modals/crop-image/cropImageUtil.ts b/src/view/com/modals/crop-image/cropImageUtil.ts new file mode 100644 index 000000000..303d15ba5 --- /dev/null +++ b/src/view/com/modals/crop-image/cropImageUtil.ts @@ -0,0 +1,13 @@ +export const calculateDimensions = ( + maxWidth: number, + originalHeight: number, + originalWidth: number, +) => { + const aspectRatio = originalWidth / originalHeight + const newHeight = maxWidth / aspectRatio + const newWidth = maxWidth + return { + width: newWidth, + height: newHeight, + } +} diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 94844cb1a..c20a8e9ee 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -22,7 +22,7 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' +import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -33,7 +33,6 @@ import {HeartIconSolid} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {pluralize} from 'lib/strings/helpers' import {niceDate} from 'lib/strings/time' import {colors, s} from 'lib/styles' import {isWeb} from 'platform/detection' @@ -176,6 +175,7 @@ let FeedItem = ({ return null } + let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : '' return ( <Link testID={`feedItem-by-${item.notification.author.handle}`} @@ -236,8 +236,10 @@ let FeedItem = ({ <Trans>and</Trans>{' '} </Text> <Text style={[pal.text, s.bold]}> - {formatCount(authors.length - 1)}{' '} - {pluralize(authors.length - 1, 'other')} + {plural(authors.length - 1, { + one: `${formattedCount} other`, + other: `${formattedCount} others`, + })} </Text> </> ) : undefined} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 69b26ad44..a52818fd1 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -371,11 +371,11 @@ export function PostThread({ ], ) - if (error || !thread) { + if (!thread || !preferences || error) { return ( <ListMaybePlaceholder - isLoading={(!preferences || !thread) && !error} - isError={!!error} + isLoading={!error} + isError={Boolean(error)} noEmpty onRetry={refetch} errorTitle={error?.title} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index cfb8bd93f..f644a5366 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -8,7 +8,7 @@ import { RichText as RichTextAPI, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' @@ -24,7 +24,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {makeProfileLink} from 'lib/routes/links' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {countLines, pluralize} from 'lib/strings/helpers' +import {countLines} from 'lib/strings/helpers' import {niceDate} from 'lib/strings/time' import {s} from 'lib/styles' import {isWeb} from 'platform/detection' @@ -336,7 +336,11 @@ let PostThreadItemLoaded = ({ <Text type="xl-bold" style={pal.text}> {formatCount(post.repostCount)} </Text>{' '} - {pluralize(post.repostCount, 'repost')} + <Plural + value={post.repostCount} + one="repost" + other="reposts" + /> </Text> </Link> ) : null} @@ -352,7 +356,7 @@ let PostThreadItemLoaded = ({ <Text type="xl-bold" style={pal.text}> {formatCount(post.likeCount)} </Text>{' '} - {pluralize(post.likeCount, 'like')} + <Plural value={post.likeCount} one="like" other="likes" /> </Text> </Link> ) : null} 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', + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 3eaa1b875..665400f14 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -119,22 +119,24 @@ function HomeScreenReady({ const gate = useGate() const mode = useMinimalShellMode() const {isMobile} = useWebMediaQueries() - React.useEffect(() => { - const listener = AppState.addEventListener('change', nextAppState => { - if (nextAppState === 'active') { - if ( - isMobile && - mode.value === 1 && - gate('disable_min_shell_on_foregrounding_v2') - ) { - setMinimalShellMode(false) + useFocusEffect( + React.useCallback(() => { + const listener = AppState.addEventListener('change', nextAppState => { + if (nextAppState === 'active') { + if ( + isMobile && + mode.value === 1 && + gate('disable_min_shell_on_foregrounding_v3') + ) { + setMinimalShellMode(false) + } } + }) + return () => { + listener.remove() } - }) - return () => { - listener.remove() - } - }, [setMinimalShellMode, mode, isMobile, gate]) + }, [setMinimalShellMode, mode, isMobile, gate]), + ) const onPageSelected = React.useCallback( (index: number) => { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index b7ce8cdd0..ebd9bb23e 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -20,8 +20,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {CommonNavigatorParams} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' -import {isWeb} from 'platform/detection' import {ProfileCard} from 'view/com/profile/ProfileCard' import {CenteredView} from 'view/com/util/Views' import {ErrorScreen} from '../com/util/error/ErrorScreen' @@ -38,7 +36,6 @@ export function ModerationBlockedAccounts({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() - const gate = useGate() const [isPTRing, setIsPTRing] = React.useState(false) const { @@ -168,9 +165,6 @@ export function ModerationBlockedAccounts({}: Props) { )} // @ts-ignore our .web version only -prf desktopFixedHeight - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> )} </CenteredView> diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 4d7ca6294..e395a3a5b 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -20,8 +20,6 @@ import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {CommonNavigatorParams} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' -import {isWeb} from 'platform/detection' import {ProfileCard} from 'view/com/profile/ProfileCard' import {CenteredView} from 'view/com/util/Views' import {ErrorScreen} from '../com/util/error/ErrorScreen' @@ -38,7 +36,6 @@ export function ModerationMutedAccounts({}: Props) { const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() - const gate = useGate() const [isPTRing, setIsPTRing] = React.useState(false) const { @@ -167,9 +164,6 @@ export function ModerationMutedAccounts({}: Props) { )} // @ts-ignore our .web version only -prf desktopFixedHeight - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> )} </CenteredView> diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index b4acbcd44..724c3f265 100644 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -12,7 +12,7 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import debounce from 'lodash.debounce' -import {Trans, msg} from '@lingui/macro' +import {Trans, msg, Plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import { usePreferencesQuery, @@ -27,7 +27,6 @@ function RepliesThresholdInput({ initialValue: number }) { const pal = usePalette('default') - const {_} = useLingui() const [value, setValue] = useState(initialValue) const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() const preValue = React.useRef(initialValue) @@ -64,13 +63,12 @@ function RepliesThresholdInput({ thumbTintColor={colors.blue3} /> <Text type="xs" style={pal.text}> - {value === 0 - ? _(msg`Show all replies`) - : _( - msg`Show replies with at least ${value} ${ - value > 1 ? `likes` : `like` - }`, - )} + <Plural + value={value} + _0="Show all replies" + one="Show replies with at least # like" + other="Show replies with at least # likes" + /> </Text> </View> ) diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index 814c1e855..c6fac743a 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useMemo} from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useIsFocused, useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' @@ -35,7 +35,6 @@ import {makeCustomFeedLink} from 'lib/routes/links' import {CommonNavigatorParams} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types' import {shareUrl} from 'lib/sharing' -import {pluralize} from 'lib/strings/helpers' import {makeRecordUri} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers' import {s} from 'lib/styles' @@ -597,7 +596,11 @@ function AboutSection({ label={_(msg`View users who like this feed`)} to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} style={[t.atoms.text_contrast_medium, a.font_bold]}> - {_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)} + <Plural + value={likeCount} + one="Liked by # user" + other="Liked by # users" + /> </InlineLinkText> )} </View> diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 1d93a9fd7..2902ccf5e 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -454,33 +454,29 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }, }) } - if (isCurateList) { + if (isCurateList && (isBlocking || isMuting)) { items.push({label: 'separator'}) - if (!isBlocking) { + if (isMuting) { items.push({ testID: 'listHeaderDropdownMuteBtn', - label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`), - onPress: isMuting - ? onUnsubscribeMute - : subscribeMutePromptControl.open, + label: _(msg`Un-mute list`), + onPress: onUnsubscribeMute, icon: { ios: { - name: isMuting ? 'eye' : 'eye.slash', + name: 'eye', }, android: '', - web: isMuting ? 'eye' : ['far', 'eye-slash'], + web: 'eye', }, }) } - if (!isMuting) { + if (isBlocking) { items.push({ testID: 'listHeaderDropdownBlockBtn', - label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`), - onPress: isBlocking - ? onUnsubscribeBlock - : subscribeBlockPromptControl.open, + label: _(msg`Un-block list`), + onPress: onUnsubscribeBlock, icon: { ios: { name: 'person.fill.xmark', @@ -508,9 +504,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { isBlocking, isMuting, onUnsubscribeMute, - subscribeMutePromptControl.open, onUnsubscribeBlock, - subscribeBlockPromptControl.open, ]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index a0e4ff60f..c3864e5a9 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -1,7 +1,5 @@ import React from 'react' import { - ActivityIndicator, - Linking, Platform, Pressable, StyleSheet, @@ -41,7 +39,7 @@ import { import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' import {useAnalytics} from 'lib/analytics/analytics' -import * as AppInfo from 'lib/app-info' +import {appVersion, BUNDLE_DATE, bundleInfo} from 'lib/app-info' import {STATUS_PAGE_URL} from 'lib/constants' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' import {useCustomPalette} from 'lib/hooks/useCustomPalette' @@ -51,7 +49,6 @@ import {HandIcon, HashtagIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types' -import {useGate} from 'lib/statsig/statsig' import {colors, s} from 'lib/styles' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' @@ -62,25 +59,40 @@ import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' import {ScrollView} from 'view/com/util/Views' -import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' +import {useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import * as TextField from '#/components/forms/TextField' import {navigate, resetToTab} from '#/Navigation' import {Email2FAToggle} from './Email2FAToggle' import {ExportCarDialog} from './ExportCarDialog' -function SettingsAccountCard({account}: {account: SessionAccount}) { +function SettingsAccountCard({ + account, + pendingDid, + onPressSwitchAccount, +}: { + account: SessionAccount + pendingDid: string | null + onPressSwitchAccount: ( + account: SessionAccount, + logContext: 'Settings', + ) => void +}) { const pal = usePalette('default') const {_} = useLingui() - const {isSwitchingAccounts, currentAccount} = useSession() + const t = useTheme() + const {currentAccount} = useSession() const {logout} = useSessionApi() const {data: profile} = useProfileQuery({did: account.did}) const isCurrentAccount = account.did === currentAccount?.did - const {onPressSwitchAccount} = useAccountSwitcher() const contents = ( - <View style={[pal.view, styles.linkCard]}> + <View + style={[ + pal.view, + styles.linkCard, + account.did === pendingDid && t.atoms.bg_contrast_25, + ]}> <View style={styles.avi}> <UserAvatar size={40} @@ -112,7 +124,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { }} accessibilityRole="button" accessibilityLabel={_(msg`Sign out`)} - accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`} + activeOpacity={0.8}> <Text type="lg" style={pal.link}> <Trans>Sign out</Trans> </Text> @@ -138,13 +151,12 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { testID={`switchToAccountBtn-${account.handle}`} key={account.did} onPress={ - isSwitchingAccounts - ? undefined - : () => onPressSwitchAccount(account, 'Settings') + pendingDid ? undefined : () => onPressSwitchAccount(account, 'Settings') } accessibilityRole="button" accessibilityLabel={_(msg`Switch to ${account.handle}`)} - accessibilityHint={_(msg`Switches the account you are logged in to`)}> + accessibilityHint={_(msg`Switches the account you are logged in to`)} + activeOpacity={0.8}> {contents} </TouchableOpacity> ) @@ -165,17 +177,14 @@ export function SettingsScreen({}: Props) { const {isMobile} = useWebMediaQueries() const {screen, track} = useAnalytics() const {openModal} = useModalControls() - const {isSwitchingAccounts, accounts, currentAccount} = useSession() + const {accounts, currentAccount} = useSession() const {mutate: clearPreferences} = useClearPreferencesMutation() const {setShowLoggedOut} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() const exportCarControl = useDialogControl() const birthdayControl = useDialogControl() - - // TODO: TEMP REMOVE WHEN CLOPS ARE RELEASED - const gate = useGate() - const {serviceUrl: dmServiceUrl, setServiceUrl: setDmServiceUrl} = - useDmServiceUrlStorage() + const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() + const isSwitchingAccounts = !!pendingDid // const primaryBg = useCustomPalette<ViewStyle>({ // light: {backgroundColor: colors.blue0}, @@ -246,7 +255,7 @@ export function SettingsScreen({}: Props) { const onPressBuildInfo = React.useCallback(() => { setStringAsync( - `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, + `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`, ) Toast.show(_(msg`Copied build version to clipboard`)) }, [_]) @@ -283,10 +292,6 @@ export function SettingsScreen({}: Props) { navigation.navigate('AccessibilitySettings') }, [navigation]) - const onPressStatusPage = React.useCallback(() => { - Linking.openURL(STATUS_PAGE_URL) - }, []) - const onPressBirthday = React.useCallback(() => { birthdayControl.open() }, [birthdayControl]) @@ -371,50 +376,53 @@ export function SettingsScreen({}: Props) { <View style={styles.spacer20} /> {!currentAccount.emailConfirmed && <EmailConfirmationNotice />} + + <View style={[s.flexRow, styles.heading]}> + <Text type="xl-bold" style={pal.text}> + <Trans>Signed in as</Trans> + </Text> + <View style={s.flex1} /> + </View> + <View pointerEvents={pendingDid ? 'none' : 'auto'}> + <SettingsAccountCard + account={currentAccount} + onPressSwitchAccount={onPressSwitchAccount} + pendingDid={pendingDid} + /> + </View> </> ) : null} - <View style={[s.flexRow, styles.heading]}> - <Text type="xl-bold" style={pal.text}> - <Trans>Signed in as</Trans> - </Text> - <View style={s.flex1} /> - </View> - - {isSwitchingAccounts ? ( - <View style={[pal.view, styles.linkCard]}> - <ActivityIndicator /> - </View> - ) : ( - <SettingsAccountCard account={currentAccount!} /> - )} - {accounts - .filter(a => a.did !== currentAccount?.did) - .map(account => ( - <SettingsAccountCard key={account.did} account={account} /> - ))} + <View pointerEvents={pendingDid ? 'none' : 'auto'}> + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SettingsAccountCard + key={account.did} + account={account} + onPressSwitchAccount={onPressSwitchAccount} + pendingDid={pendingDid} + /> + ))} - <TouchableOpacity - testID="switchToNewAccountBtn" - style={[ - styles.linkCard, - pal.view, - isSwitchingAccounts && styles.dimmed, - ]} - onPress={isSwitchingAccounts ? undefined : onPressAddAccount} - accessibilityRole="button" - accessibilityLabel={_(msg`Add account`)} - accessibilityHint={_(msg`Create a new Bluesky account`)}> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="plus" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Add account</Trans> - </Text> - </TouchableOpacity> + <TouchableOpacity + testID="switchToNewAccountBtn" + style={[styles.linkCard, pal.view]} + onPress={isSwitchingAccounts ? undefined : onPressAddAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Add account`)} + accessibilityHint={_(msg`Create a new Bluesky account`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="plus" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Add account</Trans> + </Text> + </TouchableOpacity> + </View> <View style={styles.spacer20} /> @@ -786,22 +794,6 @@ export function SettingsScreen({}: Props) { <Trans>System log</Trans> </Text> </TouchableOpacity> - {gate('dms') && ( - <TextField.Root> - <TextField.Input - value={dmServiceUrl} - onChangeText={(text: string) => { - if (text.length > 9 && text.endsWith('/')) { - text = text.slice(0, -1) - } - setDmServiceUrl(text) - }} - autoCapitalize="none" - keyboardType="url" - label="🐴" - /> - </TextField.Root> - )} {__DEV__ ? ( <> <TouchableOpacity @@ -873,17 +865,9 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" onPress={onPressBuildInfo}> <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - <Trans>Version {AppInfo.appVersion}</Trans> - </Text> - </TouchableOpacity> - <Text type="sm" style={[pal.textLight]}> - · - </Text> - <TouchableOpacity - accessibilityRole="button" - onPress={onPressStatusPage}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - <Trans>Status page</Trans> + <Trans> + Version {appVersion} {bundleInfo} + </Trans> </Text> </TouchableOpacity> </View> @@ -905,6 +889,12 @@ export function SettingsScreen({}: Props) { href="https://bsky.social/about/support/privacy-policy" text={_(msg`Privacy Policy`)} /> + <TextLink + type="md" + style={pal.link} + href={STATUS_PAGE_URL} + text={_(msg`Status Page`)} + /> </View> <View style={s.footerSpacer} /> </ScrollView> @@ -1050,7 +1040,6 @@ const styles = StyleSheet.create({ footer: { flex: 1, flexDirection: 'row', - alignItems: 'center', paddingLeft: 18, }, }) diff --git a/src/view/screens/Storybook/ListContained.tsx b/src/view/screens/Storybook/ListContained.tsx new file mode 100644 index 000000000..b3ea091f4 --- /dev/null +++ b/src/view/screens/Storybook/ListContained.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import {FlatList, View} from 'react-native' + +import {ScrollProvider} from 'lib/ScrollContext' +import {List} from 'view/com/util/List' +import {Button, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +export function ListContained() { + const [animated, setAnimated] = React.useState(false) + const ref = React.useRef<FlatList>(null) + + const data = React.useMemo(() => { + return Array.from({length: 100}, (_, i) => ({ + id: i, + text: `Message ${i}`, + })) + }, []) + + return ( + <> + <View style={{width: '100%', height: 300}}> + <ScrollProvider + onScroll={e => { + 'worklet' + console.log( + JSON.stringify({ + contentOffset: e.contentOffset, + layoutMeasurement: e.layoutMeasurement, + contentSize: e.contentSize, + }), + ) + }}> + <List + data={data} + renderItem={item => { + return ( + <View + style={{ + padding: 10, + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }}> + <Text>{item.item.text}</Text> + </View> + ) + }} + keyExtractor={item => item.id.toString()} + containWeb={true} + style={{flex: 1}} + onStartReached={() => { + console.log('Start Reached') + }} + onEndReached={() => { + console.log('End Reached (threshold of 2)') + }} + onEndReachedThreshold={2} + ref={ref} + disableVirtualization={true} + /> + </ScrollProvider> + </View> + + <View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}> + <Toggle.Item + name="a" + label="Click me" + value={animated} + onChange={() => setAnimated(prev => !prev)}> + <Toggle.Checkbox /> + <Toggle.LabelText>Animated Scrolling</Toggle.LabelText> + </Toggle.Item> + </View> + + <Button + variant="solid" + color="primary" + size="large" + label="Scroll to End" + onPress={() => ref.current?.scrollToOffset({animated, offset: 0})}> + <ButtonText>Scroll to Top</ButtonText> + </Button> + + <Button + variant="solid" + color="primary" + size="large" + label="Scroll to End" + onPress={() => ref.current?.scrollToEnd({animated})}> + <ButtonText>Scroll to End</ButtonText> + </Button> + + <Button + variant="solid" + color="primary" + size="large" + label="Scroll to Offset 100" + onPress={() => ref.current?.scrollToOffset({animated, offset: 500})}> + <ButtonText>Scroll to Offset 500</ButtonText> + </Button> + </> + ) +} diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 35a666601..282b3ff5c 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -1,8 +1,10 @@ import React from 'react' -import {View} from 'react-native' +import {ScrollView, View} from 'react-native' import {useSetThemePrefs} from '#/state/shell' -import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {isWeb} from 'platform/detection' +import {CenteredView} from '#/view/com/util/Views' +import {ListContained} from 'view/screens/Storybook/ListContained' import {atoms as a, ThemeProvider, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Breakpoints} from './Breakpoints' @@ -18,77 +20,111 @@ import {Theming} from './Theming' import {Typography} from './Typography' export function Storybook() { + if (isWeb) return <StorybookInner /> + + return ( + <ScrollView> + <StorybookInner /> + </ScrollView> + ) +} + +function StorybookInner() { const t = useTheme() const {setColorMode, setDarkTheme} = useSetThemePrefs() + const [showContainedList, setShowContainedList] = React.useState(false) return ( - <ScrollView> - <CenteredView style={[t.atoms.bg]}> - <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> - <View style={[a.flex_row, a.align_start, a.gap_md]}> - <Button - variant="outline" - color="primary" - size="small" - label='Set theme to "system"' - onPress={() => setColorMode('system')}> - <ButtonText>System</ButtonText> - </Button> - <Button - variant="solid" - color="secondary" - size="small" - label='Set theme to "light"' - onPress={() => setColorMode('light')}> - <ButtonText>Light</ButtonText> - </Button> + <CenteredView style={[t.atoms.bg]}> + <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}> + {!showContainedList ? ( + <> + <View style={[a.flex_row, a.align_start, a.gap_md]}> + <Button + variant="outline" + color="primary" + size="small" + label='Set theme to "system"' + onPress={() => setColorMode('system')}> + <ButtonText>System</ButtonText> + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "light"' + onPress={() => setColorMode('light')}> + <ButtonText>Light</ButtonText> + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "dim"' + onPress={() => { + setColorMode('dark') + setDarkTheme('dim') + }}> + <ButtonText>Dim</ButtonText> + </Button> + <Button + variant="solid" + color="secondary" + size="small" + label='Set theme to "dark"' + onPress={() => { + setColorMode('dark') + setDarkTheme('dark') + }}> + <ButtonText>Dark</ButtonText> + </Button> + </View> + + <Dialogs /> + <ThemeProvider theme="light"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dim"> + <Theming /> + </ThemeProvider> + <ThemeProvider theme="dark"> + <Theming /> + </ThemeProvider> + + <Typography /> + <Spacing /> + <Shadows /> + <Buttons /> + <Icons /> + <Links /> + <Forms /> + <Dialogs /> + <Menus /> + <Breakpoints /> + <Button variant="solid" - color="secondary" - size="small" - label='Set theme to "dim"' - onPress={() => { - setColorMode('dark') - setDarkTheme('dim') - }}> - <ButtonText>Dim</ButtonText> + color="primary" + size="large" + label="Switch to Contained List" + onPress={() => setShowContainedList(true)}> + <ButtonText>Switch to Contained List</ButtonText> </Button> + </> + ) : ( + <> <Button variant="solid" - color="secondary" - size="small" - label='Set theme to "dark"' - onPress={() => { - setColorMode('dark') - setDarkTheme('dark') - }}> - <ButtonText>Dark</ButtonText> + color="primary" + size="large" + label="Switch to Storybook" + onPress={() => setShowContainedList(false)}> + <ButtonText>Switch to Storybook</ButtonText> </Button> - </View> - - <Dialogs /> - <ThemeProvider theme="light"> - <Theming /> - </ThemeProvider> - <ThemeProvider theme="dim"> - <Theming /> - </ThemeProvider> - <ThemeProvider theme="dark"> - <Theming /> - </ThemeProvider> - - <Typography /> - <Spacing /> - <Shadows /> - <Buttons /> - <Icons /> - <Links /> - <Forms /> - <Dialogs /> - <Menus /> - <Breakpoints /> - </View> - </CenteredView> - </ScrollView> + <ListContained /> + </> + )} + </View> + </CenteredView> ) } diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index d8e604ec3..04f144e87 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -13,7 +13,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' @@ -42,7 +42,6 @@ import { } from 'lib/icons' import {getTabState, TabState} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' -import {pluralize} from 'lib/strings/helpers' import {colors, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {isWeb} from 'platform/detection' @@ -90,15 +89,26 @@ let DrawerProfileCard = ({ @{account.handle} </Text> <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> - <Text type="xl-medium" style={pal.text}> - {formatCountShortOnly(profile?.followersCount ?? 0)} - </Text>{' '} - {pluralize(profile?.followersCount || 0, 'follower')} ·{' '} + <Trans> + <Text type="xl-medium" style={pal.text}> + {formatCountShortOnly(profile?.followersCount ?? 0)} + </Text>{' '} + <Plural + value={profile?.followersCount || 0} + one="follower" + other="followers" + />{' '} + ·{' '} + </Trans> <Trans> <Text type="xl-medium" style={pal.text}> {formatCountShortOnly(profile?.followsCount ?? 0)} </Text>{' '} - following + <Plural + value={profile?.followsCount || 0} + one="following" + other="following" + /> </Trans> </Text> </TouchableOpacity> |