diff options
Diffstat (limited to 'src/view')
38 files changed, 1474 insertions, 761 deletions
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index f905e1e8d..6df4e439a 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -4,6 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useKawaiiMode} from '#/state/preferences/kawaii' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' @@ -28,6 +29,8 @@ export const SplashScreen = ({ const t = useTheme() const {isTabletOrMobile: isMobileWeb} = useWebMediaQueries() + const kawaii = useKawaiiMode() + return ( <> {onDismiss && ( @@ -66,11 +69,13 @@ export const SplashScreen = ({ ]}> <ErrorBoundary> <View style={[a.justify_center, a.align_center]}> - <Logo width={92} fill="sky" /> + <Logo width={kawaii ? 300 : 92} fill="sky" /> - <View style={[a.pb_sm, a.pt_5xl]}> - <Logotype width={161} fill={t.atoms.text.color} /> - </View> + {!kawaii && ( + <View style={[a.pb_sm, a.pt_5xl]}> + <Logotype width={161} fill={t.atoms.text.color} /> + </View> + )} <Text style={[ 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/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index 644d4cab6..f00a15b3f 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -15,6 +15,7 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {Logo} from '#/view/icons/Logo' +import {useKawaiiMode} from '../../../state/preferences/kawaii' import {Link} from '../util/Link' import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' @@ -43,10 +44,19 @@ function HomeHeaderLayoutDesktopAndTablet({ const {hasSession} = useSession() const {_} = useLingui() + const kawaii = useKawaiiMode() + return ( <> {hasSession && ( - <View style={[pal.view, pal.border, styles.bar, styles.topBar]}> + <View + style={[ + pal.view, + pal.border, + styles.bar, + styles.topBar, + kawaii && {paddingTop: 4, paddingBottom: 0}, + ]}> <Link href="/settings/following-feed" hitSlop={10} @@ -58,7 +68,7 @@ function HomeHeaderLayoutDesktopAndTablet({ style={pal.textLight as FontAwesomeIconStyle} /> </Link> - <Logo width={28} /> + <Logo width={kawaii ? 60 : 28} /> <Link href="/settings/saved-feeds" hitSlop={10} diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index dd439d475..7d34596d9 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,21 +1,22 @@ import React from 'react' -import {CenteredView} from '../util/Views' import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {FeedItem} from './FeedItem' -import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {EmptyState} from '../util/EmptyState' -import {s} from 'lib/styles' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {usePalette} from '#/lib/hooks/usePalette' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' -import {logger} from '#/logger' -import {cleanError} from '#/lib/strings/errors' -import {useModerationOpts} from '#/state/queries/preferences' +import {s} from 'lib/styles' +import {EmptyState} from '../util/EmptyState' +import {ErrorMessage} from '../util/error/ErrorMessage' import {List, ListRef} from '../util/List' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' -import {usePalette} from '#/lib/hooks/usePalette' +import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {CenteredView} from '../util/Views' +import {FeedItem} from './FeedItem' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index f4bf3b1ac..a52818fd1 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,11 +1,14 @@ import React, {useEffect, useRef} from 'react' import {StyleSheet, useWindowDimensions, View} from 'react-native' +import {runOnJS} from 'react-native-reanimated' import {AppBskyFeedDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {ScrollProvider} from '#/lib/ScrollContext' import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import { sortThread, ThreadBlocked, @@ -14,10 +17,7 @@ import { ThreadPost, usePostThreadQuery, } from '#/state/queries/post-thread' -import { - useModerationOpts, - usePreferencesQuery, -} from '#/state/queries/preferences' +import {usePreferencesQuery} from '#/state/queries/preferences' import {useSession} from '#/state/session' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {usePalette} from 'lib/hooks/usePalette' @@ -276,8 +276,11 @@ export function PostThread({ setMaxParents(n => n + PARENTS_CHUNK_SIZE) } }, []) - const onMomentumScrollEnd = bumpMaxParentsIfNeeded const onScrollToTop = bumpMaxParentsIfNeeded + const onMomentumEnd = React.useCallback(() => { + 'worklet' + runOnJS(bumpMaxParentsIfNeeded)() + }, [bumpMaxParentsIfNeeded]) const onEndReached = React.useCallback(() => { if (isFetching || posts.length < maxReplies) return @@ -368,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} @@ -382,38 +385,39 @@ export function PostThread({ } return ( - <List - ref={ref} - data={posts} - renderItem={renderItem} - keyExtractor={keyExtractor} - onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} - onStartReached={onStartReached} - onEndReached={onEndReached} - onEndReachedThreshold={2} - onMomentumScrollEnd={onMomentumScrollEnd} - onScrollToTop={onScrollToTop} - maintainVisibleContentPosition={ - isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined - } - // @ts-ignore our .web version only -prf - desktopFixedHeight - removeClippedSubviews={isAndroid ? false : undefined} - ListFooterComponent={ - <ListFooter - // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on - // initial render - isFetchingNextPage={isFetching} - error={cleanError(threadError)} - onRetry={refetch} - // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to - // work without causing weird jumps on web or glitches on native - height={windowHeight - 200} - /> - } - initialNumToRender={initialNumToRender} - windowSize={11} - /> + <ScrollProvider onMomentumEnd={onMomentumEnd}> + <List + ref={ref} + data={posts} + renderItem={renderItem} + keyExtractor={keyExtractor} + onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} + onStartReached={onStartReached} + onEndReached={onEndReached} + onEndReachedThreshold={2} + onScrollToTop={onScrollToTop} + maintainVisibleContentPosition={ + isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined + } + // @ts-ignore our .web version only -prf + desktopFixedHeight + removeClippedSubviews={isAndroid ? false : undefined} + ListFooterComponent={ + <ListFooter + // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on + // initial render + isFetchingNextPage={isFetching} + error={cleanError(threadError)} + onRetry={refetch} + // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to + // work without causing weird jumps on web or glitches on native + height={windowHeight - 200} + /> + } + initialNumToRender={initialNumToRender} + windowSize={11} + /> + </ScrollProvider> ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 564e37e7a..cfb8bd93f 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -15,8 +15,8 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useLanguagePrefs} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {ThreadPost} from '#/state/queries/post-thread' -import {useModerationOpts} from '#/state/queries/preferences' import {useComposerControls} from '#/state/shell/composer' import {MAX_POST_LINES} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 546eb2821..1a7185cd9 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -14,7 +14,7 @@ import {useQueryClient} from '@tanstack/react-query' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useComposerControls} from '#/state/shell/composer' import {MAX_POST_LINES} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 90ab9b738..6c8978946 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -12,7 +12,7 @@ import {useQueryClient} from '@tanstack/react-query' import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' import {useProfileShadow} from '#/state/cache/profile-shadow' import {Shadow} from '#/state/cache/types' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' import {usePalette} from 'lib/hooks/usePalette' import {getModerationCauseKey, isJustAMute} from 'lib/moderation' diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index 4c9d164f7..bb5ad2a63 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -9,7 +9,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useAnalytics} from 'lib/analytics/analytics' diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 5729a43a5..84b401e63 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -5,15 +5,17 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {usePalette} from '#/lib/hooks/usePalette' import {useScrollHandlers} from '#/lib/ScrollContext' -import {useGate} from 'lib/statsig/statsig' import {addStyle} from 'lib/styles' -import {isWeb} from 'platform/detection' import {FlatList_INTERNAL} from './Views' export type ListMethods = FlatList_INTERNAL export type ListProps<ItemT> = Omit< FlatListProps<ItemT>, + | 'onMomentumScrollBegin' // Use ScrollContext instead. + | 'onMomentumScrollEnd' // Use ScrollContext instead. | 'onScroll' // Use ScrollContext instead. + | 'onScrollBeginDrag' // Use ScrollContext instead. + | 'onScrollEndDrag' // Use ScrollContext instead. | 'refreshControl' // Pass refreshing and/or onRefresh instead. | 'contentOffset' // Pass headerOffset instead. > & { @@ -21,6 +23,7 @@ export type ListProps<ItemT> = Omit< headerOffset?: number refreshing?: boolean onRefresh?: () => void + containWeb?: boolean } export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> @@ -40,7 +43,6 @@ function ListImpl<ItemT>( const isScrolledDown = useSharedValue(false) const contextScrollHandlers = useScrollHandlers() const pal = usePalette('default') - const gate = useGate() function handleScrolledDownChange(didScrollDown: boolean) { onScrolledDownChange?.(didScrollDown) @@ -64,6 +66,11 @@ function ListImpl<ItemT>( } } }, + // Note: adding onMomentumBegin here makes simulator scroll + // lag on Android. So either don't add it, or figure out why. + onMomentumEnd(e, ctx) { + contextScrollHandlers.onMomentumEnd?.(e, ctx) + }, }) let refreshControl @@ -97,9 +104,6 @@ function ListImpl<ItemT>( scrollEventThrottle={1} style={style} ref={ref} - showsVerticalScrollIndicator={ - isWeb || !gate('hide_vertical_scroll_indicators') - } /> ) } diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 936bac198..9bea2d795 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -1,11 +1,13 @@ -import React, {isValidElement, memo, useRef, startTransition} from 'react' +import React, {isValidElement, memo, startTransition, useRef} from 'react' import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' -import {addStyle} from 'lib/styles' +import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' + +import {batchedUpdates} from '#/lib/batchedUpdates' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {useScrollHandlers} from '#/lib/ScrollContext' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useScrollHandlers} from '#/lib/ScrollContext' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {batchedUpdates} from '#/lib/batchedUpdates' +import {addStyle} from 'lib/styles' export type ListMethods = any // TODO: Better types. export type ListProps<ItemT> = Omit< @@ -19,6 +21,7 @@ export type ListProps<ItemT> = Omit< refreshing?: boolean onRefresh?: () => void desktopFixedHeight: any // TODO: Better types. + containWeb?: boolean } export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. @@ -26,12 +29,15 @@ function ListImpl<ItemT>( { ListHeaderComponent, ListFooterComponent, + containWeb, contentContainerStyle, data, desktopFixedHeight, headerOffset, keyExtractor, refreshing: _unsupportedRefreshing, + onStartReached, + onStartReachedThreshold = 0, onEndReached, onEndReachedThreshold = 0, onRefresh: _unsupportedOnRefresh, @@ -80,14 +86,88 @@ function ListImpl<ItemT>( }) } - const nativeRef = React.useRef(null) + const getScrollableNode = React.useCallback(() => { + if (containWeb) { + const element = nativeRef.current as HTMLDivElement | null + if (!element) return + + return { + get scrollWidth() { + return element.scrollWidth + }, + get scrollHeight() { + return element.scrollHeight + }, + get clientWidth() { + return element.clientWidth + }, + get clientHeight() { + return element.clientHeight + }, + get scrollY() { + return element.scrollTop + }, + get scrollX() { + return element.scrollLeft + }, + scrollTo(options?: ScrollToOptions) { + element.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + element.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + element.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + element.removeEventListener(event, handler) + }, + } + } else { + return { + get scrollWidth() { + return document.documentElement.scrollWidth + }, + get scrollHeight() { + return document.documentElement.scrollHeight + }, + get clientWidth() { + return window.innerWidth + }, + get clientHeight() { + return window.innerHeight + }, + get scrollY() { + return window.scrollY + }, + get scrollX() { + return window.scrollX + }, + scrollTo(options: ScrollToOptions) { + window.scrollTo(options) + }, + scrollBy(options: ScrollToOptions) { + window.scrollBy(options) + }, + addEventListener(event: string, handler: any) { + window.addEventListener(event, handler) + }, + removeEventListener(event: string, handler: any) { + window.removeEventListener(event, handler) + }, + } + } + }, [containWeb]) + + const nativeRef = React.useRef<HTMLDivElement>(null) React.useImperativeHandle( ref, () => ({ scrollToTop() { - window.scrollTo({top: 0}) + getScrollableNode()?.scrollTo({top: 0}) }, + scrollToOffset({ animated, offset, @@ -95,46 +175,74 @@ function ListImpl<ItemT>( animated: boolean offset: number }) { - window.scrollTo({ + getScrollableNode()?.scrollTo({ left: 0, top: offset, behavior: animated ? 'smooth' : 'instant', }) }, + scrollToEnd({animated = true}: {animated?: boolean}) { + const element = getScrollableNode() + element?.scrollTo({ + left: 0, + top: element.scrollHeight, + behavior: animated ? 'smooth' : 'instant', + }) + }, } as any), // TODO: Better types. - [], + [getScrollableNode], ) - // --- onContentSizeChange --- + // --- onContentSizeChange, maintainVisibleContentPosition --- const containerRef = useRef(null) useResizeObserver(containerRef, onContentSizeChange) // --- onScroll --- const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) - const handleWindowScroll = useNonReactiveCallback(() => { - if (isInsideVisibleTree) { - contextScrollHandlers.onScroll?.( - { - contentOffset: { - x: Math.max(0, window.scrollX), - y: Math.max(0, window.scrollY), - }, - } as any, // TODO: Better types. - null as any, - ) - } + const handleScroll = useNonReactiveCallback(() => { + if (!isInsideVisibleTree) return + + const element = getScrollableNode() + contextScrollHandlers.onScroll?.( + { + contentOffset: { + x: Math.max(0, element?.scrollX ?? 0), + y: Math.max(0, element?.scrollY ?? 0), + }, + layoutMeasurement: { + width: element?.clientWidth, + height: element?.clientHeight, + }, + contentSize: { + width: element?.scrollWidth, + height: element?.scrollHeight, + }, + } as Exclude< + ReanimatedScrollEvent, + | 'velocity' + | 'eventName' + | 'zoomScale' + | 'targetContentOffset' + | 'contentInset' + >, + null as any, + ) }) + React.useEffect(() => { if (!isInsideVisibleTree) { // Prevents hidden tabs from firing scroll events. // Only one list is expected to be firing these at a time. return } - window.addEventListener('scroll', handleWindowScroll) + + const element = getScrollableNode() + + element?.addEventListener('scroll', handleScroll) return () => { - window.removeEventListener('scroll', handleWindowScroll) + element?.removeEventListener('scroll', handleScroll) } - }, [isInsideVisibleTree, handleWindowScroll]) + }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode]) // --- onScrolledDownChange --- const isScrolledDown = useRef(false) @@ -148,6 +256,17 @@ function ListImpl<ItemT>( } } + // --- onStartReached --- + const onHeadVisibilityChange = useNonReactiveCallback( + (isHeadVisible: boolean) => { + if (isHeadVisible) { + onStartReached?.({ + distanceFromStart: onStartReachedThreshold || 0, + }) + } + }, + ) + // --- onEndReached --- const onTailVisibilityChange = useNonReactiveCallback( (isTailVisible: boolean) => { @@ -160,7 +279,17 @@ function ListImpl<ItemT>( ) return ( - <View {...props} style={style} ref={nativeRef}> + <View + {...props} + style={[ + style, + containWeb && { + flex: 1, + // @ts-expect-error web only + 'overflow-y': 'scroll', + }, + ]} + ref={nativeRef as any}> <Visibility onVisibleChange={setIsInsideVisibleTree} style={ @@ -178,9 +307,17 @@ function ListImpl<ItemT>( pal.border, ]}> <Visibility + root={containWeb ? nativeRef : null} onVisibleChange={handleAboveTheFoldVisibleChange} style={[styles.aboveTheFoldDetector, {height: headerOffset}]} /> + {onStartReached && ( + <Visibility + root={containWeb ? nativeRef : null} + onVisibleChange={onHeadVisibilityChange} + topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'} + /> + )} {header} {(data as Array<ItemT>).map((item, index) => ( <Row<ItemT> @@ -193,8 +330,9 @@ function ListImpl<ItemT>( ))} {onEndReached && ( <Visibility - topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} + root={containWeb ? nativeRef : null} onVisibleChange={onTailVisibilityChange} + bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} /> )} {footer} @@ -255,11 +393,15 @@ let Row = function RowImpl<ItemT>({ Row = React.memo(Row) let Visibility = ({ + root, topMargin = '0px', + bottomMargin = '0px', onVisibleChange, style, }: { + root?: React.RefObject<HTMLDivElement> | null topMargin?: string + bottomMargin?: string onVisibleChange: (isVisible: boolean) => void style?: ViewProps['style'] }): React.ReactNode => { @@ -281,14 +423,15 @@ let Visibility = ({ React.useEffect(() => { const observer = new IntersectionObserver(handleIntersection, { - rootMargin: `${topMargin} 0px 0px 0px`, + root: root?.current ?? null, + rootMargin: `${topMargin} 0px ${bottomMargin} 0px`, }) const tail: Element | null = tailRef.current! observer.observe(tail) return () => { observer.unobserve(tail) } - }, [handleIntersection, topMargin]) + }, [bottomMargin, handleIntersection, topMargin, root]) return ( <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 01b8a954d..f45229dc4 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -1,11 +1,12 @@ import React, {useCallback, useEffect} from 'react' +import {NativeScrollEvent} from 'react-native' +import {interpolate, useSharedValue} from 'react-native-reanimated' import EventEmitter from 'eventemitter3' + import {ScrollProvider} from '#/lib/ScrollContext' -import {NativeScrollEvent} from 'react-native' -import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' +import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' import {isNative, isWeb} from 'platform/detection' -import {useSharedValue, interpolate} from 'react-native-reanimated' const WEB_HIDE_SHELL_THRESHOLD = 200 @@ -32,6 +33,31 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { } }) + const snapToClosestState = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + if (isNative) { + if (startDragOffset.value === null) { + return + } + const didScrollDown = e.contentOffset.y > startDragOffset.value + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value) { + // If we're close to the top, show the shell. + setMode(false) + } else if (didScrollDown) { + // Showing the bar again on scroll down feels annoying, so don't. + setMode(true) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } + } + }, + [startDragOffset, startMode, setMode, mode, headerHeight], + ) + const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' @@ -47,18 +73,24 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (e: NativeScrollEvent) => { 'worklet' if (isNative) { - startDragOffset.value = null - startMode.value = null - if (e.contentOffset.y < headerHeight.value / 2) { - // If we're close to the top, show the shell. - setMode(false) - } else { - // Snap to whichever state is the closest. - setMode(Math.round(mode.value) === 1) + if (e.velocity && e.velocity.y !== 0) { + // If we detect a velocity, wait for onMomentumEnd to snap. + return } + snapToClosestState(e) } }, - [startDragOffset, startMode, setMode, mode, headerHeight], + [snapToClosestState], + ) + + const onMomentumEnd = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + if (isNative) { + snapToClosestState(e) + } + }, + [snapToClosestState], ) const onScroll = useCallback( @@ -119,7 +151,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { <ScrollProvider onBeginDrag={onBeginDrag} onEndDrag={onEndDrag} - onScroll={onScroll}> + onScroll={onScroll} + onMomentumEnd={onMomentumEnd}> {children} </ScrollProvider> ) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index db16ff066..e7ce18535 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {niceDate} from 'lib/strings/time' import {TypographyVariant} from 'lib/ThemeContext' import {isAndroid, isWeb} from 'platform/detection' +import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {TextLinkOnWebOnly} from './Link' import {Text} from './text/Text' import {TimeElapsed} from './TimeElapsed' @@ -58,37 +59,39 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { /> </View> )} - <Text - numberOfLines={1} - style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}> - <TextLinkOnWebOnly - type={opts.displayNameType || 'lg-bold'} - style={[pal.text]} - lineHeight={1.2} - disableMismatchWarning - text={ - <> - {sanitizeDisplayName( - displayName, - opts.moderation?.ui('displayName'), - )} - </> - } - href={profileLink} - onBeforePress={onBeforePress} - onPointerEnter={onPointerEnter} - /> - <TextLinkOnWebOnly - type="md" - disableMismatchWarning - style={[pal.textLight, {flexShrink: 4}]} - text={'\xa0' + sanitizeHandle(handle, '@')} - href={profileLink} - onBeforePress={onBeforePress} - onPointerEnter={onPointerEnter} - anchorNoUnderline - /> - </Text> + <ProfileHoverCard inline did={opts.author.did}> + <Text + numberOfLines={1} + style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}> + <TextLinkOnWebOnly + type={opts.displayNameType || 'lg-bold'} + style={[pal.text]} + lineHeight={1.2} + disableMismatchWarning + text={ + <> + {sanitizeDisplayName( + displayName, + opts.moderation?.ui('displayName'), + )} + </> + } + href={profileLink} + onBeforePress={onBeforePress} + onPointerEnter={onPointerEnter} + /> + <TextLinkOnWebOnly + type="md" + disableMismatchWarning + style={[pal.textLight, {flexShrink: 4}]} + text={'\xa0' + sanitizeHandle(handle, '@')} + href={profileLink} + onBeforePress={onBeforePress} + onPointerEnter={onPointerEnter} + anchorNoUnderline + /> + </Text> + </ProfileHoverCard> {!isAndroid && ( <Text type="md" diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index 6ea41b82b..a5d3a5372 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -3,21 +3,25 @@ import React from 'react' import {useTickEveryMinute} from '#/state/shell' import {ago} from 'lib/strings/time' -// FIXME(dan): Figure out why the false positives - export function TimeElapsed({ timestamp, children, + timeToString = ago, }: { timestamp: string children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element + timeToString?: (timeElapsed: string) => string }) { const tick = useTickEveryMinute() - const [timeElapsed, setTimeAgo] = React.useState(() => ago(timestamp)) + const [timeElapsed, setTimeAgo] = React.useState(() => + timeToString(timestamp), + ) - React.useEffect(() => { - setTimeAgo(ago(timestamp)) - }, [timestamp, setTimeAgo, tick]) + const [prevTick, setPrevTick] = React.useState(tick) + if (prevTick !== tick) { + setPrevTick(tick) + setTimeAgo(timeToString(timestamp)) + } return children({timeElapsed}) } diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx index 75f2b5081..2984a2d2d 100644 --- a/src/view/com/util/Views.jsx +++ b/src/view/com/util/Views.jsx @@ -2,19 +2,11 @@ import React from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' -import {useGate} from 'lib/statsig/statsig' - export const FlatList_INTERNAL = Animated.FlatList export function CenteredView(props) { return <View {...props} /> } export function ScrollView(props) { - const gate = useGate() - return ( - <Animated.ScrollView - {...props} - showsVerticalScrollIndicator={!gate('hide_vertical_scroll_indicators')} - /> - ) + return <Animated.ScrollView {...props} /> } diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx index 94591d393..6668ac211 100644 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -1,12 +1,13 @@ import React from 'react' +import {Pressable, StyleSheet, Text, View, ViewStyle} from 'react-native' +import {IconProp} from '@fortawesome/fontawesome-svg-core' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' -import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native' -import {IconProp} from '@fortawesome/fontawesome-svg-core' import {MenuItemCommonProps} from 'zeego/lib/typescript/menu' + +import {HITSLOP_10} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' -import {HITSLOP_10} from 'lib/constants' // Custom Dropdown Menu Components // == @@ -64,15 +65,9 @@ export function NativeDropdown({ accessibilityHint, triggerStyle, }: React.PropsWithChildren<Props>) { - const pal = usePalette('default') - const theme = useTheme() - const dropDownBackgroundColor = - theme.colorScheme === 'dark' ? pal.btn : pal.view const [open, setOpen] = React.useState(false) const buttonRef = React.useRef<HTMLButtonElement>(null) const menuRef = React.useRef<HTMLDivElement>(null) - const {borderColor: separatorColor} = - theme.colorScheme === 'dark' ? pal.borderDark : pal.border React.useEffect(() => { function clickHandler(e: MouseEvent) { @@ -114,14 +109,27 @@ export function NativeDropdown({ return ( <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}> - <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}> + <DropdownMenu.Trigger asChild> <Pressable ref={buttonRef as unknown as React.Ref<View>} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} accessibilityHint={accessibilityHint} - onPress={() => setOpen(o => !o)} + onPointerDown={e => { + // Prevent false positive that interpret mobile scroll as a tap. + // This requires the custom onPress handler below to compensate. + // https://github.com/radix-ui/primitives/issues/1912 + e.preventDefault() + }} + onPress={() => { + if (window.event instanceof KeyboardEvent) { + // The onPointerDown hack above is not relevant to this press, so don't do anything. + return + } + // Compensate for the disabled onPointerDown above by triggering it manually. + setOpen(o => !o) + }} hitSlop={HITSLOP_10} style={triggerStyle}> {children} @@ -129,53 +137,53 @@ export function NativeDropdown({ </DropdownMenu.Trigger> <DropdownMenu.Portal> - <DropdownMenu.Content - ref={menuRef} - style={ - StyleSheet.flatten([ - styles.content, - dropDownBackgroundColor, - ]) as React.CSSProperties - } - loop> - {items.map((item, index) => { - if (item.label === 'separator') { - return ( - <DropdownMenu.Separator - key={getKey(item.label, index, item.testID)} - style={ - StyleSheet.flatten([ - styles.separator, - {backgroundColor: separatorColor}, - ]) as React.CSSProperties - } - /> - ) - } - if (index > 1 && items[index - 1].label === 'separator') { - return ( - <DropdownMenu.Group - key={getKey(item.label, index, item.testID)}> - <DropdownMenuItem - key={getKey(item.label, index, item.testID)} - onSelect={item.onPress}> - <Text - selectable={false} - style={[pal.text, styles.itemTitle]}> - {item.label} - </Text> - {item.icon && ( - <FontAwesomeIcon - icon={item.icon.web} - size={20} - color={pal.colors.textLight} - /> - )} - </DropdownMenuItem> - </DropdownMenu.Group> - ) - } - return ( + <DropdownContent items={items} menuRef={menuRef} /> + </DropdownMenu.Portal> + </DropdownMenuRoot> + ) +} + +function DropdownContent({ + items, + menuRef, +}: { + items: DropdownItem[] + menuRef: React.RefObject<HTMLDivElement> +}) { + const pal = usePalette('default') + const theme = useTheme() + const dropDownBackgroundColor = + theme.colorScheme === 'dark' ? pal.btn : pal.view + const {borderColor: separatorColor} = + theme.colorScheme === 'dark' ? pal.borderDark : pal.border + + return ( + <DropdownMenu.Content + ref={menuRef} + style={ + StyleSheet.flatten([ + styles.content, + dropDownBackgroundColor, + ]) as React.CSSProperties + } + loop> + {items.map((item, index) => { + if (item.label === 'separator') { + return ( + <DropdownMenu.Separator + key={getKey(item.label, index, item.testID)} + style={ + StyleSheet.flatten([ + styles.separator, + {backgroundColor: separatorColor}, + ]) as React.CSSProperties + } + /> + ) + } + if (index > 1 && items[index - 1].label === 'separator') { + return ( + <DropdownMenu.Group key={getKey(item.label, index, item.testID)}> <DropdownMenuItem key={getKey(item.label, index, item.testID)} onSelect={item.onPress}> @@ -190,11 +198,27 @@ export function NativeDropdown({ /> )} </DropdownMenuItem> - ) - })} - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenuRoot> + </DropdownMenu.Group> + ) + } + return ( + <DropdownMenuItem + key={getKey(item.label, index, item.testID)} + onSelect={item.onPress}> + <Text selectable={false} style={[pal.text, styles.itemTitle]}> + {item.label} + </Text> + {item.icon && ( + <FontAwesomeIcon + icon={item.icon.web} + size={20} + color={pal.colors.textLight} + /> + )} + </DropdownMenuItem> + ) + })} + </DropdownMenu.Content> ) } diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 32520182e..ac97f3da2 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,6 +1,6 @@ import React, {memo} from 'react' import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native' -import {setStringAsync} from 'expo-clipboard' +import * as Clipboard from 'expo-clipboard' import { AppBskyActorDefs, AppBskyFeedPost, @@ -160,7 +160,7 @@ let PostDropdownBtn = ({ const onCopyPostText = React.useCallback(() => { const str = richTextToString(richText, true) - setStringAsync(str) + Clipboard.setStringAsync(str) Toast.show(_(msg`Copied to clipboard`)) }, [_, richText]) diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 7de3b093a..f6d2c7a1b 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,9 +1,10 @@ -import {AppBskyEmbedImages} from '@atproto/api' import React, {ComponentProps, FC} from 'react' -import {StyleSheet, Text, Pressable, View} from 'react-native' +import {Pressable, StyleSheet, Text, View} from 'react-native' import {Image} from 'expo-image' +import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' + import {isWeb} from 'platform/detection' type EventFunction = (index: number) => void diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index 1fe75c44e..b84c04b83 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -20,9 +20,11 @@ import {Text} from '../text/Text' export const ExternalLinkEmbed = ({ link, style, + hideAlt, }: { link: AppBskyEmbedExternal.ViewExternal style?: StyleProp<ViewStyle> + hideAlt?: boolean }) => { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() @@ -37,7 +39,7 @@ export const ExternalLinkEmbed = ({ }, [link.uri, externalEmbedPrefs]) if (embedPlayerParams?.source === 'tenor') { - return <GifEmbed params={embedPlayerParams} link={link} /> + return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} /> } return ( diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx index 5d21ce064..286b57992 100644 --- a/src/view/com/util/post-embeds/GifEmbed.tsx +++ b/src/view/com/util/post-embeds/GifEmbed.tsx @@ -1,14 +1,18 @@ import React from 'react' -import {Pressable, View} from 'react-native' +import {Pressable, StyleSheet, TouchableOpacity, View} from 'react-native' import {AppBskyEmbedExternal} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {HITSLOP_10} from '#/lib/constants' +import {isWeb} from '#/platform/detection' import {EmbedPlayerParams} from 'lib/strings/embed-player' import {useAutoplayDisabled} from 'state/preferences' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' import {GifView} from '../../../../../modules/expo-bluesky-gif-view' import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types' @@ -82,9 +86,11 @@ function PlaybackControls({ export function GifEmbed({ params, link, + hideAlt, }: { params: EmbedPlayerParams link: AppBskyEmbedExternal.ViewExternal + hideAlt?: boolean }) { const {_} = useLingui() const autoplayDisabled = useAutoplayDisabled() @@ -111,7 +117,8 @@ export function GifEmbed({ }, []) return ( - <View style={[a.rounded_sm, a.overflow_hidden, a.mt_sm]}> + <View + style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}> <View style={[ a.rounded_sm, @@ -133,9 +140,69 @@ export function GifEmbed({ onPlayerStateChange={onPlayerStateChange} ref={playerRef} accessibilityHint={_(msg`Animated GIF`)} - accessibilityLabel={link.description.replace('ALT: ', '')} + accessibilityLabel={link.description.replace('Alt text: ', '')} /> + + {!hideAlt && link.description.startsWith('Alt text: ') && ( + <AltText text={link.description.replace('Alt text: ', '')} /> + )} </View> </View> ) } + +function AltText({text}: {text: string}) { + const control = Prompt.usePromptControl() + + const {_} = useLingui() + return ( + <> + <TouchableOpacity + testID="altTextButton" + accessibilityRole="button" + accessibilityLabel={_(msg`Show alt text`)} + accessibilityHint="" + hitSlop={HITSLOP_10} + onPress={control.open} + style={styles.altContainer}> + <Text style={styles.alt} accessible={false}> + <Trans>ALT</Trans> + </Text> + </TouchableOpacity> + + <Prompt.Outer control={control}> + <Prompt.TitleText> + <Trans>Alt Text</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> + <Prompt.Actions> + <Prompt.Action + onPress={control.close} + cta={_(msg`Close`)} + color="secondary" + /> + </Prompt.Actions> + </Prompt.Outer> + </> + ) +} + +const styles = StyleSheet.create({ + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + // Related to margin/gap hack. This keeps the alt label in the same position + // on all platforms + left: isWeb ? 8 : 5, + bottom: isWeb ? 8 : 5, + zIndex: 2, + }, + alt: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, +}) diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index e0178f34b..0e19a6ccd 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -25,7 +25,7 @@ import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' import {s} from '#/lib/styles' -import {useModerationOpts} from '#/state/queries/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {InfoCircleIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' diff --git a/src/view/icons/Logo.tsx b/src/view/icons/Logo.tsx index 9212381a9..4de7c1613 100644 --- a/src/view/icons/Logo.tsx +++ b/src/view/icons/Logo.tsx @@ -1,15 +1,17 @@ import React from 'react' import {StyleSheet, TextProps} from 'react-native' import Svg, { - Path, Defs, LinearGradient, + Path, + PathProps, Stop, SvgProps, - PathProps, } from 'react-native-svg' +import {Image} from 'expo-image' import {colors} from '#/lib/styles' +import {useKawaiiMode} from '#/state/preferences/kawaii' const ratio = 57 / 64 @@ -25,6 +27,25 @@ export const Logo = React.forwardRef(function LogoImpl(props: Props, ref) { const _fill = gradient ? 'url(#sky)' : fill || styles?.color || colors.blue3 // @ts-ignore it's fiiiiine const size = parseInt(rest.width || 32) + + const isKawaii = useKawaiiMode() + + if (isKawaii) { + return ( + <Image + source={ + size > 100 + ? require('../../../assets/kawaii.png') + : require('../../../assets/kawaii_smol.png') + } + accessibilityLabel="Bluesky" + accessibilityHint="" + accessibilityIgnoresInvertColors + style={[{height: size, aspectRatio: 1.4}]} + /> + ) + } + return ( <Svg fill="none" diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index f88d500f9..442e33fd3 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -20,12 +20,12 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import {moderationOptsOverrideContext} from '#/state/preferences/moderation-opts' import {FeedNotification} from '#/state/queries/notifications/types' import { groupNotifications, shouldFilterNotif, } from '#/state/queries/notifications/util' -import {moderationOptsOverrideContext} from '#/state/queries/preferences' import {useSession} from '#/state/session' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {CenteredView, ScrollView} from '#/view/com/util/Views' 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/Profile.tsx b/src/view/screens/Profile.tsx index eb9979823..4fa46a4cf 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo} from 'react' +import React, {useMemo} from 'react' import {StyleSheet} from 'react-native' import { AppBskyActorDefs, @@ -11,12 +11,11 @@ import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {logEvent, useGate} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useLabelerInfoQuery} from '#/state/queries/labeler' import {resetProfilePostsQueries} from '#/state/queries/post-feed' -import {useModerationOpts} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useAgent, useSession} from '#/state/session' @@ -466,7 +465,6 @@ function ProfileScreenLoaded({ accessibilityHint="" /> )} - <TestGates /> </ScreenHider> ) } @@ -525,77 +523,3 @@ const styles = StyleSheet.create({ textAlign: 'center', }, }) - -const shouldExposeToGate2 = Math.random() < 0.2 - -// --- Temporary: we're testing our Statsig setup --- -let TestGates = React.memo(function TestGates() { - const gate = useGate() - - useEffect(() => { - logEvent('test:all:always', {}) - if (Math.random() < 0.2) { - logEvent('test:all:sometimes', {}) - } - if (Math.random() < 0.1) { - logEvent('test:all:boosted_by_gate1', { - reason: 'base', - }) - } - if (Math.random() < 0.1) { - logEvent('test:all:boosted_by_gate2', { - reason: 'base', - }) - } - if (Math.random() < 0.1) { - logEvent('test:all:boosted_by_both', { - reason: 'base', - }) - } - }, []) - - return [ - gate('test_gate_1') ? <TestGate1 /> : null, - shouldExposeToGate2 && gate('test_gate_2') ? <TestGate2 /> : null, - ] -}) - -function TestGate1() { - useEffect(() => { - logEvent('test:gate1:always', {}) - if (Math.random() < 0.2) { - logEvent('test:gate1:sometimes', {}) - } - if (Math.random() < 0.5) { - logEvent('test:all:boosted_by_gate1', { - reason: 'gate1', - }) - } - if (Math.random() < 0.5) { - logEvent('test:all:boosted_by_both', { - reason: 'gate1', - }) - } - }, []) - return null -} - -function TestGate2() { - useEffect(() => { - logEvent('test:gate2:always', {}) - if (Math.random() < 0.2) { - logEvent('test:gate2:sometimes', {}) - } - if (Math.random() < 0.5) { - logEvent('test:all:boosted_by_gate2', { - reason: 'gate2', - }) - } - if (Math.random() < 0.5) { - logEvent('test:all:boosted_by_both', { - reason: 'gate2', - }) - } - }, []) - return null -} 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/Search/Search.tsx b/src/view/screens/Search/Search.tsx index ee9e69433..9dd1c397f 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -25,11 +25,11 @@ import {NavigationProp} from '#/lib/routes/types' import {augmentSearchQuery} from '#/lib/strings/helpers' import {s} from '#/lib/styles' import {logger} from '#/logger' -import {isNative, isWeb} from '#/platform/detection' +import {isIOS, isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorSearch} from '#/state/queries/actor-search' -import {useModerationOpts} from '#/state/queries/preferences' import {useSearchPostsQuery} from '#/state/queries/search-posts' import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' @@ -49,11 +49,7 @@ import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {List} from '#/view/com/util/List' import {Text} from '#/view/com/util/text/Text' import {CenteredView, ScrollView} from '#/view/com/util/Views' -import { - MATCH_HANDLE, - SearchLinkCard, - SearchProfileCard, -} from '#/view/shell/desktop/Search' +import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {atoms as a} from '#/alf' @@ -156,7 +152,7 @@ function useSuggestedFollows(): [ return [items, onEndReached] } -function SearchScreenSuggestedFollows() { +let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { const pal = usePalette('default') const [suggestions, onEndReached] = useSuggestedFollows() @@ -180,6 +176,7 @@ function SearchScreenSuggestedFollows() { </CenteredView> ) } +SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) type SearchResultSlice = | { @@ -192,7 +189,7 @@ type SearchResultSlice = key: string } -function SearchScreenPostResults({ +let SearchScreenPostResults = ({ query, sort, active, @@ -200,7 +197,7 @@ function SearchScreenPostResults({ query: string sort?: 'top' | 'latest' active: boolean -}) { +}): React.ReactNode => { const {_} = useLingui() const {currentAccount} = useSession() const [isPTR, setIsPTR] = React.useState(false) @@ -298,14 +295,15 @@ function SearchScreenPostResults({ </> ) } +SearchScreenPostResults = React.memo(SearchScreenPostResults) -function SearchScreenUserResults({ +let SearchScreenUserResults = ({ query, active, }: { query: string active: boolean -}) { +}): React.ReactNode => { const {_} = useLingui() const {data: results, isFetched} = useActorSearch({ @@ -334,8 +332,9 @@ function SearchScreenUserResults({ <Loader /> ) } +SearchScreenUserResults = React.memo(SearchScreenUserResults) -export function SearchScreenInner({query}: {query?: string}) { +let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -467,18 +466,17 @@ export function SearchScreenInner({query}: {query?: string}) { </CenteredView> ) } +SearchScreenInner = React.memo(SearchScreenInner) export function SearchScreen( props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, ) { const navigation = useNavigation<NavigationProp>() - const theme = useTheme() const textInput = React.useRef<TextInput>(null) const {_} = useLingui() const pal = usePalette('default') const {track} = useAnalytics() const setDrawerOpen = useSetDrawerOpen() - const moderationOpts = useModerationOpts() const setMinimalShellMode = useSetMinimalShellMode() const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() @@ -527,23 +525,10 @@ export function SearchScreen( const onPressCancelSearch = React.useCallback(() => { scrollToTopWeb() - - if (showAutocomplete) { - textInput.current?.blur() - setShowAutocomplete(false) - setSearchText(queryParam) - } else { - // If we just `setParams` and set `q` to an empty string, the URL still displays `q=`, which isn't pretty. - // However, `.replace()` on native has a "push" animation that we don't want. So we need to handle these - // differently. - if (isWeb) { - navigation.replace('Search', {}) - } else { - setSearchText('') - navigation.setParams({q: ''}) - } - } - }, [showAutocomplete, navigation, queryParam]) + textInput.current?.blur() + setShowAutocomplete(false) + setSearchText(queryParam) + }, [queryParam]) const onChangeText = React.useCallback(async (text: string) => { scrollToTopWeb() @@ -597,20 +582,31 @@ export function SearchScreen( navigateToItem(searchText) }, [navigateToItem, searchText]) - const handleHistoryItemClick = (item: string) => { - setSearchText(item) - navigateToItem(item) - } + const onAutocompleteResultPress = React.useCallback(() => { + if (isWeb) { + setShowAutocomplete(false) + } else { + textInput.current?.blur() + } + }, []) - const onSoftReset = React.useCallback(() => { - scrollToTopWeb() - onPressCancelSearch() - }, [onPressCancelSearch]) + const handleHistoryItemClick = React.useCallback( + (item: string) => { + setSearchText(item) + navigateToItem(item) + }, + [navigateToItem], + ) - const queryMaybeHandle = React.useMemo(() => { - const match = MATCH_HANDLE.exec(queryParam) - return match && match[1] - }, [queryParam]) + const onSoftReset = React.useCallback(() => { + if (isWeb) { + // Empty params resets the URL to be /search rather than /search?q= + navigation.replace('Search', {}) + } else { + setSearchText('') + navigation.setParams({q: ''}) + } + }, [navigation]) useFocusEffect( React.useCallback(() => { @@ -619,15 +615,19 @@ export function SearchScreen( }, [onSoftReset, setMinimalShellMode]), ) - const handleRemoveHistoryItem = (itemToRemove: string) => { - const updatedHistory = searchHistory.filter(item => item !== itemToRemove) - setSearchHistory(updatedHistory) - AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( - e => { + const handleRemoveHistoryItem = React.useCallback( + (itemToRemove: string) => { + const updatedHistory = searchHistory.filter(item => item !== itemToRemove) + setSearchHistory(updatedHistory) + AsyncStorage.setItem( + 'searchHistory', + JSON.stringify(updatedHistory), + ).catch(e => { logger.error('Failed to update search history', {message: e}) - }, - ) - } + }) + }, + [searchHistory], + ) return ( <View style={isWeb ? null : {flex: 1}}> @@ -655,175 +655,269 @@ export function SearchScreen( /> </Pressable> )} - - <View - style={[ - {backgroundColor: pal.colors.backgroundLight}, - styles.headerSearchContainer, - ]}> - <MagnifyingGlassIcon - style={[pal.icon, styles.headerSearchIcon]} - size={21} - /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder={_(msg`Search`)} - placeholderTextColor={pal.colors.textLight} - selectTextOnFocus={isNative} - returnKeyType="search" - value={searchText} - style={[pal.text, styles.headerSearchInput]} - keyboardAppearance={theme.colorScheme} - onFocus={() => { - if (isWeb) { - // Prevent a jump on iPad by ensuring that - // the initial focused render has no result list. - requestAnimationFrame(() => { - setShowAutocomplete(true) - }) - } else { - setShowAutocomplete(true) - } - }} - onChangeText={onChangeText} - onSubmitEditing={onSubmit} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - /> - {showAutocomplete ? ( - <Pressable - testID="searchTextInputClearBtn" - onPress={onPressClearQuery} - accessibilityRole="button" - accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint="" - hitSlop={HITSLOP_10}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </Pressable> - ) : undefined} - </View> - - {(queryParam || showAutocomplete) && ( - <View style={styles.headerCancelBtn}> + <SearchInputBox + textInput={textInput} + searchText={searchText} + showAutocomplete={showAutocomplete} + setShowAutocomplete={setShowAutocomplete} + onChangeText={onChangeText} + onSubmit={onSubmit} + onPressClearQuery={onPressClearQuery} + /> + {showAutocomplete && ( + <View style={[styles.headerCancelBtn]}> <Pressable onPress={onPressCancelSearch} accessibilityRole="button" hitSlop={HITSLOP_10}> - <Text style={[pal.text]}> + <Text style={pal.text}> <Trans>Cancel</Trans> </Text> </Pressable> </View> )} </CenteredView> + <View + style={{ + display: showAutocomplete ? 'flex' : 'none', + flex: 1, + }}> + {searchText.length > 0 ? ( + <AutocompleteResults + isAutocompleteFetching={isAutocompleteFetching} + autocompleteData={autocompleteData} + searchText={searchText} + onSubmit={onSubmit} + onResultPress={onAutocompleteResultPress} + /> + ) : ( + <SearchHistory + searchHistory={searchHistory} + onItemClick={handleHistoryItemClick} + onRemoveItemClick={handleRemoveHistoryItem} + /> + )} + </View> + <View + style={{ + display: showAutocomplete ? 'none' : 'flex', + flex: 1, + }}> + <SearchScreenInner query={queryParam} /> + </View> + </View> + ) +} - {showAutocomplete && searchText.length > 0 ? ( - <> - {(isAutocompleteFetching && !autocompleteData?.length) || - !moderationOpts ? ( - <Loader /> - ) : ( - <ScrollView - style={{height: '100%'}} - // @ts-ignore web only -prf - dataSet={{stableGutters: '1'}} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag"> - <SearchLinkCard - label={_(msg`Search for "${searchText}"`)} - onPress={isNative ? onSubmit : undefined} - to={ - isNative - ? undefined - : `/search?q=${encodeURIComponent(searchText)}` - } - style={{borderBottomWidth: 1}} - /> - - {queryMaybeHandle ? ( - <SearchLinkCard - label={_(msg`Go to @${queryMaybeHandle}`)} - to={`/profile/${queryMaybeHandle}`} - /> - ) : null} - - {autocompleteData?.map(item => ( - <SearchProfileCard - key={item.did} - profile={item} - moderation={moderateProfile(item, moderationOpts)} - onPress={() => { - if (isWeb) { - setShowAutocomplete(false) - } else { - textInput.current?.blur() - } - }} - /> - ))} - - <View style={{height: 200}} /> - </ScrollView> - )} - </> - ) : !queryParam && showAutocomplete ? ( - <CenteredView - sideBorders={isTabletOrDesktop} +let SearchInputBox = ({ + textInput, + searchText, + showAutocomplete, + setShowAutocomplete, + onChangeText, + onSubmit, + onPressClearQuery, +}: { + textInput: React.RefObject<TextInput> + searchText: string + showAutocomplete: boolean + setShowAutocomplete: (show: boolean) => void + onChangeText: (text: string) => void + onSubmit: () => void + onPressClearQuery: () => void +}): React.ReactNode => { + const pal = usePalette('default') + const {_} = useLingui() + const theme = useTheme() + return ( + <Pressable + // This only exists only for extra hitslop so don't expose it to the a11y tree. + accessible={false} + focusable={false} + // @ts-ignore web-only + tabIndex={-1} + style={[ + {backgroundColor: pal.colors.backgroundLight}, + styles.headerSearchContainer, + isWeb && { + // @ts-ignore web only + cursor: 'default', + }, + ]} + onPress={() => { + textInput.current?.focus() + }}> + <MagnifyingGlassIcon + style={[pal.icon, styles.headerSearchIcon]} + size={21} + /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder={_(msg`Search`)} + placeholderTextColor={pal.colors.textLight} + returnKeyType="search" + value={searchText} + style={[pal.text, styles.headerSearchInput]} + keyboardAppearance={theme.colorScheme} + selectTextOnFocus={isNative} + onFocus={() => { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + if (isIOS) { + // We rely on selectTextOnFocus, but it's broken on iOS: + // https://github.com/facebook/react-native/issues/41988 + textInput.current?.setSelection(0, searchText.length) + // We still rely on selectTextOnFocus for it to be instant on Android. + } + } + }} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + /> + {showAutocomplete && searchText.length > 0 && ( + <Pressable + testID="searchTextInputClearBtn" + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel={_(msg`Clear search query`)} + accessibilityHint="" + hitSlop={HITSLOP_10}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + )} + </Pressable> + ) +} +SearchInputBox = React.memo(SearchInputBox) + +let AutocompleteResults = ({ + isAutocompleteFetching, + autocompleteData, + searchText, + onSubmit, + onResultPress, +}: { + isAutocompleteFetching: boolean + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined + searchText: string + onSubmit: () => void + onResultPress: () => void +}): React.ReactNode => { + const moderationOpts = useModerationOpts() + const {_} = useLingui() + return ( + <> + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( + <Loader /> + ) : ( + <ScrollView + style={{height: '100%'}} // @ts-ignore web only -prf - style={{ - height: isWeb ? '100vh' : undefined, - }}> - <View style={styles.searchHistoryContainer}> - {searchHistory.length > 0 && ( - <View style={styles.searchHistoryContent}> - <Text style={[pal.text, styles.searchHistoryTitle]}> - <Trans>Recent Searches</Trans> - </Text> - {searchHistory.map((historyItem, index) => ( - <View - key={index} - style={[ - a.flex_row, - a.mt_md, - a.justify_center, - a.justify_between, - ]}> - <Pressable - accessibilityRole="button" - onPress={() => handleHistoryItemClick(historyItem)} - style={[a.flex_1, a.py_sm]}> - <Text style={pal.text}>{historyItem}</Text> - </Pressable> - <Pressable - accessibilityRole="button" - onPress={() => handleRemoveHistoryItem(historyItem)} - style={[a.px_md, a.py_xs, a.justify_center]}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </Pressable> - </View> - ))} + dataSet={{stableGutters: '1'}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag"> + <SearchLinkCard + label={_(msg`Search for "${searchText}"`)} + onPress={isNative ? onSubmit : undefined} + to={ + isNative + ? undefined + : `/search?q=${encodeURIComponent(searchText)}` + } + style={{borderBottomWidth: 1}} + /> + {autocompleteData?.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + onPress={onResultPress} + /> + ))} + <View style={{height: 200}} /> + </ScrollView> + )} + </> + ) +} +AutocompleteResults = React.memo(AutocompleteResults) + +function SearchHistory({ + searchHistory, + onItemClick, + onRemoveItemClick, +}: { + searchHistory: string[] + onItemClick: (item: string) => void + onRemoveItemClick: (item: string) => void +}) { + const {isTabletOrDesktop} = useWebMediaQueries() + const pal = usePalette('default') + return ( + <CenteredView + sideBorders={isTabletOrDesktop} + // @ts-ignore web only -prf + style={{ + height: isWeb ? '100vh' : undefined, + }}> + <View style={styles.searchHistoryContainer}> + {searchHistory.length > 0 && ( + <View style={styles.searchHistoryContent}> + <Text style={[pal.text, styles.searchHistoryTitle]}> + <Trans>Recent Searches</Trans> + </Text> + {searchHistory.map((historyItem, index) => ( + <View + key={index} + style={[ + a.flex_row, + a.mt_md, + a.justify_center, + a.justify_between, + ]}> + <Pressable + accessibilityRole="button" + onPress={() => onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_sm]}> + <Text style={pal.text}>{historyItem}</Text> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => onRemoveItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.px_md, a.py_xs, a.justify_center]}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> </View> - )} + ))} </View> - </CenteredView> - ) : ( - <SearchScreenInner query={queryParam} /> - )} - </View> + )} + </View> + </CenteredView> ) } @@ -874,6 +968,9 @@ const styles = StyleSheet.create({ }, headerCancelBtn: { paddingLeft: 10, + alignSelf: 'center', + zIndex: -1, + elevation: -1, // For Android }, tabBarContainer: { // @ts-ignore web only diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 6b5390c29..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' @@ -61,23 +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 {useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 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} @@ -109,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> @@ -135,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> ) @@ -162,12 +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() + const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() + const isSwitchingAccounts = !!pendingDid // const primaryBg = useCustomPalette<ViewStyle>({ // light: {backgroundColor: colors.blue0}, @@ -238,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`)) }, [_]) @@ -275,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]) @@ -363,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!} /> - )} + <View pointerEvents={pendingDid ? 'none' : 'auto'}> + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SettingsAccountCard + key={account.did} + account={account} + onPressSwitchAccount={onPressSwitchAccount} + pendingDid={pendingDid} + /> + ))} - {accounts - .filter(a => a.did !== currentAccount?.did) - .map(account => ( - <SettingsAccountCard key={account.did} account={account} /> - ))} - - <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} /> @@ -849,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> @@ -881,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> @@ -1026,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 8145fa408..d8e604ec3 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -18,6 +18,7 @@ import {useLingui} from '@lingui/react' import {StackActions, useNavigation} from '@react-navigation/native' import {emitSoftReset} from '#/state/events' +import {useKawaiiMode} from '#/state/preferences/kawaii' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfileQuery} from '#/state/queries/profile' import {SessionAccount, useSession} from '#/state/session' @@ -117,6 +118,7 @@ let DrawerContent = ({}: {}): React.ReactNode => { const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {hasSession, currentAccount} = useSession() + const kawaii = useKawaiiMode() // events // = @@ -262,6 +264,17 @@ let DrawerContent = ({}: {}): React.ReactNode => { href="https://bsky.social/about/support/privacy-policy" text={_(msg`Privacy Policy`)} /> + {kawaii && ( + <Text type="md" style={pal.textLight}> + Logo by{' '} + <TextLink + type="md" + href="/profile/sawaratsuki.bsky.social" + text="@sawaratsuki.bsky.social" + style={pal.link} + /> + </Text> + )} </View> <View style={styles.smallSpacer} /> diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index c1f498724..f0cd4f59a 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -1,22 +1,26 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {DesktopSearch} from './Search' -import {DesktopFeeds} from './Feeds' -import {Text} from 'view/com/util/text/Text' -import {TextLink} from 'view/com/util/Link' -import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' -import {s} from 'lib/styles' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useKawaiiMode} from '#/state/preferences/kawaii' import {useSession} from '#/state/session' +import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {s} from 'lib/styles' +import {TextLink} from 'view/com/util/Link' +import {Text} from 'view/com/util/text/Text' +import {DesktopFeeds} from './Feeds' +import {DesktopSearch} from './Search' export function DesktopRightNav({routeName}: {routeName: string}) { const pal = usePalette('default') const {_} = useLingui() const {hasSession, currentAccount} = useSession() + const kawaii = useKawaiiMode() + const {isTablet} = useWebMediaQueries() if (isTablet) { return null @@ -90,6 +94,17 @@ export function DesktopRightNav({routeName}: {routeName: string}) { text={_(msg`Help`)} /> </View> + {kawaii && ( + <Text type="md" style={[pal.textLight, {marginTop: 12}]}> + Logo by{' '} + <TextLink + type="md" + href="/profile/sawaratsuki.bsky.social" + text="@sawaratsuki.bsky.social" + style={pal.link} + /> + </Text> + )} </View> </View> </View> diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 52f28cc63..3829a6c0b 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -21,8 +21,8 @@ import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {s} from '#/lib/styles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' -import {useModerationOpts} from '#/state/queries/preferences' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon2} from 'lib/icons' import {NavigationProp} from 'lib/routes/types' @@ -31,10 +31,7 @@ import {Link} from '#/view/com/util/Link' import {UserAvatar} from '#/view/com/util/UserAvatar' import {Text} from 'view/com/util/text/Text' -export const MATCH_HANDLE = - /@?([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))/ - -export function SearchLinkCard({ +let SearchLinkCard = ({ label, to, onPress, @@ -44,7 +41,7 @@ export function SearchLinkCard({ to?: string onPress?: () => void style?: ViewStyle -}) { +}): React.ReactNode => { const pal = usePalette('default') const inner = ( @@ -82,8 +79,10 @@ export function SearchLinkCard({ </Link> ) } +SearchLinkCard = React.memo(SearchLinkCard) +export {SearchLinkCard} -export function SearchProfileCard({ +let SearchProfileCard = ({ profile, moderation, onPress: onPressInner, @@ -91,7 +90,7 @@ export function SearchProfileCard({ profile: AppBskyActorDefs.ProfileViewBasic moderation: ModerationDecision onPress: () => void -}) { +}): React.ReactNode => { const pal = usePalette('default') const queryClient = useQueryClient() @@ -144,6 +143,8 @@ export function SearchProfileCard({ </Link> ) } +SearchProfileCard = React.memo(SearchProfileCard) +export {SearchProfileCard} export function DesktopSearch() { const {_} = useLingui() @@ -179,11 +180,6 @@ export function DesktopSearch() { setIsActive(false) }, []) - const queryMaybeHandle = React.useMemo(() => { - const match = MATCH_HANDLE.exec(query) - return match && match[1] - }, [query]) - return ( <View style={[styles.container, pal.view]}> <View @@ -239,19 +235,11 @@ export function DesktopSearch() { label={_(msg`Search for "${query}"`)} to={`/search?q=${encodeURIComponent(query)}`} style={ - queryMaybeHandle || (autocompleteData?.length ?? 0) > 0 + (autocompleteData?.length ?? 0) > 0 ? {borderBottomWidth: 1} : undefined } /> - - {queryMaybeHandle ? ( - <SearchLinkCard - label={_(msg`Go to @${queryMaybeHandle}`)} - to={`/profile/${queryMaybeHandle}`} - /> - ) : null} - {autocompleteData?.map(item => ( <SearchProfileCard key={item.did} |