diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-04-03 03:21:15 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-04-02 17:21:15 -0700 |
commit | 87da619aaa92e0ec762e68c13b24e58a25da10a8 (patch) | |
tree | 4da902d3ca43a226f6da8e5c090ab33c2df3297a /src | |
parent | 8d1f97b5ffac5d86762f1d4e9384ff3097acbc52 (diff) | |
download | voidsky-87da619aaa92e0ec762e68c13b24e58a25da10a8.tar.zst |
[Explore] Base (#8053)
* migrate to #/screens * rm unneeded import * block drawer gesture on recent profiles * rm recommendations (#8056) * [Explore] Disable Trending videos (#8054) * remove giant header * disable * [Explore] Dynamic module ordering (#8066) * Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * restore statsig to log events * filter out followed profiles, make suer enough are loaded (#8090) * [Explore] Trending topics (#8055) * redesigned trending topics * rm borders on web * get post count / age / ranking from api * spacing tweaks * fetch more topics then slice * use api data for avis/category * rm top border * Integrate new SDK, part out components * Clean up * Use status field * Bump SDK * Send up interests and langs --------- Co-authored-by: Eric Bailey <git@esb.lol> * Clean up module spacing and borders (cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c) (cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1) * Switch back border ordering (cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9) * [Explore] Starter Packs (#8095) * Temp WIP (cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18) * New SP card * Load state * Revert change * Cleanup * Interests and caching * Count total * Format * Caching * [Explore] Feed previews module (#8075) * wip new hook * get fetching working, maybe * get feed previews rendering! * fix header height * working pin button * extract out FeedLink * add loader * only make preview:header sticky * Fix headers * Header tweaks * Fix moderation filter * Fix threading --------- Co-authored-by: Eric Bailey <git@esb.lol> * Space it out * Fix query key * Mock new endpoint, filter saved feeds * Make sure we're pinning, lower cache time * add news category * Remove log * Improve suggested accounts load state * Integrate new app view endpoint * fragment * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * lint * maybe fix this --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src')
41 files changed, 3797 insertions, 2115 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 807fd92e5..420a49d4c 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,9 +1,8 @@ import * as React from 'react' -import {JSX} from 'react/jsx-runtime' -import {i18n, MessageDescriptor} from '@lingui/core' +import {i18n, type MessageDescriptor} from '@lingui/core' import {msg} from '@lingui/macro' import { - BottomTabBarProps, + type BottomTabBarProps, createBottomTabNavigator, } from '@react-navigation/bottom-tabs' import { @@ -20,16 +19,16 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' import {buildStateObject} from '#/lib/routes/helpers' import { - AllNavigatorParams, - BottomTabNavigatorParams, - FlatNavigatorParams, - HomeTabNavigatorParams, - MessagesTabNavigatorParams, - MyProfileTabNavigatorParams, - NotificationsTabNavigatorParams, - SearchTabNavigatorParams, + type AllNavigatorParams, + type BottomTabNavigatorParams, + type FlatNavigatorParams, + type HomeTabNavigatorParams, + type MessagesTabNavigatorParams, + type MyProfileTabNavigatorParams, + type NotificationsTabNavigatorParams, + type SearchTabNavigatorParams, } from '#/lib/routes/types' -import {RouteParams, State} from '#/lib/routes/types' +import {type RouteParams, type State} from '#/lib/routes/types' import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {bskyTitle} from '#/lib/strings/headings' import {logger} from '#/logger' @@ -59,7 +58,6 @@ import {ProfileScreen} from '#/view/screens/Profile' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' -import {SearchScreen} from '#/view/screens/Search' import {Storybook} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' @@ -81,6 +79,7 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed' import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {SearchScreen} from '#/screens/Search' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index f20e517d4..665bbcba8 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -1,8 +1,8 @@ import React from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import { - AppBskyFeedDefs, - AppBskyGraphDefs, + type AppBskyFeedDefs, + type AppBskyGraphDefs, AtUri, RichText as RichTextApi, } from '@atproto/api' @@ -23,15 +23,20 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Link as InternalLink, LinkProps} from '#/components/Link' +import { + Button, + ButtonIcon, + type ButtonProps, + ButtonText, +} from '#/components/Button' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {Link as InternalLink, type LinkProps} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' -import {RichText, RichTextProps} from '#/components/RichText' +import {RichText, type RichTextProps} from '#/components/RichText' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash' type Props = { view: AppBskyFeedDefs.GeneratorView @@ -81,11 +86,11 @@ export function Link({ } export function Outer({children}: {children: React.ReactNode}) { - return <View style={[a.w_full, a.gap_md]}>{children}</View> + return <View style={[a.w_full, a.gap_sm]}>{children}</View> } export function Header({children}: {children: React.ReactNode}) { - return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> + return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> } export type AvatarProps = {src: string | undefined; size?: number} @@ -220,22 +225,27 @@ export function Likes({count}: {count: number}) { export function SaveButton({ view, pin, + ...props }: { view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView pin?: boolean -}) { + text?: boolean +} & Partial<ButtonProps>) { const {hasSession} = useSession() if (!hasSession) return null - return <SaveButtonInner view={view} pin={pin} /> + return <SaveButtonInner view={view} pin={pin} {...props} /> } function SaveButtonInner({ view, pin, + text = true, + ...buttonProps }: { view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView pin?: boolean -}) { + text?: boolean +} & Partial<ButtonProps>) { const {_} = useLingui() const {data: preferences} = usePreferencesQuery() const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = @@ -294,14 +304,32 @@ function SaveButtonInner({ disabled={isPending} label={_(msg`Add this feed to your feeds`)} size="small" - variant="ghost" - color="secondary" - shape="square" - onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> + variant="solid" + color={savedFeedConfig ? 'secondary' : 'primary'} + onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave} + {...buttonProps}> {savedFeedConfig ? ( - <ButtonIcon size="md" icon={isPending ? Loader : Trash} /> + <> + {isPending ? ( + <ButtonIcon size="md" icon={Loader} /> + ) : ( + !text && <ButtonIcon size="md" icon={TrashIcon} /> + )} + {text && ( + <ButtonText> + <Trans>Unpin Feed</Trans> + </ButtonText> + )} + </> ) : ( - <ButtonIcon size="md" icon={isPending ? Loader : Plus} /> + <> + <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} /> + {text && ( + <ButtonText> + <Trans>Pin Feed</Trans> + </ButtonText> + )} + </> )} </Button> diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index b56112dcf..a37432500 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -1,14 +1,14 @@ import React from 'react' -import {GestureResponderEvent, View} from 'react-native' +import {type GestureResponderEvent, View} from 'react-native' import { moderateProfile, - ModerationOpts, + type ModerationOpts, RichText as RichTextApi, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {LogEvents} from '#/lib/statsig/statsig' +import {type LogEvents} from '#/lib/statsig/statsig' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -18,13 +18,18 @@ import {ProfileCardPills} from '#/view/com/profile/ProfileCard' import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' +import { + Button, + ButtonIcon, + type ButtonProps, + ButtonText, +} from '#/components/Button' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Link as InternalLink, type LinkProps} from '#/components/Link' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' export function Default({ profile, @@ -133,7 +138,7 @@ export function Avatar({ return ( <UserAvatar - size={42} + size={40} avatar={profile.avatar} type={profile.associated?.labeler ? 'labeler' : 'user'} moderation={moderation.ui('avatar')} @@ -149,8 +154,8 @@ export function AvatarPlaceholder() { a.rounded_full, t.atoms.bg_contrast_50, { - width: 42, - height: 42, + width: 40, + height: 40, }, ]} /> @@ -261,7 +266,7 @@ export function DescriptionPlaceholder({ }) { const t = useTheme() return ( - <View style={[{gap: 8}]}> + <View style={[a.pt_2xs, {gap: 6}]}> {Array(numberOfLines) .fill(0) .map((_, i) => ( @@ -286,6 +291,7 @@ export type FollowButtonProps = { LogEvents['profile:unfollow']['logContext'] colorInverted?: boolean onFollow?: () => void + withIcon?: boolean } & Partial<ButtonProps> export function FollowButton(props: FollowButtonProps) { @@ -301,6 +307,7 @@ export function FollowButtonInner({ onPress: onPressProp, onFollow, colorInverted, + withIcon = true, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -386,7 +393,9 @@ export function FollowButtonInner({ color="secondary" {...rest} onPress={onPressUnfollow}> - <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> + {withIcon && ( + <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> + )} {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} </Button> ) : ( @@ -397,7 +406,9 @@ export function FollowButtonInner({ color={colorInverted ? 'secondary_inverted' : 'primary'} {...rest} onPress={onPressFollow}> - <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> + {withIcon && ( + <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> + )} {isRound ? null : <ButtonText>{followLabel}</ButtonText>} </Button> )} diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 006f86574..41c3d41d8 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -5,7 +5,7 @@ import Animated, { LinearTransition, ZoomInEasyDown, } from 'react-native-reanimated' -import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search' import {usePreferencesQuery} from '#/state/queries/preferences' import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' import {useSession} from '#/state/session' -import {Follow10ProgressGuide} from '#/state/shell/progress-guide' -import {ListMethods} from '#/view/com/util/List' +import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' +import {type ListMethods} from '#/view/com/util/List' import { popularInterests, useInterestsDisplayNames, @@ -31,7 +31,7 @@ import { tokens, useBreakpoints, useTheme, - ViewStyleProp, + type ViewStyleProp, web, } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -452,12 +452,14 @@ let Tabs = ({ selectedInterest, hasSearchText, interestsDisplayNames, + TabComponent = Tab, }: { onSelectTab: (tab: string) => void interests: string[] selectedInterest: string hasSearchText: boolean interestsDisplayNames: Record<string, string> + TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> }): React.ReactNode => { const listRef = useRef<ScrollView>(null) const [scrollX, setScrollX] = useState(0) @@ -532,7 +534,7 @@ let Tabs = ({ {interests.map((interest, i) => { const active = interest === selectedInterest && !hasSearchText return ( - <Tab + <TabComponent key={interest} onSelectTab={handleSelectTab} active={active} @@ -547,6 +549,7 @@ let Tabs = ({ ) } Tabs = memo(Tabs) +export {Tabs} let Tab = ({ onSelectTab, @@ -822,7 +825,7 @@ function Empty({message}: {message: string}) { ) } -function boostInterests(boosts?: string[]) { +export function boostInterests(boosts?: string[]) { return (_a: string, _b: string) => { const indexA = boosts?.indexOf(_a) ?? -1 const indexB = boosts?.indexOf(_b) ?? -1 diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx index b5760fd6d..88d075b78 100644 --- a/src/components/StarterPack/StarterPackCard.tsx +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -13,7 +13,10 @@ import {precacheStarterPack} from '#/state/queries/starter-packs' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' -import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link' +import { + Link as BaseLink, + type LinkProps as BaseLinkProps, +} from '#/components/Link' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' @@ -104,6 +107,32 @@ export function Card({ ) } +export function useStarterPackLink({ + view, +}: { + view: bsky.starterPack.AnyStarterPackView +}) { + const {_} = useLingui() + const qc = useQueryClient() + const {rkey, handleOrDid} = React.useMemo(() => { + const rkey = new AtUri(view.uri).rkey + const {creator} = view + return {rkey, handleOrDid: creator.handle || creator.did} + }, [view]) + const precache = () => { + precacheResolvedUri(qc, view.creator.handle, view.creator.did) + precacheStarterPack(qc, view) + } + + return { + to: `/starter-pack/${handleOrDid}/${rkey}`, + label: AppBskyGraphStarterpack.isRecord(view.record) + ? _(msg`Navigate to ${view.record.name}`) + : _(msg`Navigate to starter pack`), + precache, + } +} + export function Link({ starterPack, children, diff --git a/src/components/icons/Flame.tsx b/src/components/icons/Flame.tsx new file mode 100644 index 000000000..42569b0de --- /dev/null +++ b/src/components/icons/Flame.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Flame_Stroke2_Corner1_Rounded = createSinglePathSVG({ + path: 'M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z', +}) diff --git a/src/components/icons/Trending2.tsx b/src/components/icons/Trending.tsx index 5fba4167b..bdc8539e0 100644 --- a/src/components/icons/Trending2.tsx +++ b/src/components/icons/Trending.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({ path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z', }) + +export const Trending3_Stroke2_Corner1_Rounded = createSinglePathSVG({ + path: 'M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx index 996ecb626..bc1e045a4 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -1,5 +1,5 @@ -import {StyleSheet, TextProps} from 'react-native' -import type {PathProps, SvgProps} from 'react-native-svg' +import {StyleSheet, type TextProps} from 'react-native' +import {type PathProps, type SvgProps} from 'react-native-svg' import {Defs, LinearGradient, Stop} from 'react-native-svg' import {nanoid} from 'nanoid/non-secure' @@ -19,7 +19,7 @@ export const sizes = { lg: 24, xl: 28, '2xl': 32, -} +} as const export function useCommonSVGProps(props: Props) { const t = useTheme() diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx index 7412c6f0a..56c756c50 100644 --- a/src/components/interstitials/Trending.tsx +++ b/src/components/interstitials/Trending.tsx @@ -15,7 +15,7 @@ import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {atoms as a, useGutters, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import * as Prompt from '#/components/Prompt' import {TrendingTopicLink} from '#/components/TrendingTopics' import {Text} from '#/components/Typography' diff --git a/src/components/interstitials/TrendingVideos.tsx b/src/components/interstitials/TrendingVideos.tsx index 126d6f417..fab738b9c 100644 --- a/src/components/interstitials/TrendingVideos.tsx +++ b/src/components/interstitials/TrendingVideos.tsx @@ -16,7 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import {Link} from '#/components/Link' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 3690783d3..6e0be9d0a 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,4 +1,4 @@ -import {StyleProp, TextStyle, ViewStyle} from 'react-native' +import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native' import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg' // Copyright (c) 2020 Refactoring UI Inc. diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c88a97c75..d3334d82f 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -2,6 +2,7 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' | 'debug_subscriptions' + | 'explore_show_suggested_feeds' | 'old_postonboarding' | 'onboarding_add_video_feed' | 'remove_show_latest_button' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 33cdc25e5..646758369 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -1,3 +1,5 @@ +import {type FeedDescriptor} from '#/state/queries/post-feed' + export type MetricEvents = { // App events init: { @@ -202,6 +204,7 @@ export type MetricEvents = { | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' | 'ImmersiveVideo' + | 'ExploreSuggestedAccounts' } 'suggestedUser:follow': { logContext: @@ -239,6 +242,7 @@ export type MetricEvents = { | 'ProfileHeaderSuggestedFollows' | 'PostOnboardingFindFollows' | 'ImmersiveVideo' + | 'ExploreSuggestedAccounts' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -318,6 +322,22 @@ export type MetricEvents = { context: 'interstitial:discover' | 'interstitial:explore' | 'feed' } + 'explore:module:seen': { + module: + | 'trendingTopics' + | 'trendingVideos' + | 'suggestedAccounts' + | 'suggestedFeeds' + | 'suggestedStarterPacks' + | `feed:${FeedDescriptor}` + } + 'explore:module:searchButtonPress': { + module: 'suggestedAccounts' | 'suggestedFeeds' + } + 'explore:suggestedAccounts:tabPressed': { + tab: string + } + 'progressGuide:hide': {} 'progressGuide:followDialog:open': {} diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index d0b0cacca..e725b7b80 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,12 +1,12 @@ import React from 'react' import {View} from 'react-native' import { - AppBskyActorProfile, - AppBskyGraphDefs, + type AppBskyActorProfile, + type AppBskyGraphDefs, AppBskyGraphStarterpack, - Un$Typed, + type Un$Typed, } from '@atproto/api' -import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {type SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -46,7 +46,7 @@ import {IconCircle} from '#/components/IconCircle' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' diff --git a/src/screens/Profile/ProfileSearch.tsx b/src/screens/Profile/ProfileSearch.tsx index d91dc973e..6247e3979 100644 --- a/src/screens/Profile/ProfileSearch.tsx +++ b/src/screens/Profile/ProfileSearch.tsx @@ -2,11 +2,14 @@ import {useMemo} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useSession} from '#/state/session' -import {SearchScreenShell} from '#/view/screens/Search/Search' +import {SearchScreenShell} from '#/screens/Search/Shell' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'> export const ProfileSearchScreen = ({route}: Props) => { diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx new file mode 100644 index 000000000..86877677a --- /dev/null +++ b/src/screens/Search/Explore.tsx @@ -0,0 +1,923 @@ +import {useCallback, useMemo, useRef, useState} from 'react' +import {View, type ViewabilityConfig, type ViewToken} from 'react-native' +import { + type AppBskyActorDefs, + type AppBskyFeedDefs, + type AppBskyGraphDefs, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useGate} from '#/lib/statsig/statsig' +import {cleanError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {type MetricEvents} from '#/logger/metrics' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {useGetPopularFeedsQuery} from '#/state/queries/feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery' +import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery' +import {useProgressGuide} from '#/state/shell/progress-guide' +import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' +import {PostFeedItem} from '#/view/com/posts/PostFeedItem' +import {ViewFullThread} from '#/view/com/posts/ViewFullThread' +import {List} from '#/view/com/util/List' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import { + StarterPackCard, + StarterPackCardSkeleton, +} from '#/screens/Search/components/StarterPackCard' +import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' +import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' +import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import * as FeedCard from '#/components/FeedCard' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {StarterPack} from '#/components/icons/StarterPack' +import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import * as ModuleHeader from './components/ModuleHeader' +import { + type FeedPreviewItem, + useFeedPreviews, +} from './modules/ExploreFeedPreviews' +import { + SuggestedAccountsTabBar, + SuggestedProfileCard, + useLoadEnoughProfiles, +} from './modules/ExploreSuggestedAccounts' + +function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Button + label={_(msg`Load more`)} + onPress={item.onLoadMore} + style={[a.relative, a.w_full]}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + a.justify_center, + a.px_lg, + a.py_md, + a.gap_sm, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <Text + style={[ + a.leading_snug, + hovered ? t.atoms.text : t.atoms.text_contrast_medium, + ]}> + {item.message} + </Text> + {item.isLoadingMore ? ( + <Loader size="sm" /> + ) : ( + <ChevronDownIcon + size="sm" + style={hovered ? t.atoms.text : t.atoms.text_contrast_medium} + /> + )} + </View> + )} + </Button> + ) +} + +type ExploreScreenItems = + | { + type: 'topBorder' + key: string + } + | { + type: 'header' + key: string + title: string + icon: React.ComponentType<SVGIconProps> + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'tabbedHeader' + key: string + title: string + icon: React.ComponentType<SVGIconProps> + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'trendingTopics' + key: string + } + | { + type: 'trendingVideos' + key: string + } + | { + type: 'recommendations' + key: string + } + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileView + recId?: number + } + | { + type: 'feed' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'loadMore' + key: string + message: string + isLoadingMore: boolean + onLoadMore: () => void + } + | { + type: 'profilePlaceholder' + key: string + } + | { + type: 'feedPlaceholder' + key: string + } + | { + type: 'error' + key: string + message: string + error: string + } + | { + type: 'starterPack' + key: string + view: AppBskyGraphDefs.StarterPackView + } + | { + type: 'starterPackSkeleton' + key: string + } + | FeedPreviewItem + +export function Explore({ + focusSearchInput, + headerHeight, +}: { + focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void + headerHeight: number +}) { + const {_} = useLingui() + const t = useTheme() + const {data: preferences, error: preferencesError} = usePreferencesQuery() + const moderationOpts = useModerationOpts() + const gate = useGate() + const guide = useProgressGuide('follow-10') + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) + const { + data: suggestedProfiles, + hasNextPage: hasNextSuggestedProfilesPage, + isLoading: isLoadingSuggestedProfiles, + isFetchingNextPage: isFetchingNextSuggestedProfilesPage, + error: suggestedProfilesError, + fetchNextPage: fetchNextSuggestedProfilesPage, + } = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10}) + const { + data: interestProfiles, + hasNextPage: hasNextInterestProfilesPage, + isLoading: isLoadingInterestProfiles, + isFetchingNextPage: isFetchingNextInterestProfilesPage, + error: interestProfilesError, + fetchNextPage: fetchNextInterestProfilesPage, + } = useActorSearchPaginated({ + query: selectedInterest || '', + enabled: !!selectedInterest, + limit: 10, + }) + const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({ + interest: selectedInterest, + data: interestProfiles, + isLoading: isLoadingInterestProfiles, + isFetchingNextPage: isFetchingNextInterestProfilesPage, + hasNextPage: hasNextInterestProfilesPage, + fetchNextPage: fetchNextInterestProfilesPage, + }) + const { + data: feeds, + hasNextPage: hasNextFeedsPage, + isLoading: isLoadingFeeds, + isFetchingNextPage: isFetchingNextFeedsPage, + error: feedsError, + fetchNextPage: fetchNextFeedsPage, + } = useGetPopularFeedsQuery({limit: 10}) + + const profiles: typeof suggestedProfiles & typeof interestProfiles = + !selectedInterest ? suggestedProfiles : interestProfiles + const hasNextProfilesPage = !selectedInterest + ? hasNextSuggestedProfilesPage + : hasNextInterestProfilesPage + const isLoadingProfiles = !selectedInterest + ? isLoadingSuggestedProfiles + : !canShowSuggestedProfiles + const isFetchingNextProfilesPage = !selectedInterest + ? isFetchingNextSuggestedProfilesPage + : !canShowSuggestedProfiles + const profilesError = !selectedInterest + ? suggestedProfilesError + : interestProfilesError + const fetchNextProfilesPage = !selectedInterest + ? fetchNextSuggestedProfilesPage + : fetchNextInterestProfilesPage + + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles + const onLoadMoreProfiles = useCallback(async () => { + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) + return + try { + await fetchNextProfilesPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextProfilesPage, + hasNextProfilesPage, + profilesError, + fetchNextProfilesPage, + ]) + const { + data: suggestedSPs, + isLoading: isLoadingSuggestedSPs, + error: suggestedSPsError, + } = useSuggestedStarterPacksQuery() + + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds + const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false) + const onLoadMoreFeeds = useCallback(async () => { + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return + if (!hasPressedLoadMoreFeeds) { + setHasPressedLoadMoreFeeds(true) + return + } + try { + await fetchNextFeedsPage() + } catch (err) { + logger.error('Failed to load more suggested follows', {message: err}) + } + }, [ + isFetchingNextFeedsPage, + hasNextFeedsPage, + feedsError, + fetchNextFeedsPage, + hasPressedLoadMoreFeeds, + ]) + + const {data: suggestedFeeds} = useGetSuggestedFeedsQuery() + const { + data: feedPreviewSlices, + query: { + isPending: isPendingFeedPreviews, + isFetchingNextPage: isFetchingNextPageFeedPreviews, + fetchNextPage: fetchNextPageFeedPreviews, + hasNextPage: hasNextPageFeedPreviews, + error: feedPreviewSlicesError, + }, + } = useFeedPreviews(suggestedFeeds?.feeds ?? []) + + const onLoadMoreFeedPreviews = useCallback(async () => { + if ( + isPendingFeedPreviews || + isFetchingNextPageFeedPreviews || + !hasNextPageFeedPreviews || + feedPreviewSlicesError + ) + return + try { + await fetchNextPageFeedPreviews() + } catch (err) { + logger.error('Failed to load more feed previews', {message: err}) + } + }, [ + isPendingFeedPreviews, + isFetchingNextPageFeedPreviews, + hasNextPageFeedPreviews, + feedPreviewSlicesError, + fetchNextPageFeedPreviews, + ]) + + const items = useMemo<ExploreScreenItems[]>(() => { + const i: ExploreScreenItems[] = [] + + const addTopBorder = () => { + i.push({type: 'topBorder', key: 'top-border'}) + } + + const addTrendingTopicsModule = () => { + i.push({ + type: 'trendingTopics', + key: `trending-topics`, + }) + + // temp - disable trending videos + // if (isNative) { + // i.push({ + // type: 'trendingVideos', + // key: `trending-videos`, + // }) + // } + } + + const addSuggestedFollowsModule = () => { + i.push({ + type: 'tabbedHeader', + key: 'suggested-accounts-header', + title: _(msg`Suggested Accounts`), + icon: Person, + searchButton: { + label: _(msg`Search for more accounts`), + metricsTag: 'suggestedAccounts', + tab: 'user', + }, + }) + + if (!canShowSuggestedProfiles) { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } else if (profilesError) { + i.push({ + type: 'error', + key: 'profilesError', + message: _(msg`Failed to load suggested follows`), + error: cleanError(profilesError), + }) + } else { + if (profiles !== undefined) { + if (profiles.pages.length > 0 && moderationOpts) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + const profileItems: ExploreScreenItems[] = [] + for (const page of profiles.pages) { + for (const actor of page.actors) { + if (!seen.has(actor.did) && !actor.viewer?.following) { + seen.add(actor.did) + profileItems.push({ + type: 'profile', + key: actor.did, + profile: actor, + recId: page.recId, + }) + } + } + } + + if (profileItems.length === 0) { + if (!hasNextProfilesPage) { + // no items! remove the header + i.pop() + } + } else { + i.push(...profileItems) + } + if (hasNextProfilesPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreProfiles', + message: _(msg`Load more suggested accounts`), + isLoadingMore: isLoadingMoreProfiles, + onLoadMore: onLoadMoreProfiles, + }) + } + } else { + console.log('no pages') + } + } else { + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) + } + } + } + + const addSuggestedFeedsModule = () => { + i.push({ + type: 'header', + key: 'suggested-feeds-header', + title: _(msg`Discover Feeds`), + icon: ListSparkle, + searchButton: { + label: _(msg`Search for more feeds`), + metricsTag: 'suggestedFeeds', + tab: 'feed', + }, + }) + + if (feeds && preferences) { + // Currently the responses contain duplicate items. + // Needs to be fixed on backend, but let's dedupe to be safe. + let seen = new Set() + const feedItems: ExploreScreenItems[] = [] + for (const page of feeds.pages) { + for (const feed of page.feeds) { + if (!seen.has(feed.uri)) { + seen.add(feed.uri) + feedItems.push({ + type: 'feed', + key: feed.uri, + feed, + }) + } + } + } + + // feeds errors can occur during pagination, so feeds is truthy + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + if (feedItems.length === 0) { + if (!hasNextFeedsPage) { + i.pop() + } + } else { + // This query doesn't follow the limit very well, so the first press of the + // load more button just unslices the array back to ~10 items + if (!hasPressedLoadMoreFeeds) { + i.push(...feedItems.slice(0, 3)) + } else { + i.push(...feedItems) + } + } + if (hasNextFeedsPage) { + i.push({ + type: 'loadMore', + key: 'loadMoreFeeds', + message: _(msg`Load more suggested feeds`), + isLoadingMore: isLoadingMoreFeeds, + onLoadMore: onLoadMoreFeeds, + }) + } + } + } else { + if (feedsError) { + i.push({ + type: 'error', + key: 'feedsError', + message: _(msg`Failed to load suggested feeds`), + error: cleanError(feedsError), + }) + } else if (preferencesError) { + i.push({ + type: 'error', + key: 'preferencesError', + message: _(msg`Failed to load feeds preferences`), + error: cleanError(preferencesError), + }) + } else { + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) + } + } + } + + const addSuggestedStarterPacksModule = () => { + i.push({ + type: 'header', + key: 'suggested-starterPacks-header', + title: _(msg`Starter Packs`), + icon: StarterPack, + }) + + if (isLoadingSuggestedSPs) { + Array.from({length: 3}).forEach((_, index) => + i.push({ + type: 'starterPackSkeleton', + key: `starterPackSkeleton-${index}`, + }), + ) + } else if (suggestedSPsError || !suggestedSPs) { + // just get rid of the section + i.pop() + } else { + suggestedSPs.starterPacks.map(s => { + i.push({ + type: 'starterPack', + key: s.uri, + view: s, + }) + }) + } + } + + const addFeedPreviews = () => { + i.push(...feedPreviewSlices) + if (isFetchingNextPageFeedPreviews) { + i.push({ + type: 'preview:loading', + key: 'preview-loading-more', + }) + } + } + + // Dynamic module ordering + + addTopBorder() + + if (guide?.guide === 'follow-10' && !guide.isComplete) { + addSuggestedFollowsModule() + addSuggestedStarterPacksModule() + addTrendingTopicsModule() + } else { + addTrendingTopicsModule() + addSuggestedFollowsModule() + addSuggestedStarterPacksModule() + } + + if (gate('explore_show_suggested_feeds')) { + addSuggestedFeedsModule() + } + + addFeedPreviews() + + return i + }, [ + _, + profiles, + feeds, + preferences, + onLoadMoreFeeds, + onLoadMoreProfiles, + isLoadingMoreProfiles, + isLoadingMoreFeeds, + profilesError, + feedsError, + preferencesError, + hasNextProfilesPage, + hasNextFeedsPage, + guide, + gate, + moderationOpts, + hasPressedLoadMoreFeeds, + suggestedSPs, + isLoadingSuggestedSPs, + suggestedSPsError, + feedPreviewSlices, + isFetchingNextPageFeedPreviews, + canShowSuggestedProfiles, + ]) + + const renderItem = useCallback( + ({item, index}: {item: ExploreScreenItems; index: number}) => { + switch (item.type) { + case 'topBorder': + return ( + <View + style={[ + a.w_full, + t.atoms.border_contrast_low, + a.border_t, + headerHeight && + web({ + position: 'sticky', + top: headerHeight, + }), + ]} + /> + ) + case 'header': { + return ( + <ModuleHeader.Container> + <ModuleHeader.Icon icon={item.icon} /> + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> + {item.searchButton && ( + <ModuleHeader.SearchButton + {...item.searchButton} + onPress={() => + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + </ModuleHeader.Container> + ) + } + case 'tabbedHeader': { + return ( + <View style={[a.pb_md]}> + <ModuleHeader.Container style={[a.pb_xs]}> + <ModuleHeader.Icon icon={item.icon} /> + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> + {item.searchButton && ( + <ModuleHeader.SearchButton + {...item.searchButton} + onPress={() => + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + </ModuleHeader.Container> + <SuggestedAccountsTabBar + selectedInterest={selectedInterest} + onSelectInterest={setSelectedInterest} + /> + </View> + ) + } + case 'trendingTopics': { + return ( + <View style={[a.pb_md]}> + <ExploreTrendingTopics /> + </View> + ) + } + case 'trendingVideos': { + return <ExploreTrendingVideos /> + } + case 'recommendations': { + return <ExploreRecommendations /> + } + case 'profile': { + return ( + <SuggestedProfileCard + profile={item.profile} + moderationOpts={moderationOpts!} + recId={item.recId} + position={index} + /> + ) + } + case 'feed': { + return ( + <View + style={[ + a.border_t, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default view={item.feed} /> + </View> + ) + } + case 'starterPack': { + return ( + <View style={[a.px_lg, a.pb_lg]}> + <StarterPackCard view={item.view} /> + </View> + ) + } + case 'starterPackSkeleton': { + return ( + <View style={[a.px_lg, a.pb_lg]}> + <StarterPackCardSkeleton /> + </View> + ) + } + case 'loadMore': { + return ( + <View style={[a.border_t, t.atoms.border_contrast_low]}> + <LoadMore item={item} /> + </View> + ) + } + case 'profilePlaceholder': { + return ( + <> + {Array.from({length: 3}).map((_, index) => ( + <View + style={[ + a.px_lg, + a.py_lg, + a.border_t, + t.atoms.border_contrast_low, + ]} + key={index}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.AvatarPlaceholder /> + <ProfileCard.NameAndHandlePlaceholder /> + </ProfileCard.Header> + <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> + </ProfileCard.Outer> + </View> + ))} + </> + ) + } + case 'feedPlaceholder': { + return <FeedFeedLoadingPlaceholder /> + } + case 'error': + case 'preview:error': { + return ( + <View + style={[ + a.border_t, + a.pt_md, + a.px_md, + t.atoms.border_contrast_low, + ]}> + <View + style={[ + a.flex_row, + a.gap_md, + a.p_lg, + a.rounded_sm, + t.atoms.bg_contrast_25, + ]}> + <CircleInfo size="md" fill={t.palette.negative_400} /> + <View style={[a.flex_1, a.gap_sm]}> + <Text style={[a.font_bold, a.leading_snug]}> + {item.message} + </Text> + <Text + style={[ + a.italic, + a.leading_snug, + t.atoms.text_contrast_medium, + ]}> + {item.error} + </Text> + </View> + </View> + </View> + ) + } + // feed previews + case 'preview:empty': { + return null // what should we do here? + } + case 'preview:loading': { + return ( + <View style={[a.py_2xl, a.flex_1, a.align_center]}> + <Loader size="lg" /> + </View> + ) + } + case 'preview:header': { + return ( + <ModuleHeader.Container + headerHeight={headerHeight} + style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}> + <ModuleHeader.FeedLink feed={item.feed}> + <ModuleHeader.FeedAvatar feed={item.feed} /> + <View style={[a.flex_1, a.gap_xs]}> + <ModuleHeader.TitleText style={[a.text_lg]}> + {item.feed.displayName} + </ModuleHeader.TitleText> + <ModuleHeader.SubtitleText> + <Trans> + By {sanitizeHandle(item.feed.creator.handle, '@')} + </Trans> + </ModuleHeader.SubtitleText> + </View> + </ModuleHeader.FeedLink> + <ModuleHeader.PinButton feed={item.feed} /> + </ModuleHeader.Container> + ) + } + case 'preview:footer': { + return <View style={[a.w_full, a.pt_2xl]} /> + } + case 'preview:sliceItem': { + const slice = item.slice + const indexInSlice = item.indexInSlice + const subItem = slice.items[indexInSlice] + return ( + <PostFeedItem + post={subItem.post} + record={subItem.record} + reason={indexInSlice === 0 ? slice.reason : undefined} + feedContext={slice.feedContext} + moderation={subItem.moderation} + parentAuthor={subItem.parentAuthor} + showReplyTo={item.showReplyTo} + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} + isThreadLastChild={ + isThreadChildAt(slice.items, indexInSlice) && + slice.items.length === indexInSlice + 1 + } + isParentBlocked={subItem.isParentBlocked} + isParentNotFound={subItem.isParentNotFound} + hideTopBorder={item.hideTopBorder} + rootPost={slice.items[0].post} + /> + ) + } + case 'preview:sliceViewFullThread': { + return <ViewFullThread uri={item.uri} /> + } + case 'preview:loadMoreError': { + return ( + <LoadMoreRetryBtn + label={_( + msg`There was an issue fetching posts. Tap here to try again.`, + )} + onPress={fetchNextPageFeedPreviews} + /> + ) + } + } + }, + [ + t, + focusSearchInput, + moderationOpts, + selectedInterest, + _, + fetchNextPageFeedPreviews, + headerHeight, + ], + ) + + const stickyHeaderIndices = useMemo( + () => + items.reduce( + (acc, curr) => + ['topBorder', 'preview:header'].includes(curr.type) + ? acc.concat(items.indexOf(curr)) + : acc, + [] as number[], + ), + [items], + ) + + // track headers and report module viewability + const alreadyReportedRef = useRef<Map<string, string>>(new Map()) + const onViewableItemsChanged = useCallback( + ({ + viewableItems, + }: { + viewableItems: ViewToken<ExploreScreenItems>[] + changed: ViewToken<ExploreScreenItems>[] + }) => { + for (const {item} of viewableItems.filter(vi => vi.isViewable)) { + let module: MetricEvents['explore:module:seen']['module'] + if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { + module = item.type + } else if (item.type === 'profile') { + module = 'suggestedAccounts' + } else if (item.type === 'feed') { + module = 'suggestedFeeds' + } else if (item.type === 'preview:header') { + module = `feed:feedgen|${item.feed.uri}` + } else { + continue + } + if (!alreadyReportedRef.current.has(module)) { + alreadyReportedRef.current.set(module, module) + logger.metric('explore:module:seen', {module}) + } + } + }, + [], + ) + + return ( + <List + data={items} + renderItem={renderItem} + keyExtractor={item => item.key} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + stickyHeaderIndices={native(stickyHeaderIndices)} + viewabilityConfig={viewabilityConfig} + onViewableItemsChanged={onViewableItemsChanged} + onEndReached={onLoadMoreFeedPreviews} + onEndReachedThreshold={2} + /> + ) +} + +const viewabilityConfig: ViewabilityConfig = { + itemVisiblePercentThreshold: 100, +} diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx new file mode 100644 index 000000000..bb51d2deb --- /dev/null +++ b/src/screens/Search/SearchResults.tsx @@ -0,0 +1,338 @@ +import {memo, useCallback, useMemo, useState} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {type AppBskyFeedDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {augmentSearchQuery} from '#/lib/strings/helpers' +import {useActorSearch} from '#/state/queries/actor-search' +import {usePopularFeedsSearch} from '#/state/queries/feed' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useSession} from '#/state/session' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {Post} from '#/view/com/post/Post' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' +import {atoms as a, useTheme, web} from '#/alf' +import * as FeedCard from '#/components/FeedCard' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +let SearchResults = ({ + query, + queryWithParams, + activeTab, + onPageSelected, + headerHeight, +}: { + query: string + queryWithParams: string + activeTab: number + onPageSelected: (page: number) => void + headerHeight: number +}): React.ReactNode => { + const {_} = useLingui() + + const sections = useMemo(() => { + if (!queryWithParams) return [] + const noParams = queryWithParams === query + return [ + { + title: _(msg`Top`), + component: ( + <SearchScreenPostResults + query={queryWithParams} + sort="top" + active={activeTab === 0} + /> + ), + }, + { + title: _(msg`Latest`), + component: ( + <SearchScreenPostResults + query={queryWithParams} + sort="latest" + active={activeTab === 1} + /> + ), + }, + noParams && { + title: _(msg`People`), + component: ( + <SearchScreenUserResults query={query} active={activeTab === 2} /> + ), + }, + noParams && { + title: _(msg`Feeds`), + component: ( + <SearchScreenFeedsResults query={query} active={activeTab === 3} /> + ), + }, + ].filter(Boolean) as { + title: string + component: React.ReactNode + }[] + }, [_, query, queryWithParams, activeTab]) + + return ( + <Pager + onPageSelected={onPageSelected} + renderTabBar={props => ( + <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}> + <TabBar items={sections.map(section => section.title)} {...props} /> + </Layout.Center> + )} + initialPage={0}> + {sections.map((section, i) => ( + <View key={i}>{section.component}</View> + ))} + </Pager> + ) +} +SearchResults = memo(SearchResults) +export {SearchResults} + +function Loader() { + return ( + <Layout.Content> + <View style={[a.py_xl]}> + <ActivityIndicator /> + </View> + </Layout.Content> + ) +} + +function EmptyState({message, error}: {message: string; error?: string}) { + const t = useTheme() + + return ( + <Layout.Content> + <View style={[a.p_xl]}> + <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}> + <Text style={[a.text_md]}>{message}</Text> + + {error && ( + <> + <View + style={[ + { + marginVertical: 12, + height: 1, + width: '100%', + backgroundColor: t.atoms.text.color, + opacity: 0.2, + }, + ]} + /> + + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Error: {error}</Trans> + </Text> + </> + )} + </View> + </View> + </Layout.Content> + ) +} + +type SearchResultSlice = + | { + type: 'post' + key: string + post: AppBskyFeedDefs.PostView + } + | { + type: 'loadingMore' + key: string + } + +let SearchScreenPostResults = ({ + query, + sort, + active, +}: { + query: string + sort?: 'top' | 'latest' + active: boolean +}): React.ReactNode => { + const {_} = useLingui() + const {currentAccount} = useSession() + const [isPTR, setIsPTR] = useState(false) + + const augmentedQuery = useMemo(() => { + return augmentSearchQuery(query || '', {did: currentAccount?.did}) + }, [query, currentAccount]) + + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) + + const onPullToRefresh = useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, error, hasNextPage, fetchNextPage]) + + const posts = useMemo(() => { + return results?.pages.flatMap(page => page.posts) || [] + }, [results]) + const items = useMemo(() => { + let temp: SearchResultSlice[] = [] + + const seenUris = new Set() + for (const post of posts) { + if (seenUris.has(post.uri)) { + continue + } + temp.push({ + type: 'post', + key: post.uri, + post, + }) + seenUris.add(post.uri) + } + + if (isFetchingNextPage) { + temp.push({ + type: 'loadingMore', + key: 'loadingMore', + }) + } + + return temp + }, [posts, isFetchingNextPage]) + + return error ? ( + <EmptyState + message={_( + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, + )} + error={error.toString()} + /> + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + <List + data={items} + renderItem={({item}) => { + if (item.type === 'post') { + return <Post post={item.post} /> + } else { + return null + } + }} + keyExtractor={item => item.key} + refreshing={isPTR} + onRefresh={onPullToRefresh} + onEndReached={onEndReached} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + )} + </> + ) +} +SearchScreenPostResults = memo(SearchScreenPostResults) + +let SearchScreenUserResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const {_} = useLingui() + + const {data: results, isFetched} = useActorSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + <List + data={results} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn profile={item} noBg /> + )} + keyExtractor={item => item.did} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} +SearchScreenUserResults = memo(SearchScreenUserResults) + +let SearchScreenFeedsResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + + const {data: results, isFetched} = usePopularFeedsSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + <List + data={results} + renderItem={({item}) => ( + <View + style={[ + a.border_b, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <FeedCard.Default view={item} /> + </View> + )} + keyExtractor={item => item.uri} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} +SearchScreenFeedsResults = memo(SearchScreenFeedsResults) diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx new file mode 100644 index 000000000..e930b8289 --- /dev/null +++ b/src/screens/Search/Shell.tsx @@ -0,0 +1,535 @@ +import { + memo, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + type StyleProp, + type TextInput, + View, + type ViewStyle, +} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {HITSLOP_20} from '#/lib/constants' +import {HITSLOP_10} from '#/lib/constants' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {MagnifyingGlassIcon} from '#/lib/icons' +import {type NavigationProp} from '#/lib/routes/types' +import {isWeb} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import { + unstableCacheProfileView, + useProfilesQuery, +} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import { + makeSearchQuery, + type Params, + parseSearchQuery, +} from '#/screens/Search/utils' +import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {SearchInput} from '#/components/forms/SearchInput' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' +import {account, useStorage} from '#/storage' +import type * as bsky from '#/types/bsky' +import {AutocompleteResults} from './components/AutocompleteResults' +import {SearchHistory} from './components/SearchHistory' +import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' +import {Explore} from './Explore' +import {SearchResults} from './SearchResults' + +export function SearchScreenShell({ + queryParam, + testID, + fixedParams, + navButton = 'menu', + inputPlaceholder, +}: { + queryParam: string + testID: string + fixedParams?: Params + navButton?: 'back' | 'menu' + inputPlaceholder?: string +}) { + const t = useTheme() + const {gtMobile} = useBreakpoints() + const navigation = useNavigation<NavigationProp>() + const route = useRoute() + const textInput = useRef<TextInput>(null) + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + // Query terms + const [searchText, setSearchText] = useState<string>(queryParam) + const {data: autocompleteData, isFetching: isAutocompleteFetching} = + useActorAutocompleteQuery(searchText, true) + + const [showAutocomplete, setShowAutocomplete] = useState(false) + + const [termHistory = [], setTermHistory] = useStorage(account, [ + currentAccount?.did ?? 'pwi', + 'searchTermHistory', + ] as const) + const [accountHistory = [], setAccountHistory] = useStorage(account, [ + currentAccount?.did ?? 'pwi', + 'searchAccountHistory', + ]) + + const {data: accountHistoryProfiles} = useProfilesQuery({ + handles: accountHistory, + maintainData: true, + }) + + const updateSearchHistory = useCallback( + async (item: string) => { + if (!item) return + const newSearchHistory = [ + item, + ...termHistory.filter(search => search !== item), + ].slice(0, 6) + setTermHistory(newSearchHistory) + }, + [termHistory, setTermHistory], + ) + + const updateProfileHistory = useCallback( + async (item: bsky.profile.AnyProfileView) => { + const newAccountHistory = [ + item.did, + ...accountHistory.filter(p => p !== item.did), + ].slice(0, 5) + setAccountHistory(newAccountHistory) + }, + [accountHistory, setAccountHistory], + ) + + const deleteSearchHistoryItem = useCallback( + async (item: string) => { + setTermHistory(termHistory.filter(search => search !== item)) + }, + [termHistory, setTermHistory], + ) + const deleteProfileHistoryItem = useCallback( + async (item: bsky.profile.AnyProfileView) => { + setAccountHistory(accountHistory.filter(p => p !== item.did)) + }, + [accountHistory, setAccountHistory], + ) + + const {params, query, queryWithParams} = useQueryManager({ + initialQuery: queryParam, + fixedParams, + }) + const showFilters = Boolean(queryWithParams && !showAutocomplete) + + // web only - measure header height for sticky positioning + const [headerHeight, setHeaderHeight] = useState(0) + const headerRef = useRef(null) + useLayoutEffect(() => { + if (isWeb) { + if (!headerRef.current) return + const measurement = (headerRef.current as Element).getBoundingClientRect() + setHeaderHeight(measurement.height) + } + }, []) + + useFocusEffect( + useNonReactiveCallback(() => { + if (isWeb) { + setSearchText(queryParam) + } + }), + ) + + const onPressClearQuery = useCallback(() => { + scrollToTopWeb() + setSearchText('') + textInput.current?.focus() + }, []) + + const onChangeText = useCallback(async (text: string) => { + scrollToTopWeb() + setSearchText(text) + }, []) + + const navigateToItem = useCallback( + (item: string) => { + scrollToTopWeb() + setShowAutocomplete(false) + updateSearchHistory(item) + + if (isWeb) { + // @ts-expect-error route is not typesafe + navigation.push(route.name, {...route.params, q: item}) + } else { + textInput.current?.blur() + navigation.setParams({q: item}) + } + }, + [updateSearchHistory, navigation, route], + ) + + const onPressCancelSearch = useCallback(() => { + scrollToTopWeb() + textInput.current?.blur() + setShowAutocomplete(false) + if (isWeb) { + // Empty params resets the URL to be /search rather than /search?q= + + const {q: _q, ...parameters} = (route.params ?? {}) as { + [key: string]: string + } + // @ts-expect-error route is not typesafe + navigation.replace(route.name, parameters) + } else { + setSearchText('') + navigation.setParams({q: ''}) + } + }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) + + const onSubmit = useCallback(() => { + navigateToItem(searchText) + }, [navigateToItem, searchText]) + + const onAutocompleteResultPress = useCallback(() => { + if (isWeb) { + setShowAutocomplete(false) + } else { + textInput.current?.blur() + } + }, []) + + const handleHistoryItemClick = useCallback( + (item: string) => { + setSearchText(item) + navigateToItem(item) + }, + [navigateToItem], + ) + + const handleProfileClick = useCallback( + (profile: bsky.profile.AnyProfileView) => { + unstableCacheProfileView(queryClient, profile) + // Slight delay to avoid updating during push nav animation. + setTimeout(() => { + updateProfileHistory(profile) + }, 400) + }, + [updateProfileHistory, queryClient], + ) + + const onSoftReset = useCallback(() => { + if (isWeb) { + // Empty params resets the URL to be /search rather than /search?q= + + const {q: _q, ...parameters} = (route.params ?? {}) as { + [key: string]: string + } + // @ts-expect-error route is not typesafe + navigation.replace(route.name, parameters) + } else { + setSearchText('') + navigation.setParams({q: ''}) + textInput.current?.focus() + } + }, [navigation, route]) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + return listenSoftReset(onSoftReset) + }, [onSoftReset, setMinimalShellMode]), + ) + + const onSearchInputFocus = useCallback(() => { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + } + }, [setShowAutocomplete]) + + const focusSearchInput = useCallback(() => { + textInput.current?.focus() + }, []) + + const showHeader = !gtMobile || navButton !== 'menu' + + return ( + <Layout.Screen testID={testID}> + <View + ref={headerRef} + onLayout={evt => { + if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) + }} + style={[ + a.relative, + a.z_10, + web({ + position: 'sticky', + top: 0, + }), + ]}> + <Layout.Center style={t.atoms.bg}> + {showHeader && ( + <View + // HACK: shift up search input. we can't remove the top padding + // on the search input because it messes up the layout animation + // if we add it only when the header is hidden + style={{marginBottom: tokens.space.xs * -1}}> + <Layout.Header.Outer noBottomBorder> + {navButton === 'menu' ? ( + <Layout.Header.MenuButton /> + ) : ( + <Layout.Header.BackButton /> + )} + <Layout.Header.Content align="left"> + <Layout.Header.TitleText> + <Trans>Search</Trans> + </Layout.Header.TitleText> + </Layout.Header.Content> + {showFilters ? ( + <SearchLanguageDropdown + value={params.lang} + onChange={params.setLang} + /> + ) : ( + <Layout.Header.Slot /> + )} + </Layout.Header.Outer> + </View> + )} + <View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}> + <View style={[a.gap_sm]}> + <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}> + <View style={[a.flex_1]}> + <SearchInput + ref={textInput} + value={searchText} + onFocus={onSearchInputFocus} + onChangeText={onChangeText} + onClearText={onPressClearQuery} + onSubmitEditing={onSubmit} + placeholder={ + inputPlaceholder ?? + _(msg`Search for posts, users, or feeds`) + } + hitSlop={{...HITSLOP_20, top: 0}} + /> + </View> + {showAutocomplete && ( + <Button + label={_(msg`Cancel search`)} + size="large" + variant="ghost" + color="secondary" + style={[a.px_sm]} + onPress={onPressCancelSearch} + hitSlop={HITSLOP_10}> + <ButtonText> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + )} + </View> + + {showFilters && !showHeader && ( + <View + style={[ + a.flex_row, + a.align_center, + a.justify_between, + a.gap_sm, + ]}> + <SearchLanguageDropdown + value={params.lang} + onChange={params.setLang} + /> + </View> + )} + </View> + </View> + </Layout.Center> + </View> + + <View + style={{ + display: showAutocomplete && !fixedParams ? 'flex' : 'none', + flex: 1, + }}> + {searchText.length > 0 ? ( + <AutocompleteResults + isAutocompleteFetching={isAutocompleteFetching} + autocompleteData={autocompleteData} + searchText={searchText} + onSubmit={onSubmit} + onResultPress={onAutocompleteResultPress} + onProfileClick={handleProfileClick} + /> + ) : ( + <SearchHistory + searchHistory={termHistory} + selectedProfiles={accountHistoryProfiles?.profiles || []} + onItemClick={handleHistoryItemClick} + onProfileClick={handleProfileClick} + onRemoveItemClick={deleteSearchHistoryItem} + onRemoveProfileClick={deleteProfileHistoryItem} + /> + )} + </View> + <View + style={{ + display: showAutocomplete ? 'none' : 'flex', + flex: 1, + }}> + <SearchScreenInner + query={query} + queryWithParams={queryWithParams} + headerHeight={headerHeight} + focusSearchInput={focusSearchInput} + /> + </View> + </Layout.Screen> + ) +} + +let SearchScreenInner = ({ + query, + queryWithParams, + headerHeight, + focusSearchInput, +}: { + query: string + queryWithParams: string + headerHeight: number + focusSearchInput: () => void +}): React.ReactNode => { + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {hasSession} = useSession() + const {gtTablet} = useBreakpoints() + const [activeTab, setActiveTab] = useState(0) + const {_} = useLingui() + + const onPageSelected = useCallback( + (index: number) => { + setMinimalShellMode(false) + setActiveTab(index) + }, + [setMinimalShellMode], + ) + + return queryWithParams ? ( + <SearchResults + query={query} + queryWithParams={queryWithParams} + activeTab={activeTab} + headerHeight={headerHeight} + onPageSelected={onPageSelected} + /> + ) : hasSession ? ( + <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} /> + ) : ( + <Layout.Center> + <View style={a.flex_1}> + {gtTablet && ( + <View + style={[ + a.border_b, + t.atoms.border_contrast_low, + a.px_lg, + a.pt_sm, + a.pb_lg, + ]}> + <Text style={[a.text_2xl, a.font_heavy]}> + <Trans>Search</Trans> + </Text> + </View> + )} + + <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}> + <MagnifyingGlassIcon + strokeWidth={3} + size={60} + style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>} + /> + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> + <Trans>Find posts, users, and feeds on Bluesky</Trans> + </Text> + </View> + </View> + </Layout.Center> + ) +} +SearchScreenInner = memo(SearchScreenInner) + +function useQueryManager({ + initialQuery, + fixedParams, +}: { + initialQuery: string + fixedParams?: Params +}) { + const {query, params: initialParams} = useMemo(() => { + return parseSearchQuery(initialQuery || '') + }, [initialQuery]) + const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery) + const [lang, setLang] = useState(initialParams.lang || '') + + if (initialQuery !== prevInitialQuery) { + // handle new queryParam change (from manual search entry) + setPrevInitialQuery(initialQuery) + setLang(initialParams.lang || '') + } + + const params = useMemo( + () => ({ + // default stuff + ...initialParams, + // managed stuff + lang, + ...fixedParams, + }), + [lang, initialParams, fixedParams], + ) + const handlers = useMemo( + () => ({ + setLang, + }), + [setLang], + ) + + return useMemo(() => { + return { + query, + queryWithParams: makeSearchQuery(query, params), + params: { + ...params, + ...handlers, + }, + } + }, [query, params, handlers]) +} + +function scrollToTopWeb() { + if (isWeb) { + window.scrollTo(0, 0) + } +} 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/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/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> + ) +} diff --git a/src/screens/Search/index.tsx b/src/screens/Search/index.tsx new file mode 100644 index 000000000..429f1e5c7 --- /dev/null +++ b/src/screens/Search/index.tsx @@ -0,0 +1,13 @@ +import { + type NativeStackScreenProps, + type SearchTabNavigatorParams, +} from '#/lib/routes/types' +import {SearchScreenShell} from './Shell' + +export function SearchScreen( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const queryParam = props.route?.params?.q ?? '' + + return <SearchScreenShell queryParam={queryParam} testID="searchScreen" /> +} diff --git a/src/screens/Search/modules/ExploreFeedPreviews.tsx b/src/screens/Search/modules/ExploreFeedPreviews.tsx new file mode 100644 index 000000000..30aa00a3f --- /dev/null +++ b/src/screens/Search/modules/ExploreFeedPreviews.tsx @@ -0,0 +1,264 @@ +import {useMemo} from 'react' +import {type AppBskyFeedDefs, moderatePost} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useInfiniteQuery} from '@tanstack/react-query' + +import {CustomFeedAPI} from '#/lib/api/feed/custom' +import {aggregateUserInterests} from '#/lib/api/feed/utils' +import {FeedTuner} from '#/lib/api/feed-manip' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import { + type FeedPostSlice, + type FeedPostSliceItem, +} from '#/state/queries/post-feed' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +const RQKEY_ROOT = 'feed-previews' +const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds] + +const LIMIT = 8 // sliced to 6, overfetch to account for moderation + +export type FeedPreviewItem = + | { + type: 'topBorder' + key: string + } + | { + type: 'preview:loading' + key: string + } + | { + type: 'preview:error' + key: string + message: string + error: string + } + | { + type: 'preview:loadMoreError' + key: string + } + | { + type: 'preview:empty' + key: string + } + | { + type: 'preview:header' + key: string + feed: AppBskyFeedDefs.GeneratorView + } + | { + type: 'preview:footer' + key: string + } + // copied from PostFeed.tsx + | { + type: 'preview:sliceItem' + key: string + slice: FeedPostSlice + indexInSlice: number + showReplyTo: boolean + hideTopBorder: boolean + } + | { + type: 'preview:sliceViewFullThread' + key: string + uri: string + } + +export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) { + const uris = feeds.map(feed => feed.uri) + const {_} = useLingui() + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const userInterests = aggregateUserInterests(preferences) + const moderationOpts = useModerationOpts() + const enabled = feeds.length > 0 + + const query = useInfiniteQuery({ + enabled, + queryKey: RQKEY(uris), + queryFn: async ({pageParam}) => { + const feed = feeds[pageParam] + const api = new CustomFeedAPI({ + agent, + feedParams: {feed: feed.uri}, + userInterests, + }) + const data = await api.fetch({cursor: undefined, limit: LIMIT}) + return { + feed, + posts: data.feed, + } + }, + initialPageParam: 0, + getNextPageParam: (_p, _a, count) => + count < feeds.length ? count + 1 : undefined, + }) + + const {data, isFetched, isError, isPending, error} = query + + return { + query, + data: useMemo<FeedPreviewItem[]>(() => { + const items: FeedPreviewItem[] = [] + + if (!enabled) return items + + const isEmpty = + !isPending && !data?.pages?.some(page => page.posts.length) + + if (isFetched) { + if (isError && isEmpty) { + items.push({ + type: 'preview:error', + key: 'error', + message: _(msg`An error occurred while fetching the feed.`), + error: cleanError(error), + }) + } else if (isEmpty) { + items.push({ + type: 'preview:empty', + key: 'empty', + }) + } else if (data) { + for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) { + const page = data.pages[pageIndex] + // default feed tuner - we just want it to slice up the feed + const tuner = new FeedTuner([]) + const slices: FeedPreviewItem[] = [] + + let rowIndex = 0 + for (const item of tuner.tune(page.posts)) { + if (item.isFallbackMarker) continue + + const moderations = item.items.map(item => + moderatePost(item.post, moderationOpts!), + ) + + // apply moderation filters + item.items = item.items.filter((_, i) => { + return !moderations[i]?.ui('contentList').filter + }) + + const slice = { + _reactKey: item._reactKey, + _isFeedPostSlice: true, + isFallbackMarker: false, + isIncompleteThread: item.isIncompleteThread, + feedContext: item.feedContext, + reason: item.reason, + feedPostUri: item.feedPostUri, + items: item.items.slice(0, 6).map((subItem, i) => { + const feedPostSliceItem: FeedPostSliceItem = { + _reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`, + uri: subItem.post.uri, + post: subItem.post, + record: subItem.record, + moderation: moderations[i], + parentAuthor: subItem.parentAuthor, + isParentBlocked: subItem.isParentBlocked, + isParentNotFound: subItem.isParentNotFound, + } + return feedPostSliceItem + }), + } + if (slice.isIncompleteThread && slice.items.length >= 3) { + const beforeLast = slice.items.length - 2 + const last = slice.items.length - 1 + slices.push({ + type: 'preview:sliceItem', + key: slice.items[0]._reactKey, + slice: slice, + indexInSlice: 0, + showReplyTo: false, + hideTopBorder: rowIndex === 0, + }) + slices.push({ + type: 'preview:sliceViewFullThread', + key: slice._reactKey + '-viewFullThread', + uri: slice.items[0].uri, + }) + slices.push({ + type: 'preview:sliceItem', + key: slice.items[beforeLast]._reactKey, + slice: slice, + indexInSlice: beforeLast, + showReplyTo: + slice.items[beforeLast].parentAuthor?.did !== + slice.items[beforeLast].post.author.did, + hideTopBorder: false, + }) + slices.push({ + type: 'preview:sliceItem', + key: slice.items[last]._reactKey, + slice: slice, + indexInSlice: last, + showReplyTo: false, + hideTopBorder: false, + }) + } else { + for (let i = 0; i < slice.items.length; i++) { + slices.push({ + type: 'preview:sliceItem', + key: slice.items[i]._reactKey, + slice: slice, + indexInSlice: i, + showReplyTo: i === 0, + hideTopBorder: i === 0 && rowIndex === 0, + }) + } + } + + rowIndex++ + } + + if (slices.length > 0) { + if (pageIndex > 0) { + items.push({ + type: 'topBorder', + key: `topBorder-${page.feed.uri}`, + }) + } + items.push( + { + type: 'preview:footer', + key: `footer-${page.feed.uri}`, + }, + { + type: 'preview:header', + key: `header-${page.feed.uri}`, + feed: page.feed, + }, + ...slices, + ) + } + } + } else if (isError && !isEmpty) { + items.push({ + type: 'preview:loadMoreError', + key: 'loadMoreError', + }) + } + } else { + items.push({ + type: 'preview:loading', + key: 'loading', + }) + } + + return items + }, [ + enabled, + data, + isFetched, + isError, + isPending, + moderationOpts, + _, + error, + ]), + } +} diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx index 602bab87d..4cf84269a 100644 --- a/src/screens/Search/components/ExploreRecommendations.tsx +++ b/src/screens/Search/modules/ExploreRecommendations.tsx @@ -1,8 +1,8 @@ import {View} from 'react-native' -import {AppBskyUnspeccedDefs} from '@atproto/api' +import {type AppBskyUnspeccedDefs} from '@atproto/api' import {Trans} from '@lingui/macro' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import { DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, @@ -18,6 +18,8 @@ import { } from '#/components/TrendingTopics' import {Text} from '#/components/Typography' +// Note: This module is not currently used and may be removed in the future. + export function ExploreRecommendations() { const {enabled} = useTrendingConfig() return enabled ? <Inner /> : null @@ -86,7 +88,11 @@ function Inner() { key={topic.link} topic={topic} onPress={() => { - logEvent('recommendedTopic:click', {context: 'explore'}) + logger.metric( + 'recommendedTopic:click', + {context: 'explore'}, + {statsig: true}, + ) }}> {({hovered}) => ( <TrendingTopic diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx new file mode 100644 index 000000000..070d75910 --- /dev/null +++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx @@ -0,0 +1,228 @@ +import {memo, useEffect} from 'react' +import {View} from 'react-native' +import {type AppBskyActorSearchActors, type ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {type InfiniteData} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import { + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {Button} from '#/components/Button' +import * as ProfileCard from '#/components/ProfileCard' +import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function useLoadEnoughProfiles({ + interest, + data, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, +}: { + interest: string | null + data?: InfiniteData<AppBskyActorSearchActors.OutputSchema> + isLoading: boolean + isFetchingNextPage: boolean + hasNextPage: boolean + fetchNextPage: () => Promise<unknown> +}) { + const profileCount = + data?.pages.flatMap(page => + page.actors.filter(actor => !actor.viewer?.following), + ).length || 0 + const isAnyLoading = isLoading || isFetchingNextPage + const isEnoughProfiles = profileCount > 3 + const shouldFetchMore = !isEnoughProfiles && hasNextPage && !!interest + useEffect(() => { + if (shouldFetchMore && !isAnyLoading) { + logger.info('Not enough suggested accounts - fetching more') + fetchNextPage() + } + }, [shouldFetchMore, fetchNextPage, isAnyLoading, interest]) + + return { + isReady: !shouldFetchMore, + } +} + +export function SuggestedAccountsTabBar({ + selectedInterest, + onSelectInterest, +}: { + selectedInterest: string | null + onSelectInterest: (interest: string | null) => void +}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const {data: preferences} = usePreferencesQuery() + const personalizedInterests = preferences?.interests?.tags + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)) + return ( + <BlockDrawerGesture> + <Tabs + interests={['all', ...interests]} + selectedInterest={selectedInterest || 'all'} + onSelectTab={tab => { + logger.metric( + 'explore:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + hasSearchText={false} + interestsDisplayNames={{ + all: _(msg`All`), + ...interestsDisplayNames, + }} + TabComponent={Tab} + /> + </BlockDrawerGesture> + ) +} + +let Tab = ({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + const activeText = active ? _(msg` (active)`) : '' + return ( + <View + key={interest} + onLayout={e => + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + <Button + label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} + onPress={() => onSelectTab(index)}> + {({hovered, pressed, focused}) => ( + <View + style={[ + a.rounded_full, + a.px_lg, + a.py_sm, + a.border, + active || hovered || pressed || focused + ? [ + t.atoms.bg_contrast_25, + {borderColor: t.atoms.bg_contrast_25.backgroundColor}, + ] + : [t.atoms.bg, t.atoms.border_contrast_low], + ]}> + <Text + style={[ + /* TODO: medium weight */ + active || hovered || pressed || focused + ? t.atoms.text + : t.atoms.text_contrast_medium, + ]}> + {interestsDisplayName} + </Text> + </View> + )} + </Button> + </View> + ) +} +Tab = memo(Tab) + +/** + * Profile card for suggested accounts. Note: border is on the bottom edge + */ +let SuggestedProfileCard = ({ + profile, + moderationOpts, + recId, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + recId?: number + position: number +}): React.ReactNode => { + const t = useTheme() + return ( + <ProfileCard.Link + profile={profile} + style={[a.flex_1]} + onPress={() => { + logger.metric( + 'suggestedUser:press', + { + logContext: 'Explore', + recId, + position, + }, + {statsig: true}, + ) + }}> + <View + style={[ + a.w_full, + a.py_lg, + a.px_lg, + a.border_t, + t.atoms.border_contrast_low, + a.flex_1, + ]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + withIcon={false} + logContext="ExploreSuggestedAccounts" + onFollow={() => { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Explore', + location: 'Card', + recId, + position, + }, + {statsig: true}, + ) + }} + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={2} /> + </ProfileCard.Outer> + </View> + </ProfileCard.Link> + ) +} +SuggestedProfileCard = memo(SuggestedProfileCard) +export {SuggestedProfileCard} diff --git a/src/screens/Search/modules/ExploreTrendingTopics.tsx b/src/screens/Search/modules/ExploreTrendingTopics.tsx new file mode 100644 index 000000000..88d16b393 --- /dev/null +++ b/src/screens/Search/modules/ExploreTrendingTopics.tsx @@ -0,0 +1,278 @@ +import {Pressable, View} from 'react-native' +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useTrendingSettings} from '#/state/preferences/trending' +import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' +import {useTrendingConfig} from '#/state/trending-config' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {formatCount} from '#/view/com/util/numeric/format' +import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf' +import {AvatarStack} from '#/components/AvatarStack' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame' +import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +const TOPIC_COUNT = 5 + +export function ExploreTrendingTopics() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? <Inner /> : null +} + +function Inner() { + const {data: trending, error, isLoading} = useGetTrendsQuery() + const noTopics = !isLoading && !error && !trending?.trends?.length + + return isLoading ? ( + Array.from({length: TOPIC_COUNT}).map((__, i) => ( + <TrendingTopicRowSkeleton key={i} withPosts={i === 0} /> + )) + ) : error || !trending?.trends || noTopics ? null : ( + <> + {trending.trends.map((trend, index) => ( + <TrendRow + key={trend.link} + trend={trend} + rank={index + 1} + onPress={() => { + logger.metric('trendingTopic:click', {context: 'explore'}) + }} + /> + ))} + </> + ) +} + +export function TrendRow({ + trend, + rank, + children, + onPress, +}: ViewStyleProp & { + trend: AppBskyUnspeccedDefs.TrendView + rank: number + children?: React.ReactNode + onPress?: () => void +}) { + const t = useTheme() + const {_, i18n} = useLingui() + const gutters = useGutters([0, 'base']) + + const category = useCategoryDisplayName(trend?.category || 'other') + const age = Math.floor( + (Date.now() - new Date(trend.startedAt || Date.now()).getTime()) / + (1000 * 60 * 60), + ) + const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age + const postCount = trend.postCount + ? _( + plural(trend.postCount, { + other: `${formatCount(i18n, trend.postCount)} posts`, + }), + ) + : null + + return ( + <Link + testID={trend.link} + label={_(msg`Browse topic ${trend.displayName}`)} + to={trend.link} + onPress={onPress} + style={[a.border_b, t.atoms.border_contrast_low]} + PressableComponent={Pressable}> + {({hovered, pressed}) => ( + <> + <View + style={[ + gutters, + a.w_full, + a.py_lg, + a.flex_row, + a.gap_2xs, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <View style={[a.flex_1, a.gap_xs]}> + <View style={[a.flex_row]}> + <Text + style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}> + <Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'> + {rank}. + </Trans> + </Text> + <Text + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {trend.displayName} + </Text> + </View> + <View + style={[ + a.flex_row, + a.gap_sm, + a.align_center, + {paddingLeft: 20}, + ]}> + {trend.actors.length > 0 && ( + <AvatarStack size={20} profiles={trend.actors} /> + )} + <Text + style={[ + a.text_sm, + t.atoms.text_contrast_medium, + web(a.leading_snug), + ]} + numberOfLines={1}> + {postCount} + {postCount && category && <> · </>} + {category} + </Text> + </View> + </View> + <View style={[a.flex_shrink_0]}> + <TrendingIndicator type={badgeType} /> + </View> + </View> + + {children} + </> + )} + </Link> + ) +} + +type TrendingIndicatorType = 'hot' | 'new' | number + +function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) { + const t = useTheme() + const {_} = useLingui() + const pillStyles = [ + a.flex_row, + a.align_center, + a.gap_xs, + a.rounded_full, + a.px_sm, + { + height: 28, + }, + ] + + let Icon: React.ComponentType<SVGIconProps> | null = null + let text: string | null = null + let color: string | null = null + let backgroundColor: string | null = null + + switch (type) { + case 'skeleton': { + return ( + <View + style={[ + pillStyles, + {backgroundColor: t.palette.contrast_25, width: 65, height: 28}, + ]} + /> + ) + } + case 'hot': { + Icon = FlameIcon + color = + t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950 + backgroundColor = + t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200 + text = _(msg`Hot`) + break + } + case 'new': { + Icon = TrendingIcon + text = _(msg`New`) + color = t.palette.positive_700 + backgroundColor = t.palette.positive_50 + break + } + default: { + text = _( + msg({ + message: `${type}h ago`, + comment: + 'trending topic time spent trending. should be as short as possible to fit in a pill', + }), + ) + color = t.atoms.text_contrast_medium.color + backgroundColor = t.atoms.bg_contrast_25.backgroundColor + break + } + } + + return ( + <View style={[pillStyles, {backgroundColor}]}> + {Icon && <Icon size="sm" style={{color}} />} + <Text style={[a.text_sm, {color}]}>{text}</Text> + </View> + ) +} + +function useCategoryDisplayName( + category: AppBskyUnspeccedDefs.TrendView['category'], +) { + const {_} = useLingui() + + switch (category) { + case 'sports': + return _(msg`Sports`) + case 'politics': + return _(msg`Politics`) + case 'video-games': + return _(msg`Video Games`) + case 'pop-culture': + return _(msg`Entertainment`) + case 'news': + return _(msg`News`) + case 'other': + default: + return null + } +} + +export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) { + const t = useTheme() + const gutters = useGutters([0, 'base']) + + return ( + <View + style={[ + gutters, + a.w_full, + a.py_lg, + a.flex_row, + a.gap_2xs, + a.border_b, + t.atoms.border_contrast_low, + ]}> + <View style={[a.flex_1, a.gap_sm]}> + <View style={[a.flex_row, a.align_center]}> + <View style={[{width: 20}]}> + <LoadingPlaceholder + width={12} + height={12} + style={[a.rounded_full]} + /> + </View> + <LoadingPlaceholder width={90} height={18} /> + </View> + <View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}> + <LoadingPlaceholder width={70} height={18} /> + <LoadingPlaceholder width={40} height={18} /> + <LoadingPlaceholder width={60} height={18} /> + </View> + </View> + <View style={[a.flex_shrink_0]}> + <TrendingIndicator type="skeleton" /> + </View> + </View> + ) +} diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/modules/ExploreTrendingVideos.tsx index 00fa76dbf..54eb73312 100644 --- a/src/screens/Search/components/ExploreTrendingVideos.tsx +++ b/src/screens/Search/modules/ExploreTrendingVideos.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {useMemo} from 'react' import {ScrollView, View} from 'react-native' import {AppBskyEmbedVideo, AtUri} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -8,18 +8,12 @@ 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 {logger} from '#/logger' 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 {ButtonIcon} from '#/components/Button' 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 { @@ -37,7 +31,6 @@ const FEED_PARAMS: { } export function ExploreTrendingVideos() { - const t = useTheme() const {_} = useLingui() const gutters = useGutters([0, 'base']) const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) @@ -55,30 +48,30 @@ export function ExploreTrendingVideos() { } }) - 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], - ) + // const {data: saved} = useSavedFeeds() + // const isSavedAlready = useMemo(() => { + // return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) + // }, [saved]) + + // const {mutateAsync: addSavedFeeds, isPending: isPinPending} = + // useAddSavedFeedsMutation() + // const pinFeed = useCallback( + // (e: any) => { + // e.preventDefault() + + // addSavedFeeds([ + // { + // type: 'feed', + // value: VIDEO_FEED_URI, + // pinned: true, + // }, + // ]) + + // // prevent navigation + // return false + // }, + // [addSavedFeeds], + // ) if (error) { return null @@ -86,38 +79,6 @@ export function ExploreTrendingVideos() { 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 @@ -153,7 +114,7 @@ export function ExploreTrendingVideos() { </ScrollView> </BlockDrawerGesture> - {!isSavedAlready && ( + {/* {!isSavedAlready && ( <View style={[ gutters, @@ -179,7 +140,7 @@ export function ExploreTrendingVideos() { <ButtonIcon icon={Pin} position="right" /> </Button> </View> - )} + )} */} </View> ) } @@ -191,7 +152,7 @@ function VideoCards({ }) { const t = useTheme() const {_} = useLingui() - const items = React.useMemo(() => { + const items = useMemo(() => { return data.pages .flatMap(page => page.slices) .map(slice => slice.items[0]) @@ -199,7 +160,7 @@ function VideoCards({ .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) .slice(0, 8) }, [data]) - const href = React.useMemo(() => { + const href = useMemo(() => { const urip = new AtUri(VIDEO_FEED_URI) return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') }, []) @@ -217,9 +178,11 @@ function VideoCards({ sourceInterstitial: 'explore', }} onInteract={() => { - logEvent('videoCard:click', { - context: 'interstitial:explore', - }) + logger.metric( + 'videoCard:click', + {context: 'interstitial:explore'}, + {statsig: true}, + ) }} /> </View> diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index e28c98803..57b86fb2b 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -1,8 +1,8 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' @@ -22,7 +22,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 6d6c46e04..0b5de2303 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,9 +1,12 @@ -import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' import { - InfiniteData, + type AppBskyActorDefs, + type AppBskyActorSearchActors, +} from '@atproto/api' +import { + type InfiniteData, keepPreviousData, - QueryClient, - QueryKey, + type QueryClient, + type QueryKey, useInfiniteQuery, useQuery, } from '@tanstack/react-query' @@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string) => [RQKEY_ROOT, query] const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` -export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query] +export const RQKEY_PAGINATED = (query: string, limit?: number) => [ + RQKEY_ROOT_PAGINATED, + query, + limit, +] export function useActorSearch({ query, @@ -42,10 +49,12 @@ export function useActorSearchPaginated({ query, enabled, maintainData, + limit = 25, }: { query: string enabled?: boolean maintainData?: boolean + limit?: number }) { const agent = useAgent() return useInfiniteQuery< @@ -56,11 +65,11 @@ export function useActorSearchPaginated({ string | undefined >({ staleTime: STALE.MINUTES.FIVE, - queryKey: RQKEY_PAGINATED(query), + queryKey: RQKEY_PAGINATED(query, limit), queryFn: async ({pageParam}) => { const res = await agent.searchActors({ q: query, - limit: 25, + limit, cursor: pageParam, }) return res.data diff --git a/src/state/queries/trending/useGetSuggestedFeedsQuery.ts b/src/state/queries/trending/useGetSuggestedFeedsQuery.ts new file mode 100644 index 000000000..16522f5c9 --- /dev/null +++ b/src/state/queries/trending/useGetSuggestedFeedsQuery.ts @@ -0,0 +1,48 @@ +import {useQuery} from '@tanstack/react-query' + +import { + aggregateUserInterests, + createBskyTopicsHeader, +} from '#/lib/api/feed/utils' +import {getContentLanguages} from '#/state/preferences/languages' +import {STALE} from '#/state/queries' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +export const DEFAULT_LIMIT = 5 + +export const createGetTrendsQueryKey = () => ['suggested-feeds'] + +export function useGetSuggestedFeedsQuery() { + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const savedFeeds = preferences?.savedFeeds + + return useQuery({ + enabled: !!savedFeeds, + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.ONE, + queryKey: createGetTrendsQueryKey(), + queryFn: async () => { + const contentLangs = getContentLanguages().join(',') + const {data} = await agent.app.bsky.unspecced.getSuggestedFeeds( + { + limit: DEFAULT_LIMIT, + }, + { + headers: { + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), + 'Accept-Language': contentLangs, + }, + }, + ) + + return { + feeds: data.feeds.filter(feed => { + const isSaved = !!savedFeeds?.find(s => s.value === feed.uri) + return !isSaved + }), + } + }, + }) +} diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts new file mode 100644 index 000000000..d96bf0603 --- /dev/null +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -0,0 +1,59 @@ +import React from 'react' +import {type AppBskyUnspeccedGetTrends} from '@atproto/api' +import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords' +import {useQuery} from '@tanstack/react-query' + +import { + aggregateUserInterests, + createBskyTopicsHeader, +} from '#/lib/api/feed/utils' +import {getContentLanguages} from '#/state/preferences/languages' +import {STALE} from '#/state/queries' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +export const DEFAULT_LIMIT = 5 + +export const createGetTrendsQueryKey = () => ['trends'] + +export function useGetTrendsQuery() { + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const mutedWords = React.useMemo(() => { + return preferences?.moderationPrefs?.mutedWords || [] + }, [preferences?.moderationPrefs]) + + return useQuery({ + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.THREE, + queryKey: createGetTrendsQueryKey(), + queryFn: async () => { + const contentLangs = getContentLanguages().join(',') + const {data} = await agent.app.bsky.unspecced.getTrends( + { + limit: DEFAULT_LIMIT, + }, + { + headers: { + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), + 'Accept-Language': contentLangs, + }, + }, + ) + return data + }, + select: React.useCallback( + (data: AppBskyUnspeccedGetTrends.OutputSchema) => { + return { + trends: (data.trends ?? []).filter(t => { + return !hasMutedWord({ + mutedWords, + text: t.topic + ' ' + t.displayName + ' ' + t.category, + }) + }), + } + }, + [mutedWords], + ), + }) +} diff --git a/src/state/queries/useSuggestedStarterPacksQuery.ts b/src/state/queries/useSuggestedStarterPacksQuery.ts new file mode 100644 index 000000000..18fe6439e --- /dev/null +++ b/src/state/queries/useSuggestedStarterPacksQuery.ts @@ -0,0 +1,38 @@ +import {useQuery} from '@tanstack/react-query' + +import { + aggregateUserInterests, + createBskyTopicsHeader, +} from '#/lib/api/feed/utils' +import {getContentLanguages} from '#/state/preferences/languages' +import {STALE} from '#/state/queries' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +export const createSuggestedStarterPacksQueryKey = () => [ + 'suggested-starter-packs', +] + +export function useSuggestedStarterPacksQuery() { + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const contentLangs = getContentLanguages().join(',') + + return useQuery({ + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.ONE, + queryKey: createSuggestedStarterPacksQueryKey(), + async queryFn() { + const {data} = await agent.app.bsky.unspecced.getSuggestedStarterPacks( + undefined, + { + headers: { + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), + 'Accept-Language': contentLangs, + }, + }, + ) + return data + }, + }) +} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index a99077c0c..3a6b8f660 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -3,13 +3,13 @@ import { ActivityIndicator, AppState, Dimensions, - ListRenderItemInfo, - StyleProp, + type ListRenderItemInfo, + type StyleProp, StyleSheet, View, - ViewStyle, + type ViewStyle, } from 'react-native' -import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' +import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -24,21 +24,21 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useTrendingSettings} from '#/state/preferences/trending' import {STALE} from '#/state/queries' import { - AuthorFilter, - FeedDescriptor, - FeedParams, - FeedPostSlice, - FeedPostSliceItem, + type AuthorFilter, + type FeedDescriptor, + type FeedParams, + type FeedPostSlice, + type FeedPostSliceItem, pollLatest, RQKEY, usePostFeedQuery, } from '#/state/queries/post-feed' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' -import {List, ListRef} from '#/view/com/util/List' +import {List, type ListRef} from '#/view/com/util/List' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' -import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' +import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {useBreakpoints, useLayoutBreakpoints} from '#/alf' import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' import { @@ -767,14 +767,14 @@ const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, }) -function isThreadParentAt<T>(arr: Array<T>, i: number) { +export function isThreadParentAt<T>(arr: Array<T>, i: number) { if (arr.length === 1) { return false } return i < arr.length - 1 } -function isThreadChildAt<T>(arr: Array<T>, i: number) { +export function isThreadChildAt<T>(arr: Array<T>, i: number) { if (arr.length === 1) { return false } diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts index 8f3ebd0e7..f05f28540 100644 --- a/src/view/com/util/numeric/format.ts +++ b/src/view/com/util/numeric/format.ts @@ -1,4 +1,4 @@ -import {I18n} from '@lingui/core' +import {type I18n} from '@lingui/core' export const formatCount = (i18n: I18n, num: number) => { return i18n.number(num, { diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx deleted file mode 100644 index 520e103a4..000000000 --- a/src/view/screens/Search/Explore.tsx +++ /dev/null @@ -1,641 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import { - AppBskyActorDefs, - AppBskyFeedDefs, - moderateProfile, - ModerationDecision, - ModerationOpts, -} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logEvent} from '#/lib/statsig/statsig' -import {cleanError} from '#/lib/strings/errors' -import {logger} from '#/logger' -import {isNative, isWeb} from '#/platform/detection' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useGetPopularFeedsQuery} from '#/state/queries/feed' -import {usePreferencesQuery} from '#/state/queries/preferences' -import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' -import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' -import {List} from '#/view/com/util/List' -import { - FeedFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from '#/view/com/util/LoadingPlaceholder' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' -import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' -import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos' -import {atoms as a, useTheme, ViewStyleProp} from '#/alf' -import {Button} from '#/components/Button' -import * as FeedCard from '#/components/FeedCard' -import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {Props as SVGIconProps} from '#/components/icons/common' -import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' -import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' - -function SuggestedItemsHeader({ - title, - description, - style, - icon: Icon, -}: { - title: string - description: string - icon: React.ComponentType<SVGIconProps> -} & ViewStyleProp) { - const t = useTheme() - - return ( - <View - style={[ - isWeb - ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] - : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], - a.border_b, - t.atoms.border_contrast_low, - style, - ]}> - <View style={[a.flex_1, a.gap_sm]}> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Icon - size="lg" - fill={t.palette.primary_500} - style={{marginLeft: -2}} - /> - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text> - </View> - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> - {description} - </Text> - </View> - </View> - ) -} - -type LoadMoreItem = - | { - type: 'profile' - key: string - avatar: string | undefined - moderation: ModerationDecision - } - | { - type: 'feed' - key: string - avatar: string | undefined - moderation: undefined - } - -function LoadMore({ - item, - moderationOpts, -}: { - item: ExploreScreenItems & {type: 'loadMore'} - moderationOpts?: ModerationOpts -}) { - const t = useTheme() - const {_} = useLingui() - const items: LoadMoreItem[] = React.useMemo(() => { - return item.items - .map(_item => { - let loadMoreItem: LoadMoreItem | undefined - if (_item.type === 'profile') { - loadMoreItem = { - type: 'profile', - key: _item.profile.did, - avatar: _item.profile.avatar, - moderation: moderateProfile(_item.profile, moderationOpts!), - } - } else if (_item.type === 'feed') { - loadMoreItem = { - type: 'feed', - key: _item.feed.uri, - avatar: _item.feed.avatar, - moderation: undefined, - } - } - return loadMoreItem - }) - .filter(n => !!n) - }, [item.items, moderationOpts]) - - if (items.length === 0) return null - - const type = items[0].type - - return ( - <View style={[]}> - <Button - label={_(msg`Load more`)} - onPress={item.onLoadMore} - style={[a.relative, a.w_full]}> - {({hovered, pressed}) => ( - <View - style={[ - a.flex_1, - a.flex_row, - a.align_center, - a.px_lg, - a.py_md, - (hovered || pressed) && t.atoms.bg_contrast_25, - ]}> - <View - style={[ - a.relative, - { - height: 32, - width: 32 + 15 * items.length, - }, - ]}> - <View - style={[ - a.align_center, - a.justify_center, - t.atoms.bg_contrast_25, - a.absolute, - { - width: 30, - height: 30, - left: 0, - borderWidth: 1, - backgroundColor: t.palette.primary_500, - borderColor: t.atoms.bg.backgroundColor, - borderRadius: type === 'profile' ? 999 : 4, - zIndex: 4, - }, - ]}> - <ArrowBottom fill={t.palette.white} /> - </View> - {items.map((_item, i) => { - return ( - <View - key={_item.key} - style={[ - t.atoms.bg_contrast_25, - a.absolute, - { - width: 30, - height: 30, - left: (i + 1) * 15, - borderWidth: 1, - borderColor: t.atoms.bg.backgroundColor, - borderRadius: _item.type === 'profile' ? 999 : 4, - zIndex: 3 - i, - }, - ]}> - {moderationOpts && ( - <> - {_item.type === 'profile' ? ( - <UserAvatar - size={28} - avatar={_item.avatar} - moderation={_item.moderation.ui('avatar')} - type="user" - /> - ) : _item.type === 'feed' ? ( - <UserAvatar - size={28} - avatar={_item.avatar} - type="algo" - /> - ) : null} - </> - )} - </View> - ) - })} - </View> - - <Text - style={[ - a.pl_sm, - a.leading_snug, - hovered ? t.atoms.text : t.atoms.text_contrast_medium, - ]}> - {type === 'profile' ? ( - <Trans>Load more suggested follows</Trans> - ) : ( - <Trans>Load more suggested feeds</Trans> - )} - </Text> - - <View style={[a.flex_1, a.align_end]}> - {item.isLoadingMore && <Loader size="lg" />} - </View> - </View> - )} - </Button> - </View> - ) -} - -type ExploreScreenItems = - | { - type: 'header' - key: string - title: string - description: string - style?: ViewStyleProp['style'] - icon: React.ComponentType<SVGIconProps> - } - | { - type: 'trendingTopics' - key: string - } - | { - type: 'trendingVideos' - key: string - } - | { - type: 'recommendations' - key: string - } - | { - type: 'profile' - key: string - profile: AppBskyActorDefs.ProfileView - recId?: number - } - | { - type: 'feed' - key: string - feed: AppBskyFeedDefs.GeneratorView - } - | { - type: 'loadMore' - key: string - isLoadingMore: boolean - onLoadMore: () => void - items: ExploreScreenItems[] - } - | { - type: 'profilePlaceholder' - key: string - } - | { - type: 'feedPlaceholder' - key: string - } - | { - type: 'error' - key: string - message: string - error: string - } - -export function Explore() { - const {_} = useLingui() - const t = useTheme() - const {data: preferences, error: preferencesError} = usePreferencesQuery() - const moderationOpts = useModerationOpts() - const { - data: profiles, - hasNextPage: hasNextProfilesPage, - isLoading: isLoadingProfiles, - isFetchingNextPage: isFetchingNextProfilesPage, - error: profilesError, - fetchNextPage: fetchNextProfilesPage, - } = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10}) - const { - data: feeds, - hasNextPage: hasNextFeedsPage, - isLoading: isLoadingFeeds, - isFetchingNextPage: isFetchingNextFeedsPage, - error: feedsError, - fetchNextPage: fetchNextFeedsPage, - } = useGetPopularFeedsQuery({limit: 10}) - - const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles - const onLoadMoreProfiles = React.useCallback(async () => { - if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) - return - try { - await fetchNextProfilesPage() - } catch (err) { - logger.error('Failed to load more suggested follows', {message: err}) - } - }, [ - isFetchingNextProfilesPage, - hasNextProfilesPage, - profilesError, - fetchNextProfilesPage, - ]) - - const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds - const onLoadMoreFeeds = React.useCallback(async () => { - if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return - try { - await fetchNextFeedsPage() - } catch (err) { - logger.error('Failed to load more suggested follows', {message: err}) - } - }, [ - isFetchingNextFeedsPage, - hasNextFeedsPage, - feedsError, - fetchNextFeedsPage, - ]) - - const items = React.useMemo<ExploreScreenItems[]>(() => { - const i: ExploreScreenItems[] = [] - - i.push({ - type: 'trendingTopics', - key: `trending-topics`, - }) - - if (isNative) { - i.push({ - type: 'trendingVideos', - key: `trending-videos`, - }) - } - - i.push({ - type: 'recommendations', - key: `recommendations`, - }) - - i.push({ - type: 'header', - key: 'suggested-follows-header', - title: _(msg`Suggested accounts`), - description: _( - msg`Follow more accounts to get connected to your interests and build your network.`, - ), - icon: Person, - }) - - if (profiles) { - // Currently the responses contain duplicate items. - // Needs to be fixed on backend, but let's dedupe to be safe. - let seen = new Set() - const profileItems: ExploreScreenItems[] = [] - for (const page of profiles.pages) { - for (const actor of page.actors) { - if (!seen.has(actor.did)) { - seen.add(actor.did) - profileItems.push({ - type: 'profile', - key: actor.did, - profile: actor, - recId: page.recId, - }) - } - } - } - - if (hasNextProfilesPage) { - // splice off 3 as previews if we have a next page - const previews = profileItems.splice(-3) - // push remainder - i.push(...profileItems) - i.push({ - type: 'loadMore', - key: 'loadMoreProfiles', - isLoadingMore: isLoadingMoreProfiles, - onLoadMore: onLoadMoreProfiles, - items: previews, - }) - } else { - i.push(...profileItems) - } - } else { - if (profilesError) { - i.push({ - type: 'error', - key: 'profilesError', - message: _(msg`Failed to load suggested follows`), - error: cleanError(profilesError), - }) - } else { - i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) - } - } - - i.push({ - type: 'header', - key: 'suggested-feeds-header', - title: _(msg`Discover new feeds`), - description: _( - msg`Choose your own timeline! Feeds built by the community help you find content you love.`, - ), - style: [a.pt_5xl], - icon: ListSparkle, - }) - - if (feeds && preferences) { - // Currently the responses contain duplicate items. - // Needs to be fixed on backend, but let's dedupe to be safe. - let seen = new Set() - const feedItems: ExploreScreenItems[] = [] - for (const page of feeds.pages) { - for (const feed of page.feeds) { - if (!seen.has(feed.uri)) { - seen.add(feed.uri) - feedItems.push({ - type: 'feed', - key: feed.uri, - feed, - }) - } - } - } - - // feeds errors can occur during pagination, so feeds is truthy - if (feedsError) { - i.push({ - type: 'error', - key: 'feedsError', - message: _(msg`Failed to load suggested feeds`), - error: cleanError(feedsError), - }) - } else if (preferencesError) { - i.push({ - type: 'error', - key: 'preferencesError', - message: _(msg`Failed to load feeds preferences`), - error: cleanError(preferencesError), - }) - } else if (hasNextFeedsPage) { - const preview = feedItems.splice(-3) - i.push(...feedItems) - i.push({ - type: 'loadMore', - key: 'loadMoreFeeds', - isLoadingMore: isLoadingMoreFeeds, - onLoadMore: onLoadMoreFeeds, - items: preview, - }) - } else { - i.push(...feedItems) - } - } else { - if (feedsError) { - i.push({ - type: 'error', - key: 'feedsError', - message: _(msg`Failed to load suggested feeds`), - error: cleanError(feedsError), - }) - } else if (preferencesError) { - i.push({ - type: 'error', - key: 'preferencesError', - message: _(msg`Failed to load feeds preferences`), - error: cleanError(preferencesError), - }) - } else { - i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) - } - } - - return i - }, [ - _, - profiles, - feeds, - preferences, - onLoadMoreFeeds, - onLoadMoreProfiles, - isLoadingMoreProfiles, - isLoadingMoreFeeds, - profilesError, - feedsError, - preferencesError, - hasNextProfilesPage, - hasNextFeedsPage, - ]) - - const renderItem = React.useCallback( - ({item, index}: {item: ExploreScreenItems; index: number}) => { - switch (item.type) { - case 'header': { - return ( - <SuggestedItemsHeader - title={item.title} - description={item.description} - style={item.style} - icon={item.icon} - /> - ) - } - case 'trendingTopics': { - return <ExploreTrendingTopics /> - } - case 'trendingVideos': { - return <ExploreTrendingVideos /> - } - case 'recommendations': { - return <ExploreRecommendations /> - } - case 'profile': { - return ( - <View style={[a.border_b, t.atoms.border_contrast_low]}> - <ProfileCardWithFollowBtn - profile={item.profile} - noBg - noBorder - showKnownFollowers - onPress={() => { - logEvent('suggestedUser:press', { - logContext: 'Explore', - recId: item.recId, - position: index, - }) - }} - onFollow={() => { - logEvent('suggestedUser:follow', { - logContext: 'Explore', - location: 'Card', - recId: item.recId, - position: index, - }) - }} - /> - </View> - ) - } - case 'feed': { - return ( - <View - style={[ - a.border_b, - t.atoms.border_contrast_low, - a.px_lg, - a.py_lg, - ]}> - <FeedCard.Default view={item.feed} /> - </View> - ) - } - case 'loadMore': { - return <LoadMore item={item} moderationOpts={moderationOpts} /> - } - case 'profilePlaceholder': { - return <ProfileCardFeedLoadingPlaceholder /> - } - case 'feedPlaceholder': { - return <FeedFeedLoadingPlaceholder /> - } - case 'error': { - return ( - <View - style={[ - a.border_t, - a.pt_md, - a.px_md, - t.atoms.border_contrast_low, - ]}> - <View - style={[ - a.flex_row, - a.gap_md, - a.p_lg, - a.rounded_sm, - t.atoms.bg_contrast_25, - ]}> - <CircleInfo size="md" fill={t.palette.negative_400} /> - <View style={[a.flex_1, a.gap_sm]}> - <Text style={[a.font_bold, a.leading_snug]}> - {item.message} - </Text> - <Text - style={[ - a.italic, - a.leading_snug, - t.atoms.text_contrast_medium, - ]}> - {item.error} - </Text> - </View> - </View> - </View> - ) - } - } - }, - [t, moderationOpts], - ) - - // note: actually not a screen, instead it's nested within - // the search screen. so we don't need Layout.Screen - return ( - <List - data={items} - renderItem={renderItem} - keyExtractor={item => item.key} - // @ts-ignore web only -prf - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - /> - ) -} diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx deleted file mode 100644 index 785e1872e..000000000 --- a/src/view/screens/Search/Search.tsx +++ /dev/null @@ -1,1165 +0,0 @@ -import React, {useCallback, useLayoutEffect, useMemo} from 'react' -import { - ActivityIndicator, - Pressable, - StyleProp, - StyleSheet, - TextInput, - View, - ViewStyle, -} from 'react-native' -import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' -import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' -import {createHitslop, HITSLOP_20} from '#/lib/constants' -import {HITSLOP_10} from '#/lib/constants' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {MagnifyingGlassIcon} from '#/lib/icons' -import {makeProfileLink} from '#/lib/routes/links' -import {NavigationProp} from '#/lib/routes/types' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {augmentSearchQuery} from '#/lib/strings/helpers' -import {languageName} from '#/locale/helpers' -import {isNative, isWeb} from '#/platform/detection' -import {listenSoftReset} from '#/state/events' -import {useLanguagePrefs} from '#/state/preferences/languages' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' -import {useActorSearch} from '#/state/queries/actor-search' -import {usePopularFeedsSearch} from '#/state/queries/feed' -import { - unstableCacheProfileView, - useProfilesQuery, -} from '#/state/queries/profile' -import {useSearchPostsQuery} from '#/state/queries/search-posts' -import {useSession} from '#/state/session' -import {useSetMinimalShellMode} from '#/state/shell' -import {Pager} from '#/view/com/pager/Pager' -import {TabBar} from '#/view/com/pager/TabBar' -import {Post} from '#/view/com/post/Post' -import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' -import {Link} from '#/view/com/util/Link' -import {List} from '#/view/com/util/List' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {Explore} from '#/view/screens/Search/Explore' -import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' -import {makeSearchQuery, Params, parseSearchQuery} from '#/screens/Search/utils' -import { - atoms as a, - native, - platform, - tokens, - useBreakpoints, - useTheme, - web, -} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as FeedCard from '#/components/FeedCard' -import {SearchInput} from '#/components/forms/SearchInput' -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 {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' -import * as Layout from '#/components/Layout' -import * as Menu from '#/components/Menu' -import {Text} from '#/components/Typography' -import {account, useStorage} from '#/storage' -import * as bsky from '#/types/bsky' - -function Loader() { - return ( - <Layout.Content> - <View style={[a.py_xl]}> - <ActivityIndicator /> - </View> - </Layout.Content> - ) -} - -function EmptyState({message, error}: {message: string; error?: string}) { - const t = useTheme() - - return ( - <Layout.Content> - <View style={[a.p_xl]}> - <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}> - <Text style={[a.text_md]}>{message}</Text> - - {error && ( - <> - <View - style={[ - { - marginVertical: 12, - height: 1, - width: '100%', - backgroundColor: t.atoms.text.color, - opacity: 0.2, - }, - ]} - /> - - <Text style={[t.atoms.text_contrast_medium]}> - <Trans>Error:</Trans> {error} - </Text> - </> - )} - </View> - </View> - </Layout.Content> - ) -} - -type SearchResultSlice = - | { - type: 'post' - key: string - post: AppBskyFeedDefs.PostView - } - | { - type: 'loadingMore' - key: string - } - -let SearchScreenPostResults = ({ - query, - sort, - active, -}: { - query: string - sort?: 'top' | 'latest' - active: boolean -}): React.ReactNode => { - const {_} = useLingui() - const {currentAccount} = useSession() - const [isPTR, setIsPTR] = React.useState(false) - - const augmentedQuery = React.useMemo(() => { - return augmentSearchQuery(query || '', {did: currentAccount?.did}) - }, [query, currentAccount]) - - const { - isFetched, - data: results, - isFetching, - error, - refetch, - fetchNextPage, - isFetchingNextPage, - hasNextPage, - } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) - - const onPullToRefresh = React.useCallback(async () => { - setIsPTR(true) - await refetch() - setIsPTR(false) - }, [setIsPTR, refetch]) - const onEndReached = React.useCallback(() => { - if (isFetching || !hasNextPage || error) return - fetchNextPage() - }, [isFetching, error, hasNextPage, fetchNextPage]) - - const posts = React.useMemo(() => { - return results?.pages.flatMap(page => page.posts) || [] - }, [results]) - const items = React.useMemo(() => { - let temp: SearchResultSlice[] = [] - - const seenUris = new Set() - for (const post of posts) { - if (seenUris.has(post.uri)) { - continue - } - temp.push({ - type: 'post', - key: post.uri, - post, - }) - seenUris.add(post.uri) - } - - if (isFetchingNextPage) { - temp.push({ - type: 'loadingMore', - key: 'loadingMore', - }) - } - - return temp - }, [posts, isFetchingNextPage]) - - return error ? ( - <EmptyState - message={_( - msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, - )} - error={error.toString()} - /> - ) : ( - <> - {isFetched ? ( - <> - {posts.length ? ( - <List - data={items} - renderItem={({item}) => { - if (item.type === 'post') { - return <Post post={item.post} /> - } else { - return null - } - }} - keyExtractor={item => item.key} - refreshing={isPTR} - onRefresh={onPullToRefresh} - onEndReached={onEndReached} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - <EmptyState message={_(msg`No results found for ${query}`)} /> - )} - </> - ) : ( - <Loader /> - )} - </> - ) -} -SearchScreenPostResults = React.memo(SearchScreenPostResults) - -let SearchScreenUserResults = ({ - query, - active, -}: { - query: string - active: boolean -}): React.ReactNode => { - const {_} = useLingui() - - const {data: results, isFetched} = useActorSearch({ - query, - enabled: active, - }) - - return isFetched && results ? ( - <> - {results.length ? ( - <List - data={results} - renderItem={({item}) => ( - <ProfileCardWithFollowBtn profile={item} noBg /> - )} - keyExtractor={item => item.did} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - <EmptyState message={_(msg`No results found for ${query}`)} /> - )} - </> - ) : ( - <Loader /> - ) -} -SearchScreenUserResults = React.memo(SearchScreenUserResults) - -let SearchScreenFeedsResults = ({ - query, - active, -}: { - query: string - active: boolean -}): React.ReactNode => { - const t = useTheme() - const {_} = useLingui() - - const {data: results, isFetched} = usePopularFeedsSearch({ - query, - enabled: active, - }) - - return isFetched && results ? ( - <> - {results.length ? ( - <List - data={results} - renderItem={({item}) => ( - <View - style={[ - a.border_b, - t.atoms.border_contrast_low, - a.px_lg, - a.py_lg, - ]}> - <FeedCard.Default view={item} /> - </View> - )} - keyExtractor={item => item.uri} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - <EmptyState message={_(msg`No results found for ${query}`)} /> - )} - </> - ) : ( - <Loader /> - ) -} -SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) - -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> - ) -} - -function useQueryManager({ - initialQuery, - fixedParams, -}: { - initialQuery: string - fixedParams?: Params -}) { - const {query, params: initialParams} = React.useMemo(() => { - return parseSearchQuery(initialQuery || '') - }, [initialQuery]) - const [prevInitialQuery, setPrevInitialQuery] = React.useState(initialQuery) - const [lang, setLang] = React.useState(initialParams.lang || '') - - if (initialQuery !== prevInitialQuery) { - // handle new queryParam change (from manual search entry) - setPrevInitialQuery(initialQuery) - setLang(initialParams.lang || '') - } - - const params = React.useMemo( - () => ({ - // default stuff - ...initialParams, - // managed stuff - lang, - ...fixedParams, - }), - [lang, initialParams, fixedParams], - ) - const handlers = React.useMemo( - () => ({ - setLang, - }), - [setLang], - ) - - return React.useMemo(() => { - return { - query, - queryWithParams: makeSearchQuery(query, params), - params: { - ...params, - ...handlers, - }, - } - }, [query, params, handlers]) -} - -let SearchScreenInner = ({ - query, - queryWithParams, - headerHeight, -}: { - query: string - queryWithParams: string - headerHeight: number -}): React.ReactNode => { - const t = useTheme() - const setMinimalShellMode = useSetMinimalShellMode() - const {hasSession} = useSession() - const {gtTablet} = useBreakpoints() - const [activeTab, setActiveTab] = React.useState(0) - const {_} = useLingui() - - const onPageSelected = React.useCallback( - (index: number) => { - setMinimalShellMode(false) - setActiveTab(index) - }, - [setMinimalShellMode], - ) - - const sections = React.useMemo(() => { - if (!queryWithParams) return [] - const noParams = queryWithParams === query - return [ - { - title: _(msg`Top`), - component: ( - <SearchScreenPostResults - query={queryWithParams} - sort="top" - active={activeTab === 0} - /> - ), - }, - { - title: _(msg`Latest`), - component: ( - <SearchScreenPostResults - query={queryWithParams} - sort="latest" - active={activeTab === 1} - /> - ), - }, - noParams && { - title: _(msg`People`), - component: ( - <SearchScreenUserResults query={query} active={activeTab === 2} /> - ), - }, - noParams && { - title: _(msg`Feeds`), - component: ( - <SearchScreenFeedsResults query={query} active={activeTab === 3} /> - ), - }, - ].filter(Boolean) as { - title: string - component: React.ReactNode - }[] - }, [_, query, queryWithParams, activeTab]) - - return queryWithParams ? ( - <Pager - onPageSelected={onPageSelected} - renderTabBar={props => ( - <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}> - <TabBar items={sections.map(section => section.title)} {...props} /> - </Layout.Center> - )} - initialPage={0}> - {sections.map((section, i) => ( - <View key={i}>{section.component}</View> - ))} - </Pager> - ) : hasSession ? ( - <Explore /> - ) : ( - <Layout.Center> - <View style={a.flex_1}> - {gtTablet && ( - <View - style={[ - a.border_b, - t.atoms.border_contrast_low, - a.px_lg, - a.pt_sm, - a.pb_lg, - ]}> - <Text style={[a.text_2xl, a.font_heavy]}> - <Trans>Search</Trans> - </Text> - </View> - )} - - <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}> - <MagnifyingGlassIcon - strokeWidth={3} - size={60} - style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>} - /> - <Text style={[t.atoms.text_contrast_medium, a.text_md]}> - <Trans>Find posts, users, and feeds on Bluesky</Trans> - </Text> - </View> - </View> - </Layout.Center> - ) -} -SearchScreenInner = React.memo(SearchScreenInner) - -export function SearchScreen( - props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, -) { - const queryParam = props.route?.params?.q ?? '' - - return <SearchScreenShell queryParam={queryParam} testID="searchScreen" /> -} - -export function SearchScreenShell({ - queryParam, - testID, - fixedParams, - navButton = 'menu', - inputPlaceholder, -}: { - queryParam: string - testID: string - fixedParams?: Params - navButton?: 'back' | 'menu' - inputPlaceholder?: string -}) { - const t = useTheme() - const {gtMobile} = useBreakpoints() - const navigation = useNavigation<NavigationProp>() - const route = useRoute() - const textInput = React.useRef<TextInput>(null) - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {currentAccount} = useSession() - const queryClient = useQueryClient() - - // Query terms - const [searchText, setSearchText] = React.useState<string>(queryParam) - const {data: autocompleteData, isFetching: isAutocompleteFetching} = - useActorAutocompleteQuery(searchText, true) - - const [showAutocomplete, setShowAutocomplete] = React.useState(false) - - const [termHistory = [], setTermHistory] = useStorage(account, [ - currentAccount?.did ?? 'pwi', - 'searchTermHistory', - ] as const) - const [accountHistory = [], setAccountHistory] = useStorage(account, [ - currentAccount?.did ?? 'pwi', - 'searchAccountHistory', - ]) - - const {data: accountHistoryProfiles} = useProfilesQuery({ - handles: accountHistory, - maintainData: true, - }) - - const updateSearchHistory = useCallback( - async (item: string) => { - if (!item) return - const newSearchHistory = [ - item, - ...termHistory.filter(search => search !== item), - ].slice(0, 6) - setTermHistory(newSearchHistory) - }, - [termHistory, setTermHistory], - ) - - const updateProfileHistory = useCallback( - async (item: bsky.profile.AnyProfileView) => { - const newAccountHistory = [ - item.did, - ...accountHistory.filter(p => p !== item.did), - ].slice(0, 5) - setAccountHistory(newAccountHistory) - }, - [accountHistory, setAccountHistory], - ) - - const deleteSearchHistoryItem = useCallback( - async (item: string) => { - setTermHistory(termHistory.filter(search => search !== item)) - }, - [termHistory, setTermHistory], - ) - const deleteProfileHistoryItem = useCallback( - async (item: AppBskyActorDefs.ProfileViewDetailed) => { - setAccountHistory(accountHistory.filter(p => p !== item.did)) - }, - [accountHistory, setAccountHistory], - ) - - const {params, query, queryWithParams} = useQueryManager({ - initialQuery: queryParam, - fixedParams, - }) - const showFilters = Boolean(queryWithParams && !showAutocomplete) - - // web only - measure header height for sticky positioning - const [headerHeight, setHeaderHeight] = React.useState(0) - const headerRef = React.useRef(null) - useLayoutEffect(() => { - if (isWeb) { - if (!headerRef.current) return - const measurement = (headerRef.current as Element).getBoundingClientRect() - setHeaderHeight(measurement.height) - } - }, []) - - useFocusEffect( - useNonReactiveCallback(() => { - if (isWeb) { - setSearchText(queryParam) - } - }), - ) - - const onPressClearQuery = React.useCallback(() => { - scrollToTopWeb() - setSearchText('') - textInput.current?.focus() - }, []) - - const onChangeText = React.useCallback(async (text: string) => { - scrollToTopWeb() - setSearchText(text) - }, []) - - const navigateToItem = React.useCallback( - (item: string) => { - scrollToTopWeb() - setShowAutocomplete(false) - updateSearchHistory(item) - - if (isWeb) { - // @ts-expect-error route is not typesafe - navigation.push(route.name, {...route.params, q: item}) - } else { - textInput.current?.blur() - navigation.setParams({q: item}) - } - }, - [updateSearchHistory, navigation, route], - ) - - const onPressCancelSearch = React.useCallback(() => { - scrollToTopWeb() - textInput.current?.blur() - setShowAutocomplete(false) - if (isWeb) { - // Empty params resets the URL to be /search rather than /search?q= - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {q: _q, ...parameters} = (route.params ?? {}) as { - [key: string]: string - } - // @ts-expect-error route is not typesafe - navigation.replace(route.name, parameters) - } else { - setSearchText('') - navigation.setParams({q: ''}) - } - }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) - - const onSubmit = React.useCallback(() => { - navigateToItem(searchText) - }, [navigateToItem, searchText]) - - const onAutocompleteResultPress = React.useCallback(() => { - if (isWeb) { - setShowAutocomplete(false) - } else { - textInput.current?.blur() - } - }, []) - - const handleHistoryItemClick = React.useCallback( - (item: string) => { - setSearchText(item) - navigateToItem(item) - }, - [navigateToItem], - ) - - const handleProfileClick = React.useCallback( - (profile: bsky.profile.AnyProfileView) => { - unstableCacheProfileView(queryClient, profile) - // Slight delay to avoid updating during push nav animation. - setTimeout(() => { - updateProfileHistory(profile) - }, 400) - }, - [updateProfileHistory, queryClient], - ) - - const onSoftReset = React.useCallback(() => { - if (isWeb) { - // Empty params resets the URL to be /search rather than /search?q= - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {q: _q, ...parameters} = (route.params ?? {}) as { - [key: string]: string - } - // @ts-expect-error route is not typesafe - navigation.replace(route.name, parameters) - } else { - setSearchText('') - navigation.setParams({q: ''}) - textInput.current?.focus() - } - }, [navigation, route]) - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - return listenSoftReset(onSoftReset) - }, [onSoftReset, setMinimalShellMode]), - ) - - const onSearchInputFocus = React.useCallback(() => { - if (isWeb) { - // Prevent a jump on iPad by ensuring that - // the initial focused render has no result list. - requestAnimationFrame(() => { - setShowAutocomplete(true) - }) - } else { - setShowAutocomplete(true) - } - }, [setShowAutocomplete]) - - const showHeader = !gtMobile || navButton !== 'menu' - - return ( - <Layout.Screen testID={testID}> - <View - ref={headerRef} - onLayout={evt => { - if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) - }} - style={[ - a.relative, - a.z_10, - web({ - position: 'sticky', - top: 0, - }), - ]}> - <Layout.Center style={t.atoms.bg}> - {showHeader && ( - <View - // HACK: shift up search input. we can't remove the top padding - // on the search input because it messes up the layout animation - // if we add it only when the header is hidden - style={{marginBottom: tokens.space.xs * -1}}> - <Layout.Header.Outer noBottomBorder> - {navButton === 'menu' ? ( - <Layout.Header.MenuButton /> - ) : ( - <Layout.Header.BackButton /> - )} - <Layout.Header.Content align="left"> - <Layout.Header.TitleText> - <Trans>Search</Trans> - </Layout.Header.TitleText> - </Layout.Header.Content> - {showFilters ? ( - <SearchLanguageDropdown - value={params.lang} - onChange={params.setLang} - /> - ) : ( - <Layout.Header.Slot /> - )} - </Layout.Header.Outer> - </View> - )} - <View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}> - <View style={[a.gap_sm]}> - <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}> - <View style={[a.flex_1]}> - <SearchInput - ref={textInput} - value={searchText} - onFocus={onSearchInputFocus} - onChangeText={onChangeText} - onClearText={onPressClearQuery} - onSubmitEditing={onSubmit} - placeholder={ - inputPlaceholder ?? - _(msg`Search for posts, users, or feeds`) - } - hitSlop={{...HITSLOP_20, top: 0}} - /> - </View> - {showAutocomplete && ( - <Button - label={_(msg`Cancel search`)} - size="large" - variant="ghost" - color="secondary" - style={[a.px_sm]} - onPress={onPressCancelSearch} - hitSlop={HITSLOP_10}> - <ButtonText> - <Trans>Cancel</Trans> - </ButtonText> - </Button> - )} - </View> - - {showFilters && !showHeader && ( - <View - style={[ - a.flex_row, - a.align_center, - a.justify_between, - a.gap_sm, - ]}> - <SearchLanguageDropdown - value={params.lang} - onChange={params.setLang} - /> - </View> - )} - </View> - </View> - </Layout.Center> - </View> - - <View - style={{ - display: showAutocomplete && !fixedParams ? 'flex' : 'none', - flex: 1, - }}> - {searchText.length > 0 ? ( - <AutocompleteResults - isAutocompleteFetching={isAutocompleteFetching} - autocompleteData={autocompleteData} - searchText={searchText} - onSubmit={onSubmit} - onResultPress={onAutocompleteResultPress} - onProfileClick={handleProfileClick} - /> - ) : ( - <SearchHistory - searchHistory={termHistory} - selectedProfiles={accountHistoryProfiles?.profiles || []} - onItemClick={handleHistoryItemClick} - onProfileClick={handleProfileClick} - onRemoveItemClick={deleteSearchHistoryItem} - onRemoveProfileClick={deleteProfileHistoryItem} - /> - )} - </View> - <View - style={{ - display: showAutocomplete ? 'none' : 'flex', - flex: 1, - }}> - <SearchScreenInner - query={query} - queryWithParams={queryWithParams} - headerHeight={headerHeight} - /> - </View> - </Layout.Screen> - ) -} - -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 ? ( - <Loader /> - ) : ( - <Layout.Content - 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={() => { - onProfileClick(item) - onResultPress() - }} - /> - ))} - <View style={{height: 200}} /> - </Layout.Content> - )} - </> - ) -} -AutocompleteResults = React.memo(AutocompleteResults) - -function SearchHistory({ - searchHistory, - selectedProfiles, - onItemClick, - onProfileClick, - onRemoveItemClick, - onRemoveProfileClick, -}: { - searchHistory: string[] - selectedProfiles: AppBskyActorDefs.ProfileViewDetailed[] - onItemClick: (item: string) => void - onProfileClick: (profile: AppBskyActorDefs.ProfileViewDetailed) => void - onRemoveItemClick: (item: string) => void - onRemoveProfileClick: (profile: AppBskyActorDefs.ProfileViewDetailed) => 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, - ]}> - <RNGHScrollView - keyboardShouldPersistTaps="handled" - horizontal={true} - 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> - ))} - </RNGHScrollView> - </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> - ) -} - -function scrollToTopWeb() { - if (isWeb) { - window.scrollTo(0, 0) - } -} - -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/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx deleted file mode 100644 index f6c0eca26..000000000 --- a/src/view/screens/Search/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export {SearchScreen} from '#/view/screens/Search/Search' diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index a7b9a8391..db9492349 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -14,7 +14,7 @@ import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {Divider} from '#/components/Divider' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' import * as Prompt from '#/components/Prompt' import { TrendingTopic, |