diff options
Diffstat (limited to 'src/screens/Search/components')
-rw-r--r-- | src/screens/Search/components/AutocompleteResults.tsx | 71 | ||||
-rw-r--r-- | src/screens/Search/components/ExploreRecommendations.tsx | 117 | ||||
-rw-r--r-- | src/screens/Search/components/ExploreTrendingTopics.tsx | 142 | ||||
-rw-r--r-- | src/screens/Search/components/ExploreTrendingVideos.tsx | 271 | ||||
-rw-r--r-- | src/screens/Search/components/ModuleHeader.tsx | 170 | ||||
-rw-r--r-- | src/screens/Search/components/SearchHistory.tsx | 169 | ||||
-rw-r--r-- | src/screens/Search/components/SearchLanguageDropdown.tsx | 120 | ||||
-rw-r--r-- | src/screens/Search/components/StarterPackCard.tsx | 296 |
8 files changed, 826 insertions, 530 deletions
diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx new file mode 100644 index 000000000..58a0dec77 --- /dev/null +++ b/src/screens/Search/components/AutocompleteResults.tsx @@ -0,0 +1,71 @@ +import {memo} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isNative} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' +import {atoms as a, native} from '#/alf' +import * as Layout from '#/components/Layout' + +let AutocompleteResults = ({ + isAutocompleteFetching, + autocompleteData, + searchText, + onSubmit, + onResultPress, + onProfileClick, +}: { + isAutocompleteFetching: boolean + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined + searchText: string + onSubmit: () => void + onResultPress: () => void + onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void +}): React.ReactNode => { + const moderationOpts = useModerationOpts() + const {_} = useLingui() + return ( + <> + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( + <Layout.Content> + <View style={[a.py_xl]}> + <ActivityIndicator /> + </View> + </Layout.Content> + ) : ( + <Layout.Content + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag"> + <SearchLinkCard + label={_(msg`Search for "${searchText}"`)} + onPress={native(onSubmit)} + to={ + isNative + ? undefined + : `/search?q=${encodeURIComponent(searchText)}` + } + style={{borderBottomWidth: 1}} + /> + {autocompleteData?.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + onPress={() => { + onProfileClick(item) + onResultPress() + }} + /> + ))} + <View style={{height: 200}} /> + </Layout.Content> + )} + </> + ) +} +AutocompleteResults = memo(AutocompleteResults) +export {AutocompleteResults} diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx deleted file mode 100644 index 602bab87d..000000000 --- a/src/screens/Search/components/ExploreRecommendations.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import {View} from 'react-native' -import {AppBskyUnspeccedDefs} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import { - DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, - useTrendingTopics, -} from '#/state/queries/trending/useTrendingTopics' -import {useTrendingConfig} from '#/state/trending-config' -import {atoms as a, useGutters, useTheme} from '#/alf' -import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' -import { - TrendingTopic, - TrendingTopicLink, - TrendingTopicSkeleton, -} from '#/components/TrendingTopics' -import {Text} from '#/components/Typography' - -export function ExploreRecommendations() { - const {enabled} = useTrendingConfig() - return enabled ? <Inner /> : null -} - -function Inner() { - const t = useTheme() - const gutters = useGutters([0, 'compact']) - const {data: trending, error, isLoading} = useTrendingTopics() - const noRecs = !isLoading && !error && !trending?.suggested?.length - const allFeeds = trending?.suggested && isAllFeeds(trending.suggested) - - return error || noRecs ? null : ( - <> - <View - style={[ - a.flex_row, - isWeb - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] - : [a.p_lg, a.pt_2xl, a.gap_md], - a.border_b, - t.atoms.border_contrast_low, - ]}> - <View style={[a.flex_1, a.gap_sm]}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Hashtag_Stroke2_Corner0_Rounded - size="lg" - fill={t.palette.primary_500} - style={{marginLeft: -2}} - /> - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> - <Trans>Recommended</Trans> - </Text> - </View> - {!allFeeds ? ( - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - <Trans> - Content from across the network we think you might like. - </Trans> - </Text> - ) : ( - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - <Trans>Feeds we think you might like.</Trans> - </Text> - )} - </View> - </View> - - <View style={[a.pt_md, a.pb_lg]}> - <View - style={[ - a.flex_row, - a.justify_start, - a.flex_wrap, - {rowGap: 8, columnGap: 6}, - gutters, - ]}> - {isLoading ? ( - Array(RECOMMENDATIONS_COUNT) - .fill(0) - .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) - ) : !trending?.suggested ? null : ( - <> - {trending.suggested.map(topic => ( - <TrendingTopicLink - key={topic.link} - topic={topic} - onPress={() => { - logEvent('recommendedTopic:click', {context: 'explore'}) - }}> - {({hovered}) => ( - <TrendingTopic - topic={topic} - style={[ - hovered && [ - t.atoms.border_contrast_high, - t.atoms.bg_contrast_25, - ], - ]} - /> - )} - </TrendingTopicLink> - ))} - </> - )} - </View> - </View> - </> - ) -} - -function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) { - return topics.every(topic => { - const segments = topic.link.split('/').slice(1) - return segments[0] === 'profile' && segments[2] === 'feed' - }) -} diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx deleted file mode 100644 index a010ad8dc..000000000 --- a/src/screens/Search/components/ExploreTrendingTopics.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import { - useTrendingSettings, - useTrendingSettingsApi, -} from '#/state/preferences/trending' -import { - DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, - useTrendingTopics, -} from '#/state/queries/trending/useTrendingTopics' -import {useTrendingConfig} from '#/state/trending-config' -import {atoms as a, tokens, useGutters, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {GradientFill} from '#/components/GradientFill' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' -import * as Prompt from '#/components/Prompt' -import { - TrendingTopic, - TrendingTopicLink, - TrendingTopicSkeleton, -} from '#/components/TrendingTopics' -import {Text} from '#/components/Typography' - -export function ExploreTrendingTopics() { - const {enabled} = useTrendingConfig() - const {trendingDisabled} = useTrendingSettings() - return enabled && !trendingDisabled ? <Inner /> : null -} - -function Inner() { - const t = useTheme() - const {_} = useLingui() - const gutters = useGutters([0, 'compact']) - const {data: trending, error, isLoading} = useTrendingTopics() - const noTopics = !isLoading && !error && !trending?.topics?.length - const {setTrendingDisabled} = useTrendingSettingsApi() - const trendingPrompt = Prompt.usePromptControl() - - const onConfirmHide = React.useCallback(() => { - logEvent('trendingTopics:hide', {context: 'explore:trending'}) - setTrendingDisabled(true) - }, [setTrendingDisabled]) - - return error || noTopics ? null : ( - <> - <View - style={[ - a.flex_row, - isWeb - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] - : [a.p_lg, a.pt_2xl, a.gap_md], - a.border_b, - t.atoms.border_contrast_low, - ]}> - <View style={[a.flex_1, a.gap_sm]}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Trending - size="lg" - fill={t.palette.primary_500} - style={{marginLeft: -2}} - /> - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> - <Trans>Trending</Trans> - </Text> - <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> - <GradientFill gradient={tokens.gradients.primary} /> - <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> - <Trans>BETA</Trans> - </Text> - </View> - </View> - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - <Trans>What people are posting about.</Trans> - </Text> - </View> - <Button - label={_(msg`Hide trending topics`)} - size="small" - variant="ghost" - color="secondary" - shape="round" - onPress={() => trendingPrompt.open()}> - <ButtonIcon icon={X} /> - </Button> - </View> - - <View style={[a.pt_md, a.pb_lg]}> - <View - style={[ - a.flex_row, - a.justify_start, - a.flex_wrap, - {rowGap: 8, columnGap: 6}, - gutters, - ]}> - {isLoading ? ( - Array(TRENDING_TOPICS_COUNT) - .fill(0) - .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) - ) : !trending?.topics ? null : ( - <> - {trending.topics.map(topic => ( - <TrendingTopicLink - key={topic.link} - topic={topic} - onPress={() => { - logEvent('trendingTopic:click', {context: 'explore'}) - }}> - {({hovered}) => ( - <TrendingTopic - topic={topic} - style={[ - hovered && [ - t.atoms.border_contrast_high, - t.atoms.bg_contrast_25, - ], - ]} - /> - )} - </TrendingTopicLink> - ))} - </> - )} - </View> - </View> - - <Prompt.Basic - control={trendingPrompt} - title={_(msg`Hide trending topics?`)} - description={_(msg`You can update this later from your settings.`)} - confirmButtonCta={_(msg`Hide`)} - onConfirm={onConfirmHide} - /> - </> - ) -} diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/components/ExploreTrendingVideos.tsx deleted file mode 100644 index 00fa76dbf..000000000 --- a/src/screens/Search/components/ExploreTrendingVideos.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import React from 'react' -import {ScrollView, View} from 'react-native' -import {AppBskyEmbedVideo, AtUri} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {VIDEO_FEED_URI} from '#/lib/constants' -import {makeCustomFeedLink} from '#/lib/routes/links' -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import {useSavedFeeds} from '#/state/queries/feed' -import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' -import {useAddSavedFeedsMutation} from '#/state/queries/preferences' -import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' -import {atoms as a, tokens, useGutters, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {GradientFill} from '#/components/GradientFill' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' -import {Link} from '#/components/Link' -import {Text} from '#/components/Typography' -import { - CompactVideoPostCard, - CompactVideoPostCardPlaceholder, -} from '#/components/VideoPostCard' - -const CARD_WIDTH = 100 - -const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` -const FEED_PARAMS: { - feedCacheKey: 'explore' -} = { - feedCacheKey: 'explore', -} - -export function ExploreTrendingVideos() { - const t = useTheme() - const {_} = useLingui() - const gutters = useGutters([0, 'base']) - const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) - - // Refetch on tab change if nothing else is using this query. - const queryClient = useQueryClient() - useFocusEffect(() => { - return () => { - const query = queryClient - .getQueryCache() - .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) - if (query && query.getObserversCount() <= 1) { - query.fetch() - } - } - }) - - const {data: saved} = useSavedFeeds() - const isSavedAlready = React.useMemo(() => { - return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) - }, [saved]) - - const {mutateAsync: addSavedFeeds, isPending: isPinPending} = - useAddSavedFeedsMutation() - const pinFeed = React.useCallback( - (e: any) => { - e.preventDefault() - - addSavedFeeds([ - { - type: 'feed', - value: VIDEO_FEED_URI, - pinned: true, - }, - ]) - - // prevent navigation - return false - }, - [addSavedFeeds], - ) - - if (error) { - return null - } - - return ( - <View style={[a.pb_xl]}> - <View - style={[ - a.flex_row, - isWeb - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] - : [a.p_lg, a.pt_xl, a.gap_md], - a.border_b, - t.atoms.border_contrast_low, - ]}> - <View style={[a.flex_1, a.gap_sm]}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Graph - size="lg" - fill={t.palette.primary_500} - style={{marginLeft: -2}} - /> - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> - <Trans>Trending Videos</Trans> - </Text> - <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> - <GradientFill gradient={tokens.gradients.primary} /> - <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> - <Trans>BETA</Trans> - </Text> - </View> - </View> - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - <Trans>Popular videos in your network.</Trans> - </Text> - </View> - </View> - - <BlockDrawerGesture> - <ScrollView - horizontal - showsHorizontalScrollIndicator={false} - decelerationRate="fast" - snapToInterval={CARD_WIDTH + tokens.space.sm}> - <View - style={[ - a.pt_lg, - a.flex_row, - a.gap_sm, - { - paddingLeft: gutters.paddingLeft, - paddingRight: gutters.paddingRight, - }, - ]}> - {isLoading ? ( - Array(10) - .fill(0) - .map((_, i) => ( - <View key={i} style={[{width: CARD_WIDTH}]}> - <CompactVideoPostCardPlaceholder /> - </View> - )) - ) : error || !data ? ( - <Text> - <Trans>Whoops! Trending videos failed to load.</Trans> - </Text> - ) : ( - <VideoCards data={data} /> - )} - </View> - </ScrollView> - </BlockDrawerGesture> - - {!isSavedAlready && ( - <View - style={[ - gutters, - a.pt_lg, - a.flex_row, - a.align_center, - a.justify_between, - a.gap_xl, - ]}> - <Text style={[a.flex_1, a.text_sm, a.leading_snug]}> - <Trans> - Pin the trending videos feed to your home screen for easy access - </Trans> - </Text> - <Button - disabled={isPinPending} - label={_(msg`Pin`)} - size="small" - variant="outline" - color="secondary" - onPress={pinFeed}> - <ButtonText>{_(msg`Pin`)}</ButtonText> - <ButtonIcon icon={Pin} position="right" /> - </Button> - </View> - )} - </View> - ) -} - -function VideoCards({ - data, -}: { - data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined> -}) { - const t = useTheme() - const {_} = useLingui() - const items = React.useMemo(() => { - return data.pages - .flatMap(page => page.slices) - .map(slice => slice.items[0]) - .filter(Boolean) - .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) - .slice(0, 8) - }, [data]) - const href = React.useMemo(() => { - const urip = new AtUri(VIDEO_FEED_URI) - return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') - }, []) - - return ( - <> - {items.map(item => ( - <View key={item.post.uri} style={[{width: CARD_WIDTH}]}> - <CompactVideoPostCard - post={item.post} - moderation={item.moderation} - sourceContext={{ - type: 'feedgen', - uri: VIDEO_FEED_URI, - sourceInterstitial: 'explore', - }} - onInteract={() => { - logEvent('videoCard:click', { - context: 'interstitial:explore', - }) - }} - /> - </View> - ))} - - <View style={[{width: CARD_WIDTH * 2}]}> - <Link - to={href} - label={_(msg`View more`)} - style={[ - a.justify_center, - a.align_center, - a.flex_1, - a.rounded_md, - t.atoms.bg_contrast_25, - ]}> - {({pressed}) => ( - <View - style={[ - a.flex_row, - a.align_center, - a.gap_md, - { - opacity: pressed ? 0.6 : 1, - }, - ]}> - <Text style={[a.text_md]}> - <Trans>View more</Trans> - </Text> - <View - style={[ - a.align_center, - a.justify_center, - a.rounded_full, - { - width: 34, - height: 34, - backgroundColor: t.palette.primary_500, - }, - ]}> - <ButtonIcon icon={ChevronRight} /> - </View> - </View> - )} - </Link> - </View> - </> - ) -} diff --git a/src/screens/Search/components/ModuleHeader.tsx b/src/screens/Search/components/ModuleHeader.tsx new file mode 100644 index 000000000..cbd0a856b --- /dev/null +++ b/src/screens/Search/components/ModuleHeader.tsx @@ -0,0 +1,170 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {type AppBskyFeedDefs, AtUri} from '@atproto/api' + +import {PressableScale} from '#/lib/custom-animations/PressableScale' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {logger} from '#/logger' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import { + atoms as a, + native, + useGutters, + useTheme, + type ViewStyleProp, + web, +} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {sizes as iconSizes} from '#/components/icons/common' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' +import {Link} from '#/components/Link' +import {Text, type TextProps} from '#/components/Typography' + +export function Container({ + style, + children, + headerHeight, +}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) { + const t = useTheme() + const gutters = useGutters([0, 'base']) + return ( + <View + style={[ + gutters, + a.flex_row, + a.align_center, + a.pt_2xl, + a.pb_md, + a.gap_sm, + t.atoms.bg, + headerHeight && web({position: 'sticky', top: headerHeight}), + style, + ]}> + {children} + </View> + ) +} + +export function FeedLink({ + feed, + children, +}: { + feed: AppBskyFeedDefs.GeneratorView + children?: React.ReactNode +}) { + const t = useTheme() + const {host: did, rkey} = useMemo(() => new AtUri(feed.uri), [feed.uri]) + return ( + <Link + to={makeCustomFeedLink(did, rkey)} + label={feed.displayName} + style={[a.flex_1]}> + {({focused, hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + {gap: 10}, + a.rounded_md, + a.p_xs, + {marginLeft: -6}, + (focused || hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + {children} + </View> + )} + </Link> + ) +} + +export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return <UserAvatar type="algo" size={38} avatar={feed.avatar} /> +} + +export function Icon({ + icon: Comp, + size = 'lg', +}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) { + const iconSize = iconSizes[size] + + return ( + <View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}> + <Comp width={iconSize} /> + </View> + ) +} + +export function TitleText({style, ...props}: TextProps) { + return ( + <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} emoji {...props} /> + ) +} + +export function SubtitleText({style, ...props}: TextProps) { + const t = useTheme() + return ( + <Text + style={[ + t.atoms.text_contrast_medium, + a.leading_tight, + a.flex_1, + a.text_sm, + style, + ]} + {...props} + /> + ) +} + +export function SearchButton({ + label, + metricsTag, + onPress, +}: { + label: string + metricsTag: 'suggestedAccounts' | 'suggestedFeeds' + onPress?: () => void +}) { + return ( + <Button + label={label} + size="small" + variant="ghost" + color="secondary" + shape="round" + PressableComponent={native(PressableScale)} + onPress={() => { + logger.metric( + 'explore:module:searchButtonPress', + {module: metricsTag}, + {statsig: true}, + ) + onPress?.() + }} + style={[ + { + right: -4, + }, + ]}> + <ButtonIcon icon={SearchIcon} size="lg" /> + </Button> + ) +} + +export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return ( + <View style={[a.z_20, {marginRight: -6}]}> + <FeedCard.SaveButton + pin + view={feed} + size="large" + color="secondary" + variant="ghost" + shape="square" + text={false} + /> + </View> + ) +} diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx new file mode 100644 index 000000000..5e62f2cd0 --- /dev/null +++ b/src/screens/Search/components/SearchHistory.tsx @@ -0,0 +1,169 @@ +import {Pressable, ScrollView, StyleSheet, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {createHitslop, HITSLOP_10} from '#/lib/constants' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {Link} from '#/view/com/util/Link' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function SearchHistory({ + searchHistory, + selectedProfiles, + onItemClick, + onProfileClick, + onRemoveItemClick, + onRemoveProfileClick, +}: { + searchHistory: string[] + selectedProfiles: bsky.profile.AnyProfileView[] + onItemClick: (item: string) => void + onProfileClick: (profile: bsky.profile.AnyProfileView) => void + onRemoveItemClick: (item: string) => void + onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void +}) { + const {gtMobile} = useBreakpoints() + const t = useTheme() + const {_} = useLingui() + + return ( + <Layout.Content + keyboardDismissMode="interactive" + keyboardShouldPersistTaps="handled"> + <View style={[a.w_full, a.px_md]}> + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( + <Text style={[a.text_md, a.font_bold, a.p_md]}> + <Trans>Recent Searches</Trans> + </Text> + )} + {selectedProfiles.length > 0 && ( + <View + style={[ + styles.selectedProfilesContainer, + !gtMobile && styles.selectedProfilesContainerMobile, + ]}> + <BlockDrawerGesture> + <ScrollView + horizontal + keyboardShouldPersistTaps="handled" + style={[ + a.flex_row, + a.flex_nowrap, + {marginHorizontal: tokens.space._2xl * -1}, + ]} + contentContainerStyle={[a.px_2xl, a.border_0]}> + {selectedProfiles.slice(0, 5).map((profile, index) => ( + <View + key={index} + style={[ + styles.profileItem, + !gtMobile && styles.profileItemMobile, + ]}> + <Link + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline + onBeforePress={() => onProfileClick(profile)} + style={[a.align_center, a.w_full]}> + <UserAvatar + avatar={profile.avatar} + type={profile.associated?.labeler ? 'labeler' : 'user'} + size={60} + /> + <Text + emoji + style={[a.text_xs, a.text_center, styles.profileName]} + numberOfLines={1}> + {sanitizeDisplayName( + profile.displayName || profile.handle, + )} + </Text> + </Link> + <Pressable + accessibilityRole="button" + accessibilityLabel={_(msg`Remove profile`)} + accessibilityHint={_( + msg`Removes profile from search history`, + )} + onPress={() => onRemoveProfileClick(profile)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn}> + <XIcon size="xs" style={t.atoms.text_contrast_low} /> + </Pressable> + </View> + ))} + </ScrollView> + </BlockDrawerGesture> + </View> + )} + {searchHistory.length > 0 && ( + <View style={[a.pl_md, a.pr_xs, a.mt_md]}> + {searchHistory.slice(0, 5).map((historyItem, index) => ( + <View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}> + <Pressable + accessibilityRole="button" + onPress={() => onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_md]}> + <Text style={[a.text_md]}>{historyItem}</Text> + </Pressable> + <Button + label={_(msg`Remove ${historyItem}`)} + onPress={() => onRemoveItemClick(historyItem)} + size="small" + variant="ghost" + color="secondary" + shape="round"> + <ButtonIcon icon={XIcon} /> + </Button> + </View> + ))} + </View> + )} + </View> + </Layout.Content> + ) +} + +const styles = StyleSheet.create({ + selectedProfilesContainer: { + marginTop: 10, + paddingHorizontal: 12, + height: 80, + }, + selectedProfilesContainerMobile: { + height: 100, + }, + profileItem: { + alignItems: 'center', + marginRight: 15, + width: 78, + }, + profileItemMobile: { + width: 70, + }, + profileName: { + width: 78, + marginTop: 6, + }, + profileRemoveBtn: { + position: 'absolute', + top: 0, + right: 5, + backgroundColor: 'white', + borderRadius: 10, + width: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/screens/Search/components/SearchLanguageDropdown.tsx b/src/screens/Search/components/SearchLanguageDropdown.tsx new file mode 100644 index 000000000..5c5a4b74f --- /dev/null +++ b/src/screens/Search/components/SearchLanguageDropdown.tsx @@ -0,0 +1,120 @@ +import {useMemo} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {languageName} from '#/locale/helpers' +import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' +import {atoms as a, native, platform, tokens} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import * as Menu from '#/components/Menu' + +export function SearchLanguageDropdown({ + value, + onChange, +}: { + value: string + onChange(value: string): void +}) { + const {_} = useLingui() + const {appLanguage, contentLanguages} = useLanguagePrefs() + + const languages = useMemo(() => { + return LANGUAGES.filter( + (lang, index, self) => + Boolean(lang.code2) && // reduce to the code2 varieties + index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen) + ) + .map(l => ({ + label: languageName(l, appLanguage), + value: l.code2, + key: l.code2 + l.code3, + })) + .sort((a, b) => { + // prioritize user's languages + const aIsUser = contentLanguages.includes(a.value) + const bIsUser = contentLanguages.includes(b.value) + if (aIsUser && !bIsUser) return -1 + if (bIsUser && !aIsUser) return 1 + // prioritize "common" langs in the network + const aIsCommon = !!APP_LANGUAGES.find( + al => + // skip `ast`, because it uses a 3-letter code which conflicts with `as` + // it begins with `a` anyway so still is top of the list + al.code2 !== 'ast' && al.code2.startsWith(a.value), + ) + const bIsCommon = !!APP_LANGUAGES.find( + al => + // ditto + al.code2 !== 'ast' && al.code2.startsWith(b.value), + ) + if (aIsCommon && !bIsCommon) return -1 + if (bIsCommon && !aIsCommon) return 1 + // fall back to alphabetical + return a.label.localeCompare(b.label) + }) + }, [appLanguage, contentLanguages]) + + const currentLanguageLabel = + languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) + + return ( + <Menu.Root> + <Menu.Trigger + label={_( + msg`Filter search by language (currently: ${currentLanguageLabel})`, + )}> + {({props}) => ( + <Button + {...props} + label={props.accessibilityLabel} + size="small" + color={platform({native: 'primary', default: 'secondary'})} + variant={platform({native: 'ghost', default: 'solid'})} + style={native([ + a.py_sm, + a.px_sm, + {marginRight: tokens.space.sm * -1}, + ])}> + <ButtonIcon icon={EarthIcon} /> + <ButtonText>{currentLanguageLabel}</ButtonText> + <ButtonIcon + icon={platform({ + native: ChevronUpDownIcon, + default: ChevronDownIcon, + })} + /> + </Button> + )} + </Menu.Trigger> + <Menu.Outer> + <Menu.LabelText> + <Trans>Filter search by language</Trans> + </Menu.LabelText> + <Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}> + <Menu.ItemText> + <Trans>All languages</Trans> + </Menu.ItemText> + <Menu.ItemRadio selected={value === ''} /> + </Menu.Item> + <Menu.Divider /> + <Menu.Group> + {languages.map(lang => ( + <Menu.Item + key={lang.key} + label={lang.label} + onPress={() => onChange(lang.value)}> + <Menu.ItemText>{lang.label}</Menu.ItemText> + <Menu.ItemRadio selected={value === lang.value} /> + </Menu.Item> + ))} + </Menu.Group> + </Menu.Outer> + </Menu.Root> + ) +} diff --git a/src/screens/Search/components/StarterPackCard.tsx b/src/screens/Search/components/StarterPackCard.tsx new file mode 100644 index 000000000..9520dd5a7 --- /dev/null +++ b/src/screens/Search/components/StarterPackCard.tsx @@ -0,0 +1,296 @@ +import React from 'react' +import {View} from 'react-native' +import { + type AppBskyGraphDefs, + AppBskyGraphStarterpack, + moderateProfile, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {ButtonText} from '#/components/Button' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Link} from '#/components/Link' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard' +import {Text} from '#/components/Typography' +import * as bsky from '#/types/bsky' + +export function StarterPackCard({ + view, +}: { + view: AppBskyGraphDefs.StarterPackView +}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {gtPhone} = useBreakpoints() + const link = useStarterPackLink({view}) + + if ( + !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( + view.record, + AppBskyGraphStarterpack.isRecord, + ) + ) { + return null + } + + const profileCount = gtPhone ? 11 : 8 + const profiles = view.listItemsSample + ?.slice(0, profileCount) + .map(item => item.subject) + + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.gap_md, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border_contrast_low, + ]}> + <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}> + <Link + to={link.to} + label={link.label} + style={[a.absolute, a.inset_0]} + onHoverIn={link.precache} + onPress={link.precache}> + <View /> + </Link> + </View> + + <AvatarStack + profiles={profiles ?? []} + numPending={profileCount} + total={view.list?.listItemCount} + /> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_start, + a.gap_lg, + web({ + position: 'static', + zIndex: 'unset', + }), + ]}> + <View style={[a.flex_1]}> + <Text + emoji + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {view.record.name} + </Text> + <Text + emoji + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {view.creator?.did === currentAccount?.did + ? _(msg`By you`) + : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)} + </Text> + </View> + <Link + to={link.to} + label={link.label} + onHoverIn={link.precache} + onPress={link.precache} + variant="solid" + color="secondary" + size="small" + style={[a.z_50]}> + <ButtonText> + <Trans>Open pack</Trans> + </ButtonText> + </Link> + </View> + </View> + ) +} + +export function AvatarStack({ + profiles, + numPending, + total, +}: { + profiles: bsky.profile.AnyProfileView[] + numPending: number + total?: number +}) { + const t = useTheme() + const {gtPhone} = useBreakpoints() + const moderationOpts = useModerationOpts() + const computedTotal = (total ?? numPending) - numPending + const circlesCount = numPending + 1 // add total at end + const widthPerc = 100 / circlesCount + const [size, setSize] = React.useState<number | null>(null) + + const isPending = (numPending && profiles.length === 0) || !moderationOpts + + const items = isPending + ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({ + key: i, + profile: null, + moderation: null, + })) + : profiles.map(item => ({ + key: item.did, + profile: item, + moderation: moderateProfile(item, moderationOpts), + })) + + return ( + <View + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.relative, + {width: `${100 - widthPerc * 0.2}%`}, + ]}> + {items.map((item, i) => ( + <View + key={item.key} + style={[ + { + width: `${widthPerc}%`, + zIndex: 100 - i, + }, + ]}> + <View + style={[ + a.relative, + { + width: '120%', + }, + ]}> + <View + onLayout={e => setSize(e.nativeEvent.layout.width)} + style={[ + a.rounded_full, + t.atoms.bg_contrast_25, + { + paddingTop: '100%', + }, + ]}> + {size && item.profile ? ( + <UserAvatar + size={size} + avatar={item.profile.avatar} + type={item.profile.associated?.labeler ? 'labeler' : 'user'} + moderation={item.moderation.ui('avatar')} + style={[a.absolute, a.inset_0]} + /> + ) : ( + <MediaInsetBorder style={[a.rounded_full]} /> + )} + </View> + </View> + </View> + ))} + <View + style={[ + { + width: `${widthPerc}%`, + zIndex: 1, + }, + ]}> + <View + style={[ + a.relative, + { + width: '120%', + }, + ]}> + <View + style={[ + { + paddingTop: '100%', + }, + ]}> + <View + style={[ + a.absolute, + a.inset_0, + a.rounded_full, + a.align_center, + a.justify_center, + { + backgroundColor: t.atoms.text_contrast_low.color, + }, + ]}> + {computedTotal > 0 ? ( + <Text + style={[ + gtPhone ? a.text_md : a.text_sm, + a.font_bold, + a.leading_snug, + {color: 'white'}, + ]}> + <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12"> + +{computedTotal} + </Trans> + </Text> + ) : ( + <Plus fill="white" /> + )} + </View> + </View> + </View> + </View> + </View> + ) +} + +export function StarterPackCardSkeleton() { + const t = useTheme() + const {gtPhone} = useBreakpoints() + + const profileCount = gtPhone ? 11 : 8 + + return ( + <View + style={[ + a.w_full, + a.p_lg, + a.gap_md, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border_contrast_low, + ]}> + <AvatarStack profiles={[]} numPending={profileCount} /> + + <View + style={[ + a.w_full, + a.flex_row, + a.align_start, + a.gap_lg, + web({ + position: 'static', + zIndex: 'unset', + }), + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <LoadingPlaceholder width={180} height={18} /> + <LoadingPlaceholder width={120} height={14} /> + </View> + + <LoadingPlaceholder width={100} height={33} /> + </View> + </View> + ) +} |