From 87da619aaa92e0ec762e68c13b24e58a25da10a8 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 3 Apr 2025 03:21:15 +0300 Subject: [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 * 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 * 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 Co-authored-by: Hailey --- assets/icons/flame_stroke2_corner1_rounded.svg | 1 + assets/icons/trending3_stroke2_corner1_rounded.svg | 1 + package.json | 2 +- src/Navigation.tsx | 25 +- src/components/FeedCard.tsx | 68 +- src/components/ProfileCard.tsx | 35 +- src/components/ProgressGuide/FollowDialog.tsx | 15 +- src/components/StarterPack/StarterPackCard.tsx | 31 +- src/components/icons/Flame.tsx | 5 + src/components/icons/Trending.tsx | 9 + src/components/icons/Trending2.tsx | 5 - src/components/icons/common.tsx | 6 +- src/components/interstitials/Trending.tsx | 2 +- src/components/interstitials/TrendingVideos.tsx | 2 +- src/lib/icons.tsx | 2 +- src/lib/statsig/gates.ts | 1 + src/logger/metrics.ts | 20 + src/screens/Onboarding/StepFinished.tsx | 10 +- src/screens/Profile/ProfileSearch.tsx | 7 +- src/screens/Search/Explore.tsx | 923 ++++++++++++++++ src/screens/Search/SearchResults.tsx | 338 ++++++ src/screens/Search/Shell.tsx | 535 +++++++++ .../Search/components/AutocompleteResults.tsx | 71 ++ .../Search/components/ExploreRecommendations.tsx | 117 -- .../Search/components/ExploreTrendingTopics.tsx | 142 --- .../Search/components/ExploreTrendingVideos.tsx | 271 ----- src/screens/Search/components/ModuleHeader.tsx | 170 +++ src/screens/Search/components/SearchHistory.tsx | 169 +++ .../Search/components/SearchLanguageDropdown.tsx | 120 ++ src/screens/Search/components/StarterPackCard.tsx | 296 +++++ src/screens/Search/index.tsx | 13 + src/screens/Search/modules/ExploreFeedPreviews.tsx | 264 +++++ .../Search/modules/ExploreRecommendations.tsx | 123 +++ .../Search/modules/ExploreSuggestedAccounts.tsx | 228 ++++ .../Search/modules/ExploreTrendingTopics.tsx | 278 +++++ .../Search/modules/ExploreTrendingVideos.tsx | 234 ++++ src/screens/Settings/ContentAndMediaSettings.tsx | 6 +- src/state/queries/actor-search.ts | 23 +- .../queries/trending/useGetSuggestedFeedsQuery.ts | 48 + src/state/queries/trending/useGetTrendsQuery.ts | 59 + src/state/queries/useSuggestedStarterPacksQuery.ts | 38 + src/view/com/posts/PostFeed.tsx | 26 +- src/view/com/util/numeric/format.ts | 2 +- src/view/screens/Search/Explore.tsx | 641 ----------- src/view/screens/Search/Search.tsx | 1165 -------------------- src/view/screens/Search/index.tsx | 1 - src/view/shell/desktop/SidebarTrendingTopics.tsx | 2 +- yarn.lock | 102 +- 48 files changed, 4154 insertions(+), 2498 deletions(-) create mode 100644 assets/icons/flame_stroke2_corner1_rounded.svg create mode 100644 assets/icons/trending3_stroke2_corner1_rounded.svg create mode 100644 src/components/icons/Flame.tsx create mode 100644 src/components/icons/Trending.tsx delete mode 100644 src/components/icons/Trending2.tsx create mode 100644 src/screens/Search/Explore.tsx create mode 100644 src/screens/Search/SearchResults.tsx create mode 100644 src/screens/Search/Shell.tsx create mode 100644 src/screens/Search/components/AutocompleteResults.tsx delete mode 100644 src/screens/Search/components/ExploreRecommendations.tsx delete mode 100644 src/screens/Search/components/ExploreTrendingTopics.tsx delete mode 100644 src/screens/Search/components/ExploreTrendingVideos.tsx create mode 100644 src/screens/Search/components/ModuleHeader.tsx create mode 100644 src/screens/Search/components/SearchHistory.tsx create mode 100644 src/screens/Search/components/SearchLanguageDropdown.tsx create mode 100644 src/screens/Search/components/StarterPackCard.tsx create mode 100644 src/screens/Search/index.tsx create mode 100644 src/screens/Search/modules/ExploreFeedPreviews.tsx create mode 100644 src/screens/Search/modules/ExploreRecommendations.tsx create mode 100644 src/screens/Search/modules/ExploreSuggestedAccounts.tsx create mode 100644 src/screens/Search/modules/ExploreTrendingTopics.tsx create mode 100644 src/screens/Search/modules/ExploreTrendingVideos.tsx create mode 100644 src/state/queries/trending/useGetSuggestedFeedsQuery.ts create mode 100644 src/state/queries/trending/useGetTrendsQuery.ts create mode 100644 src/state/queries/useSuggestedStarterPacksQuery.ts delete mode 100644 src/view/screens/Search/Explore.tsx delete mode 100644 src/view/screens/Search/Search.tsx delete mode 100644 src/view/screens/Search/index.tsx diff --git a/assets/icons/flame_stroke2_corner1_rounded.svg b/assets/icons/flame_stroke2_corner1_rounded.svg new file mode 100644 index 000000000..78a2acc61 --- /dev/null +++ b/assets/icons/flame_stroke2_corner1_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/trending3_stroke2_corner1_rounded.svg b/assets/icons/trending3_stroke2_corner1_rounded.svg new file mode 100644 index 000000000..0d447d52d --- /dev/null +++ b/assets/icons/trending3_stroke2_corner1_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index db68a18c8..6f73a5581 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.14.16", + "@atproto/api": "^0.14.19", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 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 {children} + return {children} } export function Header({children}: {children: React.ReactNode}) { - return {children} + return {children} } 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) { const {hasSession} = useSession() if (!hasSession) return null - return + return } function SaveButtonInner({ view, pin, + text = true, + ...buttonProps }: { view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView pin?: boolean -}) { + text?: boolean +} & Partial) { 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 ? ( - + <> + {isPending ? ( + + ) : ( + !text && + )} + {text && ( + + Unpin Feed + + )} + ) : ( - + <> + + {text && ( + + Pin Feed + + )} + )} 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 ( @@ -261,7 +266,7 @@ export function DescriptionPlaceholder({ }) { const t = useTheme() return ( - + {Array(numberOfLines) .fill(0) .map((_, i) => ( @@ -286,6 +291,7 @@ export type FollowButtonProps = { LogEvents['profile:unfollow']['logContext'] colorInverted?: boolean onFollow?: () => void + withIcon?: boolean } & Partial 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}> - + {withIcon && ( + + )} {isRound ? null : {unfollowLabel}} ) : ( @@ -397,7 +406,9 @@ export function FollowButtonInner({ color={colorInverted ? 'secondary_inverted' : 'primary'} {...rest} onPress={onPressFollow}> - + {withIcon && ( + + )} {isRound ? null : {followLabel}} )} 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 + TabComponent?: React.ComponentType> }): React.ReactNode => { const listRef = useRef(null) const [scrollX, setScrollX] = useState(0) @@ -532,7 +534,7 @@ let Tabs = ({ {interests.map((interest, i) => { const active = interest === selectedInterest && !hasSearchText return ( - { 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/Trending.tsx b/src/components/icons/Trending.tsx new file mode 100644 index 000000000..bdc8539e0 --- /dev/null +++ b/src/components/icons/Trending.tsx @@ -0,0 +1,9 @@ +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/Trending2.tsx b/src/components/icons/Trending2.tsx deleted file mode 100644 index 5fba4167b..000000000 --- a/src/components/icons/Trending2.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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', -}) 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 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 ( + + ) +} + +type ExploreScreenItems = + | { + type: 'topBorder' + key: string + } + | { + type: 'header' + key: string + title: string + icon: React.ComponentType + searchButton?: { + label: string + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] + tab: 'user' | 'profile' | 'feed' + } + } + | { + type: 'tabbedHeader' + key: string + title: string + icon: React.ComponentType + 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(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(() => { + 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 ( + + ) + case 'header': { + return ( + + + {item.title} + {item.searchButton && ( + + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + + ) + } + case 'tabbedHeader': { + return ( + + + + {item.title} + {item.searchButton && ( + + focusSearchInput(item.searchButton?.tab || 'user') + } + /> + )} + + + + ) + } + case 'trendingTopics': { + return ( + + + + ) + } + case 'trendingVideos': { + return + } + case 'recommendations': { + return + } + case 'profile': { + return ( + + ) + } + case 'feed': { + return ( + + + + ) + } + case 'starterPack': { + return ( + + + + ) + } + case 'starterPackSkeleton': { + return ( + + + + ) + } + case 'loadMore': { + return ( + + + + ) + } + case 'profilePlaceholder': { + return ( + <> + {Array.from({length: 3}).map((_, index) => ( + + + + + + + + + + ))} + + ) + } + case 'feedPlaceholder': { + return + } + case 'error': + case 'preview:error': { + return ( + + + + + + {item.message} + + + {item.error} + + + + + ) + } + // feed previews + case 'preview:empty': { + return null // what should we do here? + } + case 'preview:loading': { + return ( + + + + ) + } + case 'preview:header': { + return ( + + + + + + {item.feed.displayName} + + + + By {sanitizeHandle(item.feed.creator.handle, '@')} + + + + + + + ) + } + case 'preview:footer': { + return + } + case 'preview:sliceItem': { + const slice = item.slice + const indexInSlice = item.indexInSlice + const subItem = slice.items[indexInSlice] + return ( + + ) + } + case 'preview:sliceViewFullThread': { + return + } + case 'preview:loadMoreError': { + return ( + + ) + } + } + }, + [ + 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>(new Map()) + const onViewableItemsChanged = useCallback( + ({ + viewableItems, + }: { + viewableItems: ViewToken[] + changed: ViewToken[] + }) => { + 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 ( + 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: ( + + ), + }, + { + title: _(msg`Latest`), + component: ( + + ), + }, + noParams && { + title: _(msg`People`), + component: ( + + ), + }, + noParams && { + title: _(msg`Feeds`), + component: ( + + ), + }, + ].filter(Boolean) as { + title: string + component: React.ReactNode + }[] + }, [_, query, queryWithParams, activeTab]) + + return ( + ( + + section.title)} {...props} /> + + )} + initialPage={0}> + {sections.map((section, i) => ( + {section.component} + ))} + + ) +} +SearchResults = memo(SearchResults) +export {SearchResults} + +function Loader() { + return ( + + + + + + ) +} + +function EmptyState({message, error}: {message: string; error?: string}) { + const t = useTheme() + + return ( + + + + {message} + + {error && ( + <> + + + + Error: {error} + + + )} + + + + ) +} + +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 ? ( + + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + { + if (item.type === 'post') { + return + } else { + return null + } + }} + keyExtractor={item => item.key} + refreshing={isPTR} + onRefresh={onPullToRefresh} + onEndReached={onEndReached} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + )} + + ) +} +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 ? ( + ( + + )} + keyExtractor={item => item.did} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + ) +} +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 ? ( + ( + + + + )} + keyExtractor={item => item.uri} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + ) +} +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() + const route = useRoute() + const textInput = useRef(null) + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + // Query terms + const [searchText, setSearchText] = useState(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 ( + + { + if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) + }} + style={[ + a.relative, + a.z_10, + web({ + position: 'sticky', + top: 0, + }), + ]}> + + {showHeader && ( + + )} + + + + + + + {showAutocomplete && ( + + )} + + + {showFilters && !showHeader && ( + + + + )} + + + + + + + {searchText.length > 0 ? ( + + ) : ( + + )} + + + + + + ) +} + +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 ? ( + + ) : hasSession ? ( + + ) : ( + + + {gtTablet && ( + + + Search + + + )} + + + } + /> + + Find posts, users, and feeds on Bluesky + + + + + ) +} +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 ? ( + + + + + + ) : ( + + + {autocompleteData?.map(item => ( + { + onProfileClick(item) + onResultPress() + }} + /> + ))} + + + )} + + ) +} +AutocompleteResults = memo(AutocompleteResults) +export {AutocompleteResults} diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx deleted file mode 100644 index 602bab87d..000000000 --- a/src/screens/Search/components/ExploreRecommendations.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import {View} from 'react-native' -import {AppBskyUnspeccedDefs} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import { - DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, - useTrendingTopics, -} from '#/state/queries/trending/useTrendingTopics' -import {useTrendingConfig} from '#/state/trending-config' -import {atoms as a, useGutters, useTheme} from '#/alf' -import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' -import { - TrendingTopic, - TrendingTopicLink, - TrendingTopicSkeleton, -} from '#/components/TrendingTopics' -import {Text} from '#/components/Typography' - -export function ExploreRecommendations() { - const {enabled} = useTrendingConfig() - return enabled ? : null -} - -function Inner() { - const t = useTheme() - const gutters = useGutters([0, 'compact']) - const {data: trending, error, isLoading} = useTrendingTopics() - const noRecs = !isLoading && !error && !trending?.suggested?.length - const allFeeds = trending?.suggested && isAllFeeds(trending.suggested) - - return error || noRecs ? null : ( - <> - - - - - - Recommended - - - {!allFeeds ? ( - - - Content from across the network we think you might like. - - - ) : ( - - Feeds we think you might like. - - )} - - - - - - {isLoading ? ( - Array(RECOMMENDATIONS_COUNT) - .fill(0) - .map((_, i) => ) - ) : !trending?.suggested ? null : ( - <> - {trending.suggested.map(topic => ( - { - logEvent('recommendedTopic:click', {context: 'explore'}) - }}> - {({hovered}) => ( - - )} - - ))} - - )} - - - - ) -} - -function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) { - return topics.every(topic => { - const segments = topic.link.split('/').slice(1) - return segments[0] === 'profile' && segments[2] === 'feed' - }) -} diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx deleted file mode 100644 index a010ad8dc..000000000 --- a/src/screens/Search/components/ExploreTrendingTopics.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import { - useTrendingSettings, - useTrendingSettingsApi, -} from '#/state/preferences/trending' -import { - DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, - useTrendingTopics, -} from '#/state/queries/trending/useTrendingTopics' -import {useTrendingConfig} from '#/state/trending-config' -import {atoms as a, tokens, useGutters, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' -import {GradientFill} from '#/components/GradientFill' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' -import * as Prompt from '#/components/Prompt' -import { - TrendingTopic, - TrendingTopicLink, - TrendingTopicSkeleton, -} from '#/components/TrendingTopics' -import {Text} from '#/components/Typography' - -export function ExploreTrendingTopics() { - const {enabled} = useTrendingConfig() - const {trendingDisabled} = useTrendingSettings() - return enabled && !trendingDisabled ? : 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 : ( - <> - - - - - - Trending - - - - - BETA - - - - - What people are posting about. - - - - - - - - {isLoading ? ( - Array(TRENDING_TOPICS_COUNT) - .fill(0) - .map((_, i) => ) - ) : !trending?.topics ? null : ( - <> - {trending.topics.map(topic => ( - { - logEvent('trendingTopic:click', {context: 'explore'}) - }}> - {({hovered}) => ( - - )} - - ))} - - )} - - - - - - ) -} diff --git a/src/screens/Search/components/ExploreTrendingVideos.tsx b/src/screens/Search/components/ExploreTrendingVideos.tsx deleted file mode 100644 index 00fa76dbf..000000000 --- a/src/screens/Search/components/ExploreTrendingVideos.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import React from 'react' -import {ScrollView, View} from 'react-native' -import {AppBskyEmbedVideo, AtUri} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {VIDEO_FEED_URI} from '#/lib/constants' -import {makeCustomFeedLink} from '#/lib/routes/links' -import {logEvent} from '#/lib/statsig/statsig' -import {isWeb} from '#/platform/detection' -import {useSavedFeeds} from '#/state/queries/feed' -import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' -import {useAddSavedFeedsMutation} from '#/state/queries/preferences' -import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' -import {atoms as a, tokens, useGutters, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {GradientFill} from '#/components/GradientFill' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' -import {Link} from '#/components/Link' -import {Text} from '#/components/Typography' -import { - CompactVideoPostCard, - CompactVideoPostCardPlaceholder, -} from '#/components/VideoPostCard' - -const CARD_WIDTH = 100 - -const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` -const FEED_PARAMS: { - feedCacheKey: 'explore' -} = { - feedCacheKey: 'explore', -} - -export function ExploreTrendingVideos() { - const t = useTheme() - const {_} = useLingui() - const gutters = useGutters([0, 'base']) - const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) - - // Refetch on tab change if nothing else is using this query. - const queryClient = useQueryClient() - useFocusEffect(() => { - return () => { - const query = queryClient - .getQueryCache() - .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) - if (query && query.getObserversCount() <= 1) { - query.fetch() - } - } - }) - - const {data: saved} = useSavedFeeds() - const isSavedAlready = React.useMemo(() => { - return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) - }, [saved]) - - const {mutateAsync: addSavedFeeds, isPending: isPinPending} = - useAddSavedFeedsMutation() - const pinFeed = React.useCallback( - (e: any) => { - e.preventDefault() - - addSavedFeeds([ - { - type: 'feed', - value: VIDEO_FEED_URI, - pinned: true, - }, - ]) - - // prevent navigation - return false - }, - [addSavedFeeds], - ) - - if (error) { - return null - } - - return ( - - - - - - - Trending Videos - - - - - BETA - - - - - Popular videos in your network. - - - - - - - - {isLoading ? ( - Array(10) - .fill(0) - .map((_, i) => ( - - - - )) - ) : error || !data ? ( - - Whoops! Trending videos failed to load. - - ) : ( - - )} - - - - - {!isSavedAlready && ( - - - - Pin the trending videos feed to your home screen for easy access - - - - - )} - - ) -} - -function VideoCards({ - data, -}: { - data: Exclude['data'], undefined> -}) { - const t = useTheme() - const {_} = useLingui() - const items = React.useMemo(() => { - return data.pages - .flatMap(page => page.slices) - .map(slice => slice.items[0]) - .filter(Boolean) - .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) - .slice(0, 8) - }, [data]) - const href = React.useMemo(() => { - const urip = new AtUri(VIDEO_FEED_URI) - return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') - }, []) - - return ( - <> - {items.map(item => ( - - { - logEvent('videoCard:click', { - context: 'interstitial:explore', - }) - }} - /> - - ))} - - - - {({pressed}) => ( - - - View more - - - - - - )} - - - - ) -} 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 ( + + {children} + + ) +} + +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 ( + + {({focused, hovered, pressed}) => ( + + {children} + + )} + + ) +} + +export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return +} + +export function Icon({ + icon: Comp, + size = 'lg', +}: Pick, 'icon' | 'size'>) { + const iconSize = iconSizes[size] + + return ( + + + + ) +} + +export function TitleText({style, ...props}: TextProps) { + return ( + + ) +} + +export function SubtitleText({style, ...props}: TextProps) { + const t = useTheme() + return ( + + ) +} + +export function SearchButton({ + label, + metricsTag, + onPress, +}: { + label: string + metricsTag: 'suggestedAccounts' | 'suggestedFeeds' + onPress?: () => void +}) { + return ( + + ) +} + +export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { + return ( + + + + ) +} 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 ( + + + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( + + Recent Searches + + )} + {selectedProfiles.length > 0 && ( + + + + {selectedProfiles.slice(0, 5).map((profile, index) => ( + + onProfileClick(profile)} + style={[a.align_center, a.w_full]}> + + + {sanitizeDisplayName( + profile.displayName || profile.handle, + )} + + + onRemoveProfileClick(profile)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn}> + + + + ))} + + + + )} + {searchHistory.length > 0 && ( + + {searchHistory.slice(0, 5).map((historyItem, index) => ( + + onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_md]}> + {historyItem} + + + + ))} + + )} + + + ) +} + +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 ( + + + {({props}) => ( + + )} + + + + Filter search by language + + onChange('')}> + + All languages + + + + + + {languages.map(lang => ( + onChange(lang.value)}> + {lang.label} + + + ))} + + + + ) +} 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( + view.record, + AppBskyGraphStarterpack.isRecord, + ) + ) { + return null + } + + const profileCount = gtPhone ? 11 : 8 + const profiles = view.listItemsSample + ?.slice(0, profileCount) + .map(item => item.subject) + + return ( + + + + + + + + + + + + + {view.record.name} + + + {view.creator?.did === currentAccount?.did + ? _(msg`By you`) + : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)} + + + + + Open pack + + + + + ) +} + +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(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 ( + + {items.map((item, i) => ( + + + setSize(e.nativeEvent.layout.width)} + style={[ + a.rounded_full, + t.atoms.bg_contrast_25, + { + paddingTop: '100%', + }, + ]}> + {size && item.profile ? ( + + ) : ( + + )} + + + + ))} + + + + + {computedTotal > 0 ? ( + + + +{computedTotal} + + + ) : ( + + )} + + + + + + ) +} + +export function StarterPackCardSkeleton() { + const t = useTheme() + const {gtPhone} = useBreakpoints() + + const profileCount = gtPhone ? 11 : 8 + + return ( + + + + + + + + + + + + + ) +} 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, +) { + const queryParam = props.route?.params?.q ?? '' + + return +} 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(() => { + 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/modules/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx new file mode 100644 index 000000000..4cf84269a --- /dev/null +++ b/src/screens/Search/modules/ExploreRecommendations.tsx @@ -0,0 +1,123 @@ +import {View} from 'react-native' +import {type AppBskyUnspeccedDefs} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import { + DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, useGutters, useTheme} from '#/alf' +import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +// Note: This module is not currently used and may be removed in the future. + +export function ExploreRecommendations() { + const {enabled} = useTrendingConfig() + return enabled ? : null +} + +function Inner() { + const t = useTheme() + const gutters = useGutters([0, 'compact']) + const {data: trending, error, isLoading} = useTrendingTopics() + const noRecs = !isLoading && !error && !trending?.suggested?.length + const allFeeds = trending?.suggested && isAllFeeds(trending.suggested) + + return error || noRecs ? null : ( + <> + + + + + + Recommended + + + {!allFeeds ? ( + + + Content from across the network we think you might like. + + + ) : ( + + Feeds we think you might like. + + )} + + + + + + {isLoading ? ( + Array(RECOMMENDATIONS_COUNT) + .fill(0) + .map((_, i) => ) + ) : !trending?.suggested ? null : ( + <> + {trending.suggested.map(topic => ( + { + logger.metric( + 'recommendedTopic:click', + {context: 'explore'}, + {statsig: true}, + ) + }}> + {({hovered}) => ( + + )} + + ))} + + )} + + + + ) +} + +function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) { + return topics.every(topic => { + const segments = topic.link.split('/').slice(1) + return segments[0] === 'profile' && segments[2] === 'feed' + }) +} diff --git a/src/screens/Search/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 + isLoading: boolean + isFetchingNextPage: boolean + hasNextPage: boolean + fetchNextPage: () => Promise +}) { + 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 ( + + { + logger.metric( + 'explore:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + hasSearchText={false} + interestsDisplayNames={{ + all: _(msg`All`), + ...interestsDisplayNames, + }} + TabComponent={Tab} + /> + + ) +} + +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 ( + + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + + + ) +} +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 ( + { + logger.metric( + 'suggestedUser:press', + { + logContext: 'Explore', + recId, + position, + }, + {statsig: true}, + ) + }}> + + + + + + { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Explore', + location: 'Card', + recId, + position, + }, + {statsig: true}, + ) + }} + /> + + + + + + ) +} +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 ? : 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) => ( + + )) + ) : error || !trending?.trends || noTopics ? null : ( + <> + {trending.trends.map((trend, index) => ( + { + 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 ( + + {({hovered, pressed}) => ( + <> + + + + + + {rank}. + + + + {trend.displayName} + + + + {trend.actors.length > 0 && ( + + )} + + {postCount} + {postCount && category && <> · } + {category} + + + + + + + + + {children} + + )} + + ) +} + +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 | null = null + let text: string | null = null + let color: string | null = null + let backgroundColor: string | null = null + + switch (type) { + case 'skeleton': { + return ( + + ) + } + 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 ( + + {Icon && } + {text} + + ) +} + +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 ( + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/screens/Search/modules/ExploreTrendingVideos.tsx b/src/screens/Search/modules/ExploreTrendingVideos.tsx new file mode 100644 index 000000000..54eb73312 --- /dev/null +++ b/src/screens/Search/modules/ExploreTrendingVideos.tsx @@ -0,0 +1,234 @@ +import {useMemo} from 'react' +import {ScrollView, View} from 'react-native' +import {AppBskyEmbedVideo, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {VIDEO_FEED_URI} from '#/lib/constants' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {logger} from '#/logger' +import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, tokens, useGutters, useTheme} from '#/alf' +import {ButtonIcon} from '#/components/Button' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' +import { + CompactVideoPostCard, + CompactVideoPostCardPlaceholder, +} from '#/components/VideoPostCard' + +const CARD_WIDTH = 100 + +const FEED_DESC = `feedgen|${VIDEO_FEED_URI}` +const FEED_PARAMS: { + feedCacheKey: 'explore' +} = { + feedCacheKey: 'explore', +} + +export function ExploreTrendingVideos() { + const {_} = useLingui() + const gutters = useGutters([0, 'base']) + const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) + + // Refetch on tab change if nothing else is using this query. + const queryClient = useQueryClient() + useFocusEffect(() => { + return () => { + const query = queryClient + .getQueryCache() + .find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)}) + if (query && query.getObserversCount() <= 1) { + query.fetch() + } + } + }) + + // const {data: saved} = useSavedFeeds() + // const isSavedAlready = 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 + } + + return ( + + + + + {isLoading ? ( + Array(10) + .fill(0) + .map((_, i) => ( + + + + )) + ) : error || !data ? ( + + Whoops! Trending videos failed to load. + + ) : ( + + )} + + + + + {/* {!isSavedAlready && ( + + + + Pin the trending videos feed to your home screen for easy access + + + + + )} */} + + ) +} + +function VideoCards({ + data, +}: { + data: Exclude['data'], undefined> +}) { + const t = useTheme() + const {_} = useLingui() + const items = useMemo(() => { + return data.pages + .flatMap(page => page.slices) + .map(slice => slice.items[0]) + .filter(Boolean) + .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) + .slice(0, 8) + }, [data]) + const href = useMemo(() => { + const urip = new AtUri(VIDEO_FEED_URI) + return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') + }, []) + + return ( + <> + {items.map(item => ( + + { + logger.metric( + 'videoCard:click', + {context: 'interstitial:explore'}, + {statsig: true}, + ) + }} + /> + + ))} + + + + {({pressed}) => ( + + + View more + + + + + + )} + + + + ) +} 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(arr: Array, i: number) { +export function isThreadParentAt(arr: Array, i: number) { if (arr.length === 1) { return false } return i < arr.length - 1 } -function isThreadChildAt(arr: Array, i: number) { +export function isThreadChildAt(arr: Array, 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 -} & ViewStyleProp) { - const t = useTheme() - - return ( - - - - - {title} - - - {description} - - - - ) -} - -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 ( - - - - ) -} - -type ExploreScreenItems = - | { - type: 'header' - key: string - title: string - description: string - style?: ViewStyleProp['style'] - icon: React.ComponentType - } - | { - 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(() => { - 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 ( - - ) - } - case 'trendingTopics': { - return - } - case 'trendingVideos': { - return - } - case 'recommendations': { - return - } - case 'profile': { - return ( - - { - logEvent('suggestedUser:press', { - logContext: 'Explore', - recId: item.recId, - position: index, - }) - }} - onFollow={() => { - logEvent('suggestedUser:follow', { - logContext: 'Explore', - location: 'Card', - recId: item.recId, - position: index, - }) - }} - /> - - ) - } - case 'feed': { - return ( - - - - ) - } - case 'loadMore': { - return - } - case 'profilePlaceholder': { - return - } - case 'feedPlaceholder': { - return - } - case 'error': { - return ( - - - - - - {item.message} - - - {item.error} - - - - - ) - } - } - }, - [t, moderationOpts], - ) - - // note: actually not a screen, instead it's nested within - // the search screen. so we don't need Layout.Screen - return ( - 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 ( - - - - - - ) -} - -function EmptyState({message, error}: {message: string; error?: string}) { - const t = useTheme() - - return ( - - - - {message} - - {error && ( - <> - - - - Error: {error} - - - )} - - - - ) -} - -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 ? ( - - ) : ( - <> - {isFetched ? ( - <> - {posts.length ? ( - { - if (item.type === 'post') { - return - } else { - return null - } - }} - keyExtractor={item => item.key} - refreshing={isPTR} - onRefresh={onPullToRefresh} - onEndReached={onEndReached} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - - )} - - ) : ( - - )} - - ) -} -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 ? ( - ( - - )} - keyExtractor={item => item.did} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - - )} - - ) : ( - - ) -} -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 ? ( - ( - - - - )} - keyExtractor={item => item.uri} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - - )} - - ) : ( - - ) -} -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 ( - - - {({props}) => ( - - )} - - - - Filter search by language - - onChange('')}> - - All languages - - - - - - {languages.map(lang => ( - onChange(lang.value)}> - {lang.label} - - - ))} - - - - ) -} - -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: ( - - ), - }, - { - title: _(msg`Latest`), - component: ( - - ), - }, - noParams && { - title: _(msg`People`), - component: ( - - ), - }, - noParams && { - title: _(msg`Feeds`), - component: ( - - ), - }, - ].filter(Boolean) as { - title: string - component: React.ReactNode - }[] - }, [_, query, queryWithParams, activeTab]) - - return queryWithParams ? ( - ( - - section.title)} {...props} /> - - )} - initialPage={0}> - {sections.map((section, i) => ( - {section.component} - ))} - - ) : hasSession ? ( - - ) : ( - - - {gtTablet && ( - - - Search - - - )} - - - } - /> - - Find posts, users, and feeds on Bluesky - - - - - ) -} -SearchScreenInner = React.memo(SearchScreenInner) - -export function SearchScreen( - props: NativeStackScreenProps, -) { - const queryParam = props.route?.params?.q ?? '' - - return -} - -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() - const route = useRoute() - const textInput = React.useRef(null) - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {currentAccount} = useSession() - const queryClient = useQueryClient() - - // Query terms - const [searchText, setSearchText] = React.useState(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 ( - - { - if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) - }} - style={[ - a.relative, - a.z_10, - web({ - position: 'sticky', - top: 0, - }), - ]}> - - {showHeader && ( - - )} - - - - - - - {showAutocomplete && ( - - )} - - - {showFilters && !showHeader && ( - - - - )} - - - - - - - {searchText.length > 0 ? ( - - ) : ( - - )} - - - - - - ) -} - -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 ? ( - - ) : ( - - - {autocompleteData?.map(item => ( - { - onProfileClick(item) - onResultPress() - }} - /> - ))} - - - )} - - ) -} -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 ( - - - {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( - - Recent Searches - - )} - {selectedProfiles.length > 0 && ( - - - {selectedProfiles.slice(0, 5).map((profile, index) => ( - - onProfileClick(profile)} - style={[a.align_center, a.w_full]}> - - - {sanitizeDisplayName( - profile.displayName || profile.handle, - )} - - - onRemoveProfileClick(profile)} - hitSlop={createHitslop(6)} - style={styles.profileRemoveBtn}> - - - - ))} - - - )} - {searchHistory.length > 0 && ( - - {searchHistory.slice(0, 5).map((historyItem, index) => ( - - onItemClick(historyItem)} - hitSlop={HITSLOP_10} - style={[a.flex_1, a.py_md]}> - {historyItem} - - - - ))} - - )} - - - ) -} - -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, diff --git a/yarn.lock b/yarn.lock index 7572c2fd2..0541080f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -80,15 +80,15 @@ tlds "^1.234.0" zod "^3.23.8" -"@atproto/api@^0.14.16": - version "0.14.16" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.16.tgz#7b59eb83a27e906e0dc442d3de0f0d3869092b4a" - integrity sha512-xzUK3KVdp1TDJJ09Di2rvS/fisVctvMHO7Er0XhYviL3V4lxGQPNT3pHwbTbbb22QP7xH/d5ghCgfdIoS5Z8/A== +"@atproto/api@^0.14.19": + version "0.14.19" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.19.tgz#fef8994e2b14e69a9e3a0aef043c7fcb34d6bf8c" + integrity sha512-YYTqM0K0qk2TP7PguktPzlAQGLTL1bEGz6PgY5kqKJNX4o1318kJYB22DzjJYqV2NUCq0JQ9Lb0oskLvTisEOg== dependencies: - "@atproto/common-web" "^0.4.0" - "@atproto/lexicon" "^0.4.9" + "@atproto/common-web" "^0.4.1" + "@atproto/lexicon" "^0.4.10" "@atproto/syntax" "^0.4.0" - "@atproto/xrpc" "^0.6.11" + "@atproto/xrpc" "^0.6.12" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -182,6 +182,16 @@ uint8arrays "3.0.0" zod "^3.23.8" +"@atproto/common-web@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.1.tgz#f31054f689f4f52b06da6ffd727e40ecd67a30b6" + integrity sha512-Ghh+djHYMAUCktLKwr2IuGgtjcwSWGudp+K7+N7KBA9pDDloOXUEY8Agjc5SHSo9B1QIEFkegClU5n+apn2e0w== + dependencies: + graphemer "^1.4.0" + multiformats "^9.9.0" + uint8arrays "3.0.0" + zod "^3.23.8" + "@atproto/common@0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" @@ -291,24 +301,24 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/lexicon@^0.4.7": - version "0.4.7" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86" - integrity sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g== +"@atproto/lexicon@^0.4.10": + version "0.4.10" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.10.tgz#276790a1bca060a55c80d556ce763eaa81f6e944" + integrity sha512-uDbP20vetBgtXPuxoyRcvOGBt2gNe1dFc9yYKcb6jWmXfseHiGTnIlORJOLBXIT2Pz15Eap4fLxAu6zFAykD5A== dependencies: - "@atproto/common-web" "^0.4.0" - "@atproto/syntax" "^0.3.3" + "@atproto/common-web" "^0.4.1" + "@atproto/syntax" "^0.4.0" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" zod "^3.23.8" -"@atproto/lexicon@^0.4.9": - version "0.4.9" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.9.tgz#612951a85ecc1398366bd837cda6be89440f179d" - integrity sha512-/tmEuHQFr51V2V7EAVJzaA40sqJ7ylAZpR962VbOsPtmcdOHvezbjVHYEMXgfb927hS+xqbVyzBTbu5w9v8prA== +"@atproto/lexicon@^0.4.7": + version "0.4.7" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86" + integrity sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g== dependencies: "@atproto/common-web" "^0.4.0" - "@atproto/syntax" "^0.4.0" + "@atproto/syntax" "^0.3.3" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" zod "^3.23.8" @@ -480,12 +490,12 @@ ws "^8.12.0" zod "^3.23.8" -"@atproto/xrpc@^0.6.11": - version "0.6.11" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.11.tgz#54c527e39a2f5ddd2655b11f7cb99b8f303d8364" - integrity sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA== +"@atproto/xrpc@^0.6.12": + version "0.6.12" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.12.tgz#a21ee5b87fde63994c98c34098d5e092252e25d0" + integrity sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w== dependencies: - "@atproto/lexicon" "^0.4.9" + "@atproto/lexicon" "^0.4.10" zod "^3.23.8" "@atproto/xrpc@^0.6.9": @@ -3370,7 +3380,7 @@ "@babel/parser" "^7.26.9" "@babel/types" "^7.26.9" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== @@ -3431,19 +3441,6 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" - integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== - dependencies: - "@babel/code-frame" "^7.25.9" - "@babel/generator" "^7.25.9" - "@babel/parser" "^7.25.9" - "@babel/template" "^7.25.9" - "@babel/types" "^7.25.9" - debug "^4.3.1" - globals "^11.1.0" - "@babel/traverse@^7.26.10": version "7.26.10" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.10.tgz#43cca33d76005dbaa93024fae536cc1946a4c380" @@ -18460,16 +18457,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18601,7 +18589,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -18615,13 +18603,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -20054,7 +20035,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20072,15 +20053,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" -- cgit 1.4.1