diff --git a/assets/icons/flame_stroke2_corner1_rounded.svg b/assets/icons/flame_stroke2_corner1_rounded.svg
new file mode 100644
index 0000000000..78a2acc616
--- /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 0000000000..0d447d52db
--- /dev/null
+++ b/assets/icons/trending3_stroke2_corner1_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index db68a18c84..6f73a5581b 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 807fd92e5b..420a49d4c6 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 f20e517d43..665bbcba82 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 b56112dcf2..a374325008 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 006f86574b..41c3d41d89 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 b5760fd6df..88d075b783 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 0000000000..42569b0dee
--- /dev/null
+++ b/src/components/icons/Flame.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Flame_Stroke2_Corner1_Rounded = createSinglePathSVG({
+ path: 'M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z',
+})
diff --git a/src/components/icons/Trending2.tsx b/src/components/icons/Trending.tsx
similarity index 61%
rename from src/components/icons/Trending2.tsx
rename to src/components/icons/Trending.tsx
index 5fba4167b5..bdc8539e0e 100644
--- a/src/components/icons/Trending2.tsx
+++ b/src/components/icons/Trending.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z',
})
+
+export const Trending3_Stroke2_Corner1_Rounded = createSinglePathSVG({
+ path: 'M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
index 996ecb626c..bc1e045a48 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 7412c6f0ac..56c756c50b 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 126d6f4174..fab738b9cb 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 3690783d32..6e0be9d0a0 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 c88a97c751..d3334d82f6 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 33cdc25e58..6467583692 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 d0b0caccaa..e725b7b80a 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 d91dc973ee..6247e39792 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 0000000000..86877677a6
--- /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
)
}
@@ -191,7 +152,7 @@ function VideoCards({
}) {
const t = useTheme()
const {_} = useLingui()
- const items = React.useMemo(() => {
+ const items = useMemo(() => {
return data.pages
.flatMap(page => page.slices)
.map(slice => slice.items[0])
@@ -199,7 +160,7 @@ function VideoCards({
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
.slice(0, 8)
}, [data])
- const href = React.useMemo(() => {
+ const href = useMemo(() => {
const urip = new AtUri(VIDEO_FEED_URI)
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
}, [])
@@ -217,9 +178,11 @@ function VideoCards({
sourceInterstitial: 'explore',
}}
onInteract={() => {
- logEvent('videoCard:click', {
- context: 'interstitial:explore',
- })
+ logger.metric(
+ 'videoCard:click',
+ {context: 'interstitial:explore'},
+ {statsig: true},
+ )
}}
/>
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index e28c98803c..57b86fb2bf 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 6d6c46e040..0b5de23037 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 0000000000..16522f5c9e
--- /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 0000000000..d96bf06035
--- /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 0000000000..18fe6439ea
--- /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 a99077c0c6..3a6b8f6601 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 8f3ebd0e71..f05f285401 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 520e103a4f..0000000000
--- 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 785e1872ea..0000000000
--- 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 && (
-
-
- {navButton === 'menu' ? (
-
- ) : (
-
- )}
-
-
- Search
-
-
- {showFilters ? (
-
- ) : (
-
- )}
-
-
- )}
-
-
-
-
-
-
- {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 f6c0eca262..0000000000
--- 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 a7b9a8391e..db94923495 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 7572c2fd26..0541080f64 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,6 +301,17 @@
multiformats "^9.9.0"
zod "^3.23.8"
+"@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.1"
+ "@atproto/syntax" "^0.4.0"
+ iso-datestring-validator "^2.2.2"
+ 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"
@@ -302,17 +323,6 @@
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==
- dependencies:
- "@atproto/common-web" "^0.4.0"
- "@atproto/syntax" "^0.4.0"
- iso-datestring-validator "^2.2.2"
- multiformats "^9.9.0"
- zod "^3.23.8"
-
"@atproto/oauth-provider@^0.2.17":
version "0.2.17"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.17.tgz#4644d391eedbbbbe5825ecc0e8cc03f1c6433b95"
@@ -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"