[Explore] Base (#8053)
* migrate to #/screens * rm unneeded import * block drawer gesture on recent profiles * rm recommendations (#8056) * [Explore] Disable Trending videos (#8054) * remove giant header * disable * [Explore] Dynamic module ordering (#8066) * Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * restore statsig to log events * filter out followed profiles, make suer enough are loaded (#8090) * [Explore] Trending topics (#8055) * redesigned trending topics * rm borders on web * get post count / age / ranking from api * spacing tweaks * fetch more topics then slice * use api data for avis/category * rm top border * Integrate new SDK, part out components * Clean up * Use status field * Bump SDK * Send up interests and langs --------- Co-authored-by: Eric Bailey <git@esb.lol> * Clean up module spacing and borders (cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c) (cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1) * Switch back border ordering (cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9) * [Explore] Starter Packs (#8095) * Temp WIP (cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18) * New SP card * Load state * Revert change * Cleanup * Interests and caching * Count total * Format * Caching * [Explore] Feed previews module (#8075) * wip new hook * get fetching working, maybe * get feed previews rendering! * fix header height * working pin button * extract out FeedLink * add loader * only make preview:header sticky * Fix headers * Header tweaks * Fix moderation filter * Fix threading --------- Co-authored-by: Eric Bailey <git@esb.lol> * Space it out * Fix query key * Mock new endpoint, filter saved feeds * Make sure we're pinning, lower cache time * add news category * Remove log * Improve suggested accounts load state * Integrate new app view endpoint * fragment * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * lint * maybe fix this --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 791 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
+1
-1
@@ -58,7 +58,7 @@
|
|||||||
"icons:optimize": "svgo -f ./assets/icons"
|
"icons:optimize": "svgo -f ./assets/icons"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.14.16",
|
"@atproto/api": "^0.14.19",
|
||||||
"@bitdrift/react-native": "^0.6.8",
|
"@bitdrift/react-native": "^0.6.8",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|||||||
+12
-13
@@ -1,9 +1,8 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import {JSX} from 'react/jsx-runtime'
|
import {i18n, type MessageDescriptor} from '@lingui/core'
|
||||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {
|
import {
|
||||||
BottomTabBarProps,
|
type BottomTabBarProps,
|
||||||
createBottomTabNavigator,
|
createBottomTabNavigator,
|
||||||
} from '@react-navigation/bottom-tabs'
|
} from '@react-navigation/bottom-tabs'
|
||||||
import {
|
import {
|
||||||
@@ -20,16 +19,16 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
|||||||
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
||||||
import {buildStateObject} from '#/lib/routes/helpers'
|
import {buildStateObject} from '#/lib/routes/helpers'
|
||||||
import {
|
import {
|
||||||
AllNavigatorParams,
|
type AllNavigatorParams,
|
||||||
BottomTabNavigatorParams,
|
type BottomTabNavigatorParams,
|
||||||
FlatNavigatorParams,
|
type FlatNavigatorParams,
|
||||||
HomeTabNavigatorParams,
|
type HomeTabNavigatorParams,
|
||||||
MessagesTabNavigatorParams,
|
type MessagesTabNavigatorParams,
|
||||||
MyProfileTabNavigatorParams,
|
type MyProfileTabNavigatorParams,
|
||||||
NotificationsTabNavigatorParams,
|
type NotificationsTabNavigatorParams,
|
||||||
SearchTabNavigatorParams,
|
type SearchTabNavigatorParams,
|
||||||
} from '#/lib/routes/types'
|
} 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 {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||||
import {bskyTitle} from '#/lib/strings/headings'
|
import {bskyTitle} from '#/lib/strings/headings'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -59,7 +58,6 @@ import {ProfileScreen} from '#/view/screens/Profile'
|
|||||||
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
||||||
import {ProfileListScreen} from '#/view/screens/ProfileList'
|
import {ProfileListScreen} from '#/view/screens/ProfileList'
|
||||||
import {SavedFeeds} from '#/view/screens/SavedFeeds'
|
import {SavedFeeds} from '#/view/screens/SavedFeeds'
|
||||||
import {SearchScreen} from '#/view/screens/Search'
|
|
||||||
import {Storybook} from '#/view/screens/Storybook'
|
import {Storybook} from '#/view/screens/Storybook'
|
||||||
import {SupportScreen} from '#/view/screens/Support'
|
import {SupportScreen} from '#/view/screens/Support'
|
||||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||||
@@ -81,6 +79,7 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed'
|
|||||||
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
|
import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers'
|
||||||
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
|
import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {SearchScreen} from '#/screens/Search'
|
||||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||||
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||||
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
|
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
|
||||||
|
|||||||
+48
-20
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {GestureResponderEvent, View} from 'react-native'
|
import {type GestureResponderEvent, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
AppBskyGraphDefs,
|
type AppBskyGraphDefs,
|
||||||
AtUri,
|
AtUri,
|
||||||
RichText as RichTextApi,
|
RichText as RichTextApi,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
@@ -23,15 +23,20 @@ import * as Toast from '#/view/com/util/Toast'
|
|||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
Button,
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
ButtonIcon,
|
||||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
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 {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
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 {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 = {
|
type Props = {
|
||||||
view: AppBskyFeedDefs.GeneratorView
|
view: AppBskyFeedDefs.GeneratorView
|
||||||
@@ -81,11 +86,11 @@ export function Link({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Outer({children}: {children: React.ReactNode}) {
|
export function Outer({children}: {children: React.ReactNode}) {
|
||||||
return <View style={[a.w_full, a.gap_md]}>{children}</View>
|
return <View style={[a.w_full, a.gap_sm]}>{children}</View>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({children}: {children: React.ReactNode}) {
|
export function Header({children}: {children: React.ReactNode}) {
|
||||||
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarProps = {src: string | undefined; size?: number}
|
export type AvatarProps = {src: string | undefined; size?: number}
|
||||||
@@ -220,22 +225,27 @@ export function Likes({count}: {count: number}) {
|
|||||||
export function SaveButton({
|
export function SaveButton({
|
||||||
view,
|
view,
|
||||||
pin,
|
pin,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||||
pin?: boolean
|
pin?: boolean
|
||||||
}) {
|
text?: boolean
|
||||||
|
} & Partial<ButtonProps>) {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
if (!hasSession) return null
|
if (!hasSession) return null
|
||||||
return <SaveButtonInner view={view} pin={pin} />
|
return <SaveButtonInner view={view} pin={pin} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SaveButtonInner({
|
function SaveButtonInner({
|
||||||
view,
|
view,
|
||||||
pin,
|
pin,
|
||||||
|
text = true,
|
||||||
|
...buttonProps
|
||||||
}: {
|
}: {
|
||||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||||
pin?: boolean
|
pin?: boolean
|
||||||
}) {
|
text?: boolean
|
||||||
|
} & Partial<ButtonProps>) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||||
@@ -294,14 +304,32 @@ function SaveButtonInner({
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
label={_(msg`Add this feed to your feeds`)}
|
label={_(msg`Add this feed to your feeds`)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="solid"
|
||||||
color="secondary"
|
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||||
shape="square"
|
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
||||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
{...buttonProps}>
|
||||||
{savedFeedConfig ? (
|
{savedFeedConfig ? (
|
||||||
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
|
<>
|
||||||
|
{isPending ? (
|
||||||
|
<ButtonIcon size="md" icon={Loader} />
|
||||||
|
) : (
|
||||||
|
!text && <ButtonIcon size="md" icon={TrashIcon} />
|
||||||
|
)}
|
||||||
|
{text && (
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Unpin Feed</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
|
<>
|
||||||
|
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||||
|
{text && (
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Pin Feed</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {GestureResponderEvent, View} from 'react-native'
|
import {type GestureResponderEvent, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationOpts,
|
type ModerationOpts,
|
||||||
RichText as RichTextApi,
|
RichText as RichTextApi,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
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 {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function Default({
|
export function Default({
|
||||||
profile,
|
profile,
|
||||||
@@ -133,7 +138,7 @@ export function Avatar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={42}
|
size={40}
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
@@ -149,8 +154,8 @@ export function AvatarPlaceholder() {
|
|||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
t.atoms.bg_contrast_50,
|
t.atoms.bg_contrast_50,
|
||||||
{
|
{
|
||||||
width: 42,
|
width: 40,
|
||||||
height: 42,
|
height: 40,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -261,7 +266,7 @@ export function DescriptionPlaceholder({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<View style={[{gap: 8}]}>
|
<View style={[a.pt_2xs, {gap: 6}]}>
|
||||||
{Array(numberOfLines)
|
{Array(numberOfLines)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
.map((_, i) => (
|
.map((_, i) => (
|
||||||
@@ -286,6 +291,7 @@ export type FollowButtonProps = {
|
|||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow']['logContext']
|
||||||
colorInverted?: boolean
|
colorInverted?: boolean
|
||||||
onFollow?: () => void
|
onFollow?: () => void
|
||||||
|
withIcon?: boolean
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
@@ -301,6 +307,7 @@ export function FollowButtonInner({
|
|||||||
onPress: onPressProp,
|
onPress: onPressProp,
|
||||||
onFollow,
|
onFollow,
|
||||||
colorInverted,
|
colorInverted,
|
||||||
|
withIcon = true,
|
||||||
...rest
|
...rest
|
||||||
}: FollowButtonProps) {
|
}: FollowButtonProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -386,7 +393,9 @@ export function FollowButtonInner({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
{...rest}
|
{...rest}
|
||||||
onPress={onPressUnfollow}>
|
onPress={onPressUnfollow}>
|
||||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
{withIcon && (
|
||||||
|
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||||
|
)}
|
||||||
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -397,7 +406,9 @@ export function FollowButtonInner({
|
|||||||
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
||||||
{...rest}
|
{...rest}
|
||||||
onPress={onPressFollow}>
|
onPress={onPressFollow}>
|
||||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
{withIcon && (
|
||||||
|
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||||
|
)}
|
||||||
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Animated, {
|
|||||||
LinearTransition,
|
LinearTransition,
|
||||||
ZoomInEasyDown,
|
ZoomInEasyDown,
|
||||||
} from 'react-native-reanimated'
|
} 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 {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
|||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
|
||||||
import {ListMethods} from '#/view/com/util/List'
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
import {
|
import {
|
||||||
popularInterests,
|
popularInterests,
|
||||||
useInterestsDisplayNames,
|
useInterestsDisplayNames,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
tokens,
|
tokens,
|
||||||
useBreakpoints,
|
useBreakpoints,
|
||||||
useTheme,
|
useTheme,
|
||||||
ViewStyleProp,
|
type ViewStyleProp,
|
||||||
web,
|
web,
|
||||||
} from '#/alf'
|
} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -452,12 +452,14 @@ let Tabs = ({
|
|||||||
selectedInterest,
|
selectedInterest,
|
||||||
hasSearchText,
|
hasSearchText,
|
||||||
interestsDisplayNames,
|
interestsDisplayNames,
|
||||||
|
TabComponent = Tab,
|
||||||
}: {
|
}: {
|
||||||
onSelectTab: (tab: string) => void
|
onSelectTab: (tab: string) => void
|
||||||
interests: string[]
|
interests: string[]
|
||||||
selectedInterest: string
|
selectedInterest: string
|
||||||
hasSearchText: boolean
|
hasSearchText: boolean
|
||||||
interestsDisplayNames: Record<string, string>
|
interestsDisplayNames: Record<string, string>
|
||||||
|
TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const listRef = useRef<ScrollView>(null)
|
const listRef = useRef<ScrollView>(null)
|
||||||
const [scrollX, setScrollX] = useState(0)
|
const [scrollX, setScrollX] = useState(0)
|
||||||
@@ -532,7 +534,7 @@ let Tabs = ({
|
|||||||
{interests.map((interest, i) => {
|
{interests.map((interest, i) => {
|
||||||
const active = interest === selectedInterest && !hasSearchText
|
const active = interest === selectedInterest && !hasSearchText
|
||||||
return (
|
return (
|
||||||
<Tab
|
<TabComponent
|
||||||
key={interest}
|
key={interest}
|
||||||
onSelectTab={handleSelectTab}
|
onSelectTab={handleSelectTab}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -547,6 +549,7 @@ let Tabs = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Tabs = memo(Tabs)
|
Tabs = memo(Tabs)
|
||||||
|
export {Tabs}
|
||||||
|
|
||||||
let Tab = ({
|
let Tab = ({
|
||||||
onSelectTab,
|
onSelectTab,
|
||||||
@@ -822,7 +825,7 @@ function Empty({message}: {message: string}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function boostInterests(boosts?: string[]) {
|
export function boostInterests(boosts?: string[]) {
|
||||||
return (_a: string, _b: string) => {
|
return (_a: string, _b: string) => {
|
||||||
const indexA = boosts?.indexOf(_a) ?? -1
|
const indexA = boosts?.indexOf(_a) ?? -1
|
||||||
const indexB = boosts?.indexOf(_b) ?? -1
|
const indexB = boosts?.indexOf(_b) ?? -1
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {precacheStarterPack} from '#/state/queries/starter-packs'
|
|||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
|
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 {Text} from '#/components/Typography'
|
||||||
import * as bsky from '#/types/bsky'
|
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({
|
export function Link({
|
||||||
starterPack,
|
starterPack,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
|||||||
export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
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',
|
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',
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {StyleSheet, TextProps} from 'react-native'
|
import {StyleSheet, type TextProps} from 'react-native'
|
||||||
import type {PathProps, SvgProps} from 'react-native-svg'
|
import {type PathProps, type SvgProps} from 'react-native-svg'
|
||||||
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
import {Defs, LinearGradient, Stop} from 'react-native-svg'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export const sizes = {
|
|||||||
lg: 24,
|
lg: 24,
|
||||||
xl: 28,
|
xl: 28,
|
||||||
'2xl': 32,
|
'2xl': 32,
|
||||||
}
|
} as const
|
||||||
|
|
||||||
export function useCommonSVGProps(props: Props) {
|
export function useCommonSVGProps(props: Props) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
|||||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
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 * as Prompt from '#/components/Prompt'
|
||||||
import {TrendingTopicLink} from '#/components/TrendingTopics'
|
import {TrendingTopicLink} from '#/components/TrendingTopics'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {atoms as a, useGutters, useTheme} from '#/alf'
|
|||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
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 {Link} from '#/components/Link'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|||||||
+1
-1
@@ -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'
|
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
|
||||||
|
|
||||||
// Copyright (c) 2020 Refactoring UI Inc.
|
// Copyright (c) 2020 Refactoring UI Inc.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type Gate =
|
|||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'debug_subscriptions'
|
| 'debug_subscriptions'
|
||||||
|
| 'explore_show_suggested_feeds'
|
||||||
| 'old_postonboarding'
|
| 'old_postonboarding'
|
||||||
| 'onboarding_add_video_feed'
|
| 'onboarding_add_video_feed'
|
||||||
| 'remove_show_latest_button'
|
| 'remove_show_latest_button'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
|
|
||||||
export type MetricEvents = {
|
export type MetricEvents = {
|
||||||
// App events
|
// App events
|
||||||
init: {
|
init: {
|
||||||
@@ -202,6 +204,7 @@ export type MetricEvents = {
|
|||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
| 'PostOnboardingFindFollows'
|
| 'PostOnboardingFindFollows'
|
||||||
| 'ImmersiveVideo'
|
| 'ImmersiveVideo'
|
||||||
|
| 'ExploreSuggestedAccounts'
|
||||||
}
|
}
|
||||||
'suggestedUser:follow': {
|
'suggestedUser:follow': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -239,6 +242,7 @@ export type MetricEvents = {
|
|||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
| 'PostOnboardingFindFollows'
|
| 'PostOnboardingFindFollows'
|
||||||
| 'ImmersiveVideo'
|
| 'ImmersiveVideo'
|
||||||
|
| 'ExploreSuggestedAccounts'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
@@ -318,6 +322,22 @@ export type MetricEvents = {
|
|||||||
context: 'interstitial:discover' | 'interstitial:explore' | 'feed'
|
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:hide': {}
|
||||||
'progressGuide:followDialog:open': {}
|
'progressGuide:followDialog:open': {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorProfile,
|
type AppBskyActorProfile,
|
||||||
AppBskyGraphDefs,
|
type AppBskyGraphDefs,
|
||||||
AppBskyGraphStarterpack,
|
AppBskyGraphStarterpack,
|
||||||
Un$Typed,
|
type Un$Typed,
|
||||||
} from '@atproto/api'
|
} 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 {TID} from '@atproto/common-web'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
|
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
|
||||||
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
|
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 {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import {useMemo} from 'react'
|
|||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {SearchScreenShell} from '#/view/screens/Search/Search'
|
import {SearchScreenShell} from '#/screens/Search/Shell'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'>
|
||||||
export const ProfileSearchScreen = ({route}: Props) => {
|
export const ProfileSearchScreen = ({route}: Props) => {
|
||||||
|
|||||||
@@ -0,0 +1,923 @@
|
|||||||
|
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||||
|
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
|
||||||
|
import {
|
||||||
|
type AppBskyActorDefs,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
type AppBskyGraphDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {type MetricEvents} from '#/logger/metrics'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useActorSearchPaginated} from '#/state/queries/actor-search'
|
||||||
|
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||||
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
|
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
||||||
|
import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
|
||||||
|
import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
|
||||||
|
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||||
|
import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed'
|
||||||
|
import {PostFeedItem} from '#/view/com/posts/PostFeedItem'
|
||||||
|
import {ViewFullThread} from '#/view/com/posts/ViewFullThread'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
|
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
|
||||||
|
import {
|
||||||
|
StarterPackCard,
|
||||||
|
StarterPackCardSkeleton,
|
||||||
|
} from '#/screens/Search/components/StarterPackCard'
|
||||||
|
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
|
||||||
|
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
|
||||||
|
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
|
||||||
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
|
||||||
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
||||||
|
import {StarterPack} from '#/components/icons/StarterPack'
|
||||||
|
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as ModuleHeader from './components/ModuleHeader'
|
||||||
|
import {
|
||||||
|
type FeedPreviewItem,
|
||||||
|
useFeedPreviews,
|
||||||
|
} from './modules/ExploreFeedPreviews'
|
||||||
|
import {
|
||||||
|
SuggestedAccountsTabBar,
|
||||||
|
SuggestedProfileCard,
|
||||||
|
useLoadEnoughProfiles,
|
||||||
|
} from './modules/ExploreSuggestedAccounts'
|
||||||
|
|
||||||
|
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Load more`)}
|
||||||
|
onPress={item.onLoadMore}
|
||||||
|
style={[a.relative, a.w_full]}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_md,
|
||||||
|
a.gap_sm,
|
||||||
|
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.leading_snug,
|
||||||
|
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{item.message}
|
||||||
|
</Text>
|
||||||
|
{item.isLoadingMore ? (
|
||||||
|
<Loader size="sm" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon
|
||||||
|
size="sm"
|
||||||
|
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExploreScreenItems =
|
||||||
|
| {
|
||||||
|
type: 'topBorder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'header'
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
searchButton?: {
|
||||||
|
label: string
|
||||||
|
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
|
||||||
|
tab: 'user' | 'profile' | 'feed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tabbedHeader'
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
icon: React.ComponentType<SVGIconProps>
|
||||||
|
searchButton?: {
|
||||||
|
label: string
|
||||||
|
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
|
||||||
|
tab: 'user' | 'profile' | 'feed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'trendingTopics'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'trendingVideos'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'recommendations'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'profile'
|
||||||
|
key: string
|
||||||
|
profile: AppBskyActorDefs.ProfileView
|
||||||
|
recId?: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'feed'
|
||||||
|
key: string
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'loadMore'
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
isLoadingMore: boolean
|
||||||
|
onLoadMore: () => void
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'profilePlaceholder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'feedPlaceholder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'error'
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'starterPack'
|
||||||
|
key: string
|
||||||
|
view: AppBskyGraphDefs.StarterPackView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'starterPackSkeleton'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| FeedPreviewItem
|
||||||
|
|
||||||
|
export function Explore({
|
||||||
|
focusSearchInput,
|
||||||
|
headerHeight,
|
||||||
|
}: {
|
||||||
|
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
|
||||||
|
headerHeight: number
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const gate = useGate()
|
||||||
|
const guide = useProgressGuide('follow-10')
|
||||||
|
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
|
||||||
|
const {
|
||||||
|
data: suggestedProfiles,
|
||||||
|
hasNextPage: hasNextSuggestedProfilesPage,
|
||||||
|
isLoading: isLoadingSuggestedProfiles,
|
||||||
|
isFetchingNextPage: isFetchingNextSuggestedProfilesPage,
|
||||||
|
error: suggestedProfilesError,
|
||||||
|
fetchNextPage: fetchNextSuggestedProfilesPage,
|
||||||
|
} = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10})
|
||||||
|
const {
|
||||||
|
data: interestProfiles,
|
||||||
|
hasNextPage: hasNextInterestProfilesPage,
|
||||||
|
isLoading: isLoadingInterestProfiles,
|
||||||
|
isFetchingNextPage: isFetchingNextInterestProfilesPage,
|
||||||
|
error: interestProfilesError,
|
||||||
|
fetchNextPage: fetchNextInterestProfilesPage,
|
||||||
|
} = useActorSearchPaginated({
|
||||||
|
query: selectedInterest || '',
|
||||||
|
enabled: !!selectedInterest,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({
|
||||||
|
interest: selectedInterest,
|
||||||
|
data: interestProfiles,
|
||||||
|
isLoading: isLoadingInterestProfiles,
|
||||||
|
isFetchingNextPage: isFetchingNextInterestProfilesPage,
|
||||||
|
hasNextPage: hasNextInterestProfilesPage,
|
||||||
|
fetchNextPage: fetchNextInterestProfilesPage,
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
data: feeds,
|
||||||
|
hasNextPage: hasNextFeedsPage,
|
||||||
|
isLoading: isLoadingFeeds,
|
||||||
|
isFetchingNextPage: isFetchingNextFeedsPage,
|
||||||
|
error: feedsError,
|
||||||
|
fetchNextPage: fetchNextFeedsPage,
|
||||||
|
} = useGetPopularFeedsQuery({limit: 10})
|
||||||
|
|
||||||
|
const profiles: typeof suggestedProfiles & typeof interestProfiles =
|
||||||
|
!selectedInterest ? suggestedProfiles : interestProfiles
|
||||||
|
const hasNextProfilesPage = !selectedInterest
|
||||||
|
? hasNextSuggestedProfilesPage
|
||||||
|
: hasNextInterestProfilesPage
|
||||||
|
const isLoadingProfiles = !selectedInterest
|
||||||
|
? isLoadingSuggestedProfiles
|
||||||
|
: !canShowSuggestedProfiles
|
||||||
|
const isFetchingNextProfilesPage = !selectedInterest
|
||||||
|
? isFetchingNextSuggestedProfilesPage
|
||||||
|
: !canShowSuggestedProfiles
|
||||||
|
const profilesError = !selectedInterest
|
||||||
|
? suggestedProfilesError
|
||||||
|
: interestProfilesError
|
||||||
|
const fetchNextProfilesPage = !selectedInterest
|
||||||
|
? fetchNextSuggestedProfilesPage
|
||||||
|
: fetchNextInterestProfilesPage
|
||||||
|
|
||||||
|
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
||||||
|
const onLoadMoreProfiles = useCallback(async () => {
|
||||||
|
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
await fetchNextProfilesPage()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more suggested follows', {message: err})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isFetchingNextProfilesPage,
|
||||||
|
hasNextProfilesPage,
|
||||||
|
profilesError,
|
||||||
|
fetchNextProfilesPage,
|
||||||
|
])
|
||||||
|
const {
|
||||||
|
data: suggestedSPs,
|
||||||
|
isLoading: isLoadingSuggestedSPs,
|
||||||
|
error: suggestedSPsError,
|
||||||
|
} = useSuggestedStarterPacksQuery()
|
||||||
|
|
||||||
|
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
|
||||||
|
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
|
||||||
|
const onLoadMoreFeeds = useCallback(async () => {
|
||||||
|
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
|
||||||
|
if (!hasPressedLoadMoreFeeds) {
|
||||||
|
setHasPressedLoadMoreFeeds(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetchNextFeedsPage()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more suggested follows', {message: err})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isFetchingNextFeedsPage,
|
||||||
|
hasNextFeedsPage,
|
||||||
|
feedsError,
|
||||||
|
fetchNextFeedsPage,
|
||||||
|
hasPressedLoadMoreFeeds,
|
||||||
|
])
|
||||||
|
|
||||||
|
const {data: suggestedFeeds} = useGetSuggestedFeedsQuery()
|
||||||
|
const {
|
||||||
|
data: feedPreviewSlices,
|
||||||
|
query: {
|
||||||
|
isPending: isPendingFeedPreviews,
|
||||||
|
isFetchingNextPage: isFetchingNextPageFeedPreviews,
|
||||||
|
fetchNextPage: fetchNextPageFeedPreviews,
|
||||||
|
hasNextPage: hasNextPageFeedPreviews,
|
||||||
|
error: feedPreviewSlicesError,
|
||||||
|
},
|
||||||
|
} = useFeedPreviews(suggestedFeeds?.feeds ?? [])
|
||||||
|
|
||||||
|
const onLoadMoreFeedPreviews = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
isPendingFeedPreviews ||
|
||||||
|
isFetchingNextPageFeedPreviews ||
|
||||||
|
!hasNextPageFeedPreviews ||
|
||||||
|
feedPreviewSlicesError
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
await fetchNextPageFeedPreviews()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to load more feed previews', {message: err})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isPendingFeedPreviews,
|
||||||
|
isFetchingNextPageFeedPreviews,
|
||||||
|
hasNextPageFeedPreviews,
|
||||||
|
feedPreviewSlicesError,
|
||||||
|
fetchNextPageFeedPreviews,
|
||||||
|
])
|
||||||
|
|
||||||
|
const items = useMemo<ExploreScreenItems[]>(() => {
|
||||||
|
const i: ExploreScreenItems[] = []
|
||||||
|
|
||||||
|
const addTopBorder = () => {
|
||||||
|
i.push({type: 'topBorder', key: 'top-border'})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTrendingTopicsModule = () => {
|
||||||
|
i.push({
|
||||||
|
type: 'trendingTopics',
|
||||||
|
key: `trending-topics`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// temp - disable trending videos
|
||||||
|
// if (isNative) {
|
||||||
|
// i.push({
|
||||||
|
// type: 'trendingVideos',
|
||||||
|
// key: `trending-videos`,
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuggestedFollowsModule = () => {
|
||||||
|
i.push({
|
||||||
|
type: 'tabbedHeader',
|
||||||
|
key: 'suggested-accounts-header',
|
||||||
|
title: _(msg`Suggested Accounts`),
|
||||||
|
icon: Person,
|
||||||
|
searchButton: {
|
||||||
|
label: _(msg`Search for more accounts`),
|
||||||
|
metricsTag: 'suggestedAccounts',
|
||||||
|
tab: 'user',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!canShowSuggestedProfiles) {
|
||||||
|
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
||||||
|
} else if (profilesError) {
|
||||||
|
i.push({
|
||||||
|
type: 'error',
|
||||||
|
key: 'profilesError',
|
||||||
|
message: _(msg`Failed to load suggested follows`),
|
||||||
|
error: cleanError(profilesError),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (profiles !== undefined) {
|
||||||
|
if (profiles.pages.length > 0 && moderationOpts) {
|
||||||
|
// Currently the responses contain duplicate items.
|
||||||
|
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||||
|
let seen = new Set()
|
||||||
|
const profileItems: ExploreScreenItems[] = []
|
||||||
|
for (const page of profiles.pages) {
|
||||||
|
for (const actor of page.actors) {
|
||||||
|
if (!seen.has(actor.did) && !actor.viewer?.following) {
|
||||||
|
seen.add(actor.did)
|
||||||
|
profileItems.push({
|
||||||
|
type: 'profile',
|
||||||
|
key: actor.did,
|
||||||
|
profile: actor,
|
||||||
|
recId: page.recId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileItems.length === 0) {
|
||||||
|
if (!hasNextProfilesPage) {
|
||||||
|
// no items! remove the header
|
||||||
|
i.pop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i.push(...profileItems)
|
||||||
|
}
|
||||||
|
if (hasNextProfilesPage) {
|
||||||
|
i.push({
|
||||||
|
type: 'loadMore',
|
||||||
|
key: 'loadMoreProfiles',
|
||||||
|
message: _(msg`Load more suggested accounts`),
|
||||||
|
isLoadingMore: isLoadingMoreProfiles,
|
||||||
|
onLoadMore: onLoadMoreProfiles,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('no pages')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuggestedFeedsModule = () => {
|
||||||
|
i.push({
|
||||||
|
type: 'header',
|
||||||
|
key: 'suggested-feeds-header',
|
||||||
|
title: _(msg`Discover Feeds`),
|
||||||
|
icon: ListSparkle,
|
||||||
|
searchButton: {
|
||||||
|
label: _(msg`Search for more feeds`),
|
||||||
|
metricsTag: 'suggestedFeeds',
|
||||||
|
tab: 'feed',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (feeds && preferences) {
|
||||||
|
// Currently the responses contain duplicate items.
|
||||||
|
// Needs to be fixed on backend, but let's dedupe to be safe.
|
||||||
|
let seen = new Set()
|
||||||
|
const feedItems: ExploreScreenItems[] = []
|
||||||
|
for (const page of feeds.pages) {
|
||||||
|
for (const feed of page.feeds) {
|
||||||
|
if (!seen.has(feed.uri)) {
|
||||||
|
seen.add(feed.uri)
|
||||||
|
feedItems.push({
|
||||||
|
type: 'feed',
|
||||||
|
key: feed.uri,
|
||||||
|
feed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feeds errors can occur during pagination, so feeds is truthy
|
||||||
|
if (feedsError) {
|
||||||
|
i.push({
|
||||||
|
type: 'error',
|
||||||
|
key: 'feedsError',
|
||||||
|
message: _(msg`Failed to load suggested feeds`),
|
||||||
|
error: cleanError(feedsError),
|
||||||
|
})
|
||||||
|
} else if (preferencesError) {
|
||||||
|
i.push({
|
||||||
|
type: 'error',
|
||||||
|
key: 'preferencesError',
|
||||||
|
message: _(msg`Failed to load feeds preferences`),
|
||||||
|
error: cleanError(preferencesError),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (feedItems.length === 0) {
|
||||||
|
if (!hasNextFeedsPage) {
|
||||||
|
i.pop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This query doesn't follow the limit very well, so the first press of the
|
||||||
|
// load more button just unslices the array back to ~10 items
|
||||||
|
if (!hasPressedLoadMoreFeeds) {
|
||||||
|
i.push(...feedItems.slice(0, 3))
|
||||||
|
} else {
|
||||||
|
i.push(...feedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasNextFeedsPage) {
|
||||||
|
i.push({
|
||||||
|
type: 'loadMore',
|
||||||
|
key: 'loadMoreFeeds',
|
||||||
|
message: _(msg`Load more suggested feeds`),
|
||||||
|
isLoadingMore: isLoadingMoreFeeds,
|
||||||
|
onLoadMore: onLoadMoreFeeds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (feedsError) {
|
||||||
|
i.push({
|
||||||
|
type: 'error',
|
||||||
|
key: 'feedsError',
|
||||||
|
message: _(msg`Failed to load suggested feeds`),
|
||||||
|
error: cleanError(feedsError),
|
||||||
|
})
|
||||||
|
} else if (preferencesError) {
|
||||||
|
i.push({
|
||||||
|
type: 'error',
|
||||||
|
key: 'preferencesError',
|
||||||
|
message: _(msg`Failed to load feeds preferences`),
|
||||||
|
error: cleanError(preferencesError),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuggestedStarterPacksModule = () => {
|
||||||
|
i.push({
|
||||||
|
type: 'header',
|
||||||
|
key: 'suggested-starterPacks-header',
|
||||||
|
title: _(msg`Starter Packs`),
|
||||||
|
icon: StarterPack,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoadingSuggestedSPs) {
|
||||||
|
Array.from({length: 3}).forEach((_, index) =>
|
||||||
|
i.push({
|
||||||
|
type: 'starterPackSkeleton',
|
||||||
|
key: `starterPackSkeleton-${index}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else if (suggestedSPsError || !suggestedSPs) {
|
||||||
|
// just get rid of the section
|
||||||
|
i.pop()
|
||||||
|
} else {
|
||||||
|
suggestedSPs.starterPacks.map(s => {
|
||||||
|
i.push({
|
||||||
|
type: 'starterPack',
|
||||||
|
key: s.uri,
|
||||||
|
view: s,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFeedPreviews = () => {
|
||||||
|
i.push(...feedPreviewSlices)
|
||||||
|
if (isFetchingNextPageFeedPreviews) {
|
||||||
|
i.push({
|
||||||
|
type: 'preview:loading',
|
||||||
|
key: 'preview-loading-more',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic module ordering
|
||||||
|
|
||||||
|
addTopBorder()
|
||||||
|
|
||||||
|
if (guide?.guide === 'follow-10' && !guide.isComplete) {
|
||||||
|
addSuggestedFollowsModule()
|
||||||
|
addSuggestedStarterPacksModule()
|
||||||
|
addTrendingTopicsModule()
|
||||||
|
} else {
|
||||||
|
addTrendingTopicsModule()
|
||||||
|
addSuggestedFollowsModule()
|
||||||
|
addSuggestedStarterPacksModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gate('explore_show_suggested_feeds')) {
|
||||||
|
addSuggestedFeedsModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
addFeedPreviews()
|
||||||
|
|
||||||
|
return i
|
||||||
|
}, [
|
||||||
|
_,
|
||||||
|
profiles,
|
||||||
|
feeds,
|
||||||
|
preferences,
|
||||||
|
onLoadMoreFeeds,
|
||||||
|
onLoadMoreProfiles,
|
||||||
|
isLoadingMoreProfiles,
|
||||||
|
isLoadingMoreFeeds,
|
||||||
|
profilesError,
|
||||||
|
feedsError,
|
||||||
|
preferencesError,
|
||||||
|
hasNextProfilesPage,
|
||||||
|
hasNextFeedsPage,
|
||||||
|
guide,
|
||||||
|
gate,
|
||||||
|
moderationOpts,
|
||||||
|
hasPressedLoadMoreFeeds,
|
||||||
|
suggestedSPs,
|
||||||
|
isLoadingSuggestedSPs,
|
||||||
|
suggestedSPsError,
|
||||||
|
feedPreviewSlices,
|
||||||
|
isFetchingNextPageFeedPreviews,
|
||||||
|
canShowSuggestedProfiles,
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({item, index}: {item: ExploreScreenItems; index: number}) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'topBorder':
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.border_t,
|
||||||
|
headerHeight &&
|
||||||
|
web({
|
||||||
|
position: 'sticky',
|
||||||
|
top: headerHeight,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'header': {
|
||||||
|
return (
|
||||||
|
<ModuleHeader.Container>
|
||||||
|
<ModuleHeader.Icon icon={item.icon} />
|
||||||
|
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||||
|
{item.searchButton && (
|
||||||
|
<ModuleHeader.SearchButton
|
||||||
|
{...item.searchButton}
|
||||||
|
onPress={() =>
|
||||||
|
focusSearchInput(item.searchButton?.tab || 'user')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModuleHeader.Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'tabbedHeader': {
|
||||||
|
return (
|
||||||
|
<View style={[a.pb_md]}>
|
||||||
|
<ModuleHeader.Container style={[a.pb_xs]}>
|
||||||
|
<ModuleHeader.Icon icon={item.icon} />
|
||||||
|
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||||
|
{item.searchButton && (
|
||||||
|
<ModuleHeader.SearchButton
|
||||||
|
{...item.searchButton}
|
||||||
|
onPress={() =>
|
||||||
|
focusSearchInput(item.searchButton?.tab || 'user')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModuleHeader.Container>
|
||||||
|
<SuggestedAccountsTabBar
|
||||||
|
selectedInterest={selectedInterest}
|
||||||
|
onSelectInterest={setSelectedInterest}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'trendingTopics': {
|
||||||
|
return (
|
||||||
|
<View style={[a.pb_md]}>
|
||||||
|
<ExploreTrendingTopics />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'trendingVideos': {
|
||||||
|
return <ExploreTrendingVideos />
|
||||||
|
}
|
||||||
|
case 'recommendations': {
|
||||||
|
return <ExploreRecommendations />
|
||||||
|
}
|
||||||
|
case 'profile': {
|
||||||
|
return (
|
||||||
|
<SuggestedProfileCard
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts!}
|
||||||
|
recId={item.recId}
|
||||||
|
position={index}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'feed': {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_lg,
|
||||||
|
]}>
|
||||||
|
<FeedCard.Default view={item.feed} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'starterPack': {
|
||||||
|
return (
|
||||||
|
<View style={[a.px_lg, a.pb_lg]}>
|
||||||
|
<StarterPackCard view={item.view} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'starterPackSkeleton': {
|
||||||
|
return (
|
||||||
|
<View style={[a.px_lg, a.pb_lg]}>
|
||||||
|
<StarterPackCardSkeleton />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'loadMore': {
|
||||||
|
return (
|
||||||
|
<View style={[a.border_t, t.atoms.border_contrast_low]}>
|
||||||
|
<LoadMore item={item} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'profilePlaceholder': {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({length: 3}).map((_, index) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.px_lg,
|
||||||
|
a.py_lg,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}
|
||||||
|
key={index}>
|
||||||
|
<ProfileCard.Outer>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.AvatarPlaceholder />
|
||||||
|
<ProfileCard.NameAndHandlePlaceholder />
|
||||||
|
</ProfileCard.Header>
|
||||||
|
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
|
||||||
|
</ProfileCard.Outer>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'feedPlaceholder': {
|
||||||
|
return <FeedFeedLoadingPlaceholder />
|
||||||
|
}
|
||||||
|
case 'error':
|
||||||
|
case 'preview:error': {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_t,
|
||||||
|
a.pt_md,
|
||||||
|
a.px_md,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_md,
|
||||||
|
a.p_lg,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<CircleInfo size="md" fill={t.palette.negative_400} />
|
||||||
|
<View style={[a.flex_1, a.gap_sm]}>
|
||||||
|
<Text style={[a.font_bold, a.leading_snug]}>
|
||||||
|
{item.message}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.italic,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{item.error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// feed previews
|
||||||
|
case 'preview:empty': {
|
||||||
|
return null // what should we do here?
|
||||||
|
}
|
||||||
|
case 'preview:loading': {
|
||||||
|
return (
|
||||||
|
<View style={[a.py_2xl, a.flex_1, a.align_center]}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'preview:header': {
|
||||||
|
return (
|
||||||
|
<ModuleHeader.Container
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}>
|
||||||
|
<ModuleHeader.FeedLink feed={item.feed}>
|
||||||
|
<ModuleHeader.FeedAvatar feed={item.feed} />
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<ModuleHeader.TitleText style={[a.text_lg]}>
|
||||||
|
{item.feed.displayName}
|
||||||
|
</ModuleHeader.TitleText>
|
||||||
|
<ModuleHeader.SubtitleText>
|
||||||
|
<Trans>
|
||||||
|
By {sanitizeHandle(item.feed.creator.handle, '@')}
|
||||||
|
</Trans>
|
||||||
|
</ModuleHeader.SubtitleText>
|
||||||
|
</View>
|
||||||
|
</ModuleHeader.FeedLink>
|
||||||
|
<ModuleHeader.PinButton feed={item.feed} />
|
||||||
|
</ModuleHeader.Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'preview:footer': {
|
||||||
|
return <View style={[a.w_full, a.pt_2xl]} />
|
||||||
|
}
|
||||||
|
case 'preview:sliceItem': {
|
||||||
|
const slice = item.slice
|
||||||
|
const indexInSlice = item.indexInSlice
|
||||||
|
const subItem = slice.items[indexInSlice]
|
||||||
|
return (
|
||||||
|
<PostFeedItem
|
||||||
|
post={subItem.post}
|
||||||
|
record={subItem.record}
|
||||||
|
reason={indexInSlice === 0 ? slice.reason : undefined}
|
||||||
|
feedContext={slice.feedContext}
|
||||||
|
moderation={subItem.moderation}
|
||||||
|
parentAuthor={subItem.parentAuthor}
|
||||||
|
showReplyTo={item.showReplyTo}
|
||||||
|
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
|
||||||
|
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
|
||||||
|
isThreadLastChild={
|
||||||
|
isThreadChildAt(slice.items, indexInSlice) &&
|
||||||
|
slice.items.length === indexInSlice + 1
|
||||||
|
}
|
||||||
|
isParentBlocked={subItem.isParentBlocked}
|
||||||
|
isParentNotFound={subItem.isParentNotFound}
|
||||||
|
hideTopBorder={item.hideTopBorder}
|
||||||
|
rootPost={slice.items[0].post}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'preview:sliceViewFullThread': {
|
||||||
|
return <ViewFullThread uri={item.uri} />
|
||||||
|
}
|
||||||
|
case 'preview:loadMoreError': {
|
||||||
|
return (
|
||||||
|
<LoadMoreRetryBtn
|
||||||
|
label={_(
|
||||||
|
msg`There was an issue fetching posts. Tap here to try again.`,
|
||||||
|
)}
|
||||||
|
onPress={fetchNextPageFeedPreviews}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
t,
|
||||||
|
focusSearchInput,
|
||||||
|
moderationOpts,
|
||||||
|
selectedInterest,
|
||||||
|
_,
|
||||||
|
fetchNextPageFeedPreviews,
|
||||||
|
headerHeight,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const stickyHeaderIndices = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(acc, curr) =>
|
||||||
|
['topBorder', 'preview:header'].includes(curr.type)
|
||||||
|
? acc.concat(items.indexOf(curr))
|
||||||
|
: acc,
|
||||||
|
[] as number[],
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
// track headers and report module viewability
|
||||||
|
const alreadyReportedRef = useRef<Map<string, string>>(new Map())
|
||||||
|
const onViewableItemsChanged = useCallback(
|
||||||
|
({
|
||||||
|
viewableItems,
|
||||||
|
}: {
|
||||||
|
viewableItems: ViewToken<ExploreScreenItems>[]
|
||||||
|
changed: ViewToken<ExploreScreenItems>[]
|
||||||
|
}) => {
|
||||||
|
for (const {item} of viewableItems.filter(vi => vi.isViewable)) {
|
||||||
|
let module: MetricEvents['explore:module:seen']['module']
|
||||||
|
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
|
||||||
|
module = item.type
|
||||||
|
} else if (item.type === 'profile') {
|
||||||
|
module = 'suggestedAccounts'
|
||||||
|
} else if (item.type === 'feed') {
|
||||||
|
module = 'suggestedFeeds'
|
||||||
|
} else if (item.type === 'preview:header') {
|
||||||
|
module = `feed:feedgen|${item.feed.uri}`
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!alreadyReportedRef.current.has(module)) {
|
||||||
|
alreadyReportedRef.current.set(module, module)
|
||||||
|
logger.metric('explore:module:seen', {module})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
data={items}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={item => item.key}
|
||||||
|
desktopFixedHeight
|
||||||
|
contentContainerStyle={{paddingBottom: 100}}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
stickyHeaderIndices={native(stickyHeaderIndices)}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
|
onEndReached={onLoadMoreFeedPreviews}
|
||||||
|
onEndReachedThreshold={2}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewabilityConfig: ViewabilityConfig = {
|
||||||
|
itemVisiblePercentThreshold: 100,
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import {memo, useCallback, useMemo, useState} from 'react'
|
||||||
|
import {ActivityIndicator, View} from 'react-native'
|
||||||
|
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||||
|
import {useActorSearch} from '#/state/queries/actor-search'
|
||||||
|
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
||||||
|
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {Pager} from '#/view/com/pager/Pager'
|
||||||
|
import {TabBar} from '#/view/com/pager/TabBar'
|
||||||
|
import {Post} from '#/view/com/post/Post'
|
||||||
|
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
let SearchResults = ({
|
||||||
|
query,
|
||||||
|
queryWithParams,
|
||||||
|
activeTab,
|
||||||
|
onPageSelected,
|
||||||
|
headerHeight,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
queryWithParams: string
|
||||||
|
activeTab: number
|
||||||
|
onPageSelected: (page: number) => void
|
||||||
|
headerHeight: number
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
if (!queryWithParams) return []
|
||||||
|
const noParams = queryWithParams === query
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: _(msg`Top`),
|
||||||
|
component: (
|
||||||
|
<SearchScreenPostResults
|
||||||
|
query={queryWithParams}
|
||||||
|
sort="top"
|
||||||
|
active={activeTab === 0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: _(msg`Latest`),
|
||||||
|
component: (
|
||||||
|
<SearchScreenPostResults
|
||||||
|
query={queryWithParams}
|
||||||
|
sort="latest"
|
||||||
|
active={activeTab === 1}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
noParams && {
|
||||||
|
title: _(msg`People`),
|
||||||
|
component: (
|
||||||
|
<SearchScreenUserResults query={query} active={activeTab === 2} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
noParams && {
|
||||||
|
title: _(msg`Feeds`),
|
||||||
|
component: (
|
||||||
|
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].filter(Boolean) as {
|
||||||
|
title: string
|
||||||
|
component: React.ReactNode
|
||||||
|
}[]
|
||||||
|
}, [_, query, queryWithParams, activeTab])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pager
|
||||||
|
onPageSelected={onPageSelected}
|
||||||
|
renderTabBar={props => (
|
||||||
|
<Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
|
||||||
|
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||||
|
</Layout.Center>
|
||||||
|
)}
|
||||||
|
initialPage={0}>
|
||||||
|
{sections.map((section, i) => (
|
||||||
|
<View key={i}>{section.component}</View>
|
||||||
|
))}
|
||||||
|
</Pager>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchResults = memo(SearchResults)
|
||||||
|
export {SearchResults}
|
||||||
|
|
||||||
|
function Loader() {
|
||||||
|
return (
|
||||||
|
<Layout.Content>
|
||||||
|
<View style={[a.py_xl]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
</Layout.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({message, error}: {message: string; error?: string}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Content>
|
||||||
|
<View style={[a.p_xl]}>
|
||||||
|
<View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}>
|
||||||
|
<Text style={[a.text_md]}>{message}</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
marginVertical: 12,
|
||||||
|
height: 1,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: t.atoms.text.color,
|
||||||
|
opacity: 0.2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Error: {error}</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Layout.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResultSlice =
|
||||||
|
| {
|
||||||
|
type: 'post'
|
||||||
|
key: string
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'loadingMore'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let SearchScreenPostResults = ({
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
sort?: 'top' | 'latest'
|
||||||
|
active: boolean
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const [isPTR, setIsPTR] = useState(false)
|
||||||
|
|
||||||
|
const augmentedQuery = useMemo(() => {
|
||||||
|
return augmentSearchQuery(query || '', {did: currentAccount?.did})
|
||||||
|
}, [query, currentAccount])
|
||||||
|
|
||||||
|
const {
|
||||||
|
isFetched,
|
||||||
|
data: results,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
} = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
|
||||||
|
|
||||||
|
const onPullToRefresh = useCallback(async () => {
|
||||||
|
setIsPTR(true)
|
||||||
|
await refetch()
|
||||||
|
setIsPTR(false)
|
||||||
|
}, [setIsPTR, refetch])
|
||||||
|
const onEndReached = useCallback(() => {
|
||||||
|
if (isFetching || !hasNextPage || error) return
|
||||||
|
fetchNextPage()
|
||||||
|
}, [isFetching, error, hasNextPage, fetchNextPage])
|
||||||
|
|
||||||
|
const posts = useMemo(() => {
|
||||||
|
return results?.pages.flatMap(page => page.posts) || []
|
||||||
|
}, [results])
|
||||||
|
const items = useMemo(() => {
|
||||||
|
let temp: SearchResultSlice[] = []
|
||||||
|
|
||||||
|
const seenUris = new Set()
|
||||||
|
for (const post of posts) {
|
||||||
|
if (seenUris.has(post.uri)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
temp.push({
|
||||||
|
type: 'post',
|
||||||
|
key: post.uri,
|
||||||
|
post,
|
||||||
|
})
|
||||||
|
seenUris.add(post.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFetchingNextPage) {
|
||||||
|
temp.push({
|
||||||
|
type: 'loadingMore',
|
||||||
|
key: 'loadingMore',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return temp
|
||||||
|
}, [posts, isFetchingNextPage])
|
||||||
|
|
||||||
|
return error ? (
|
||||||
|
<EmptyState
|
||||||
|
message={_(
|
||||||
|
msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
|
||||||
|
)}
|
||||||
|
error={error.toString()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isFetched ? (
|
||||||
|
<>
|
||||||
|
{posts.length ? (
|
||||||
|
<List
|
||||||
|
data={items}
|
||||||
|
renderItem={({item}) => {
|
||||||
|
if (item.type === 'post') {
|
||||||
|
return <Post post={item.post} />
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
keyExtractor={item => item.key}
|
||||||
|
refreshing={isPTR}
|
||||||
|
onRefresh={onPullToRefresh}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
desktopFixedHeight
|
||||||
|
contentContainerStyle={{paddingBottom: 100}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState message={_(msg`No results found for ${query}`)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchScreenPostResults = memo(SearchScreenPostResults)
|
||||||
|
|
||||||
|
let SearchScreenUserResults = ({
|
||||||
|
query,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
active: boolean
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const {data: results, isFetched} = useActorSearch({
|
||||||
|
query,
|
||||||
|
enabled: active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return isFetched && results ? (
|
||||||
|
<>
|
||||||
|
{results.length ? (
|
||||||
|
<List
|
||||||
|
data={results}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<ProfileCardWithFollowBtn profile={item} noBg />
|
||||||
|
)}
|
||||||
|
keyExtractor={item => item.did}
|
||||||
|
desktopFixedHeight
|
||||||
|
contentContainerStyle={{paddingBottom: 100}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState message={_(msg`No results found for ${query}`)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchScreenUserResults = memo(SearchScreenUserResults)
|
||||||
|
|
||||||
|
let SearchScreenFeedsResults = ({
|
||||||
|
query,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
active: boolean
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const {data: results, isFetched} = usePopularFeedsSearch({
|
||||||
|
query,
|
||||||
|
enabled: active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return isFetched && results ? (
|
||||||
|
<>
|
||||||
|
{results.length ? (
|
||||||
|
<List
|
||||||
|
data={results}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_lg,
|
||||||
|
]}>
|
||||||
|
<FeedCard.Default view={item} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={item => item.uri}
|
||||||
|
desktopFixedHeight
|
||||||
|
contentContainerStyle={{paddingBottom: 100}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState message={_(msg`No results found for ${query}`)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
type StyleProp,
|
||||||
|
type TextInput,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {HITSLOP_20} from '#/lib/constants'
|
||||||
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {MagnifyingGlassIcon} from '#/lib/icons'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {listenSoftReset} from '#/state/events'
|
||||||
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
|
import {
|
||||||
|
unstableCacheProfileView,
|
||||||
|
useProfilesQuery,
|
||||||
|
} from '#/state/queries/profile'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {
|
||||||
|
makeSearchQuery,
|
||||||
|
type Params,
|
||||||
|
parseSearchQuery,
|
||||||
|
} from '#/screens/Search/utils'
|
||||||
|
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {SearchInput} from '#/components/forms/SearchInput'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {account, useStorage} from '#/storage'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
import {AutocompleteResults} from './components/AutocompleteResults'
|
||||||
|
import {SearchHistory} from './components/SearchHistory'
|
||||||
|
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
|
||||||
|
import {Explore} from './Explore'
|
||||||
|
import {SearchResults} from './SearchResults'
|
||||||
|
|
||||||
|
export function SearchScreenShell({
|
||||||
|
queryParam,
|
||||||
|
testID,
|
||||||
|
fixedParams,
|
||||||
|
navButton = 'menu',
|
||||||
|
inputPlaceholder,
|
||||||
|
}: {
|
||||||
|
queryParam: string
|
||||||
|
testID: string
|
||||||
|
fixedParams?: Params
|
||||||
|
navButton?: 'back' | 'menu'
|
||||||
|
inputPlaceholder?: string
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const route = useRoute()
|
||||||
|
const textInput = useRef<TextInput>(null)
|
||||||
|
const {_} = useLingui()
|
||||||
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Query terms
|
||||||
|
const [searchText, setSearchText] = useState<string>(queryParam)
|
||||||
|
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
|
||||||
|
useActorAutocompleteQuery(searchText, true)
|
||||||
|
|
||||||
|
const [showAutocomplete, setShowAutocomplete] = useState(false)
|
||||||
|
|
||||||
|
const [termHistory = [], setTermHistory] = useStorage(account, [
|
||||||
|
currentAccount?.did ?? 'pwi',
|
||||||
|
'searchTermHistory',
|
||||||
|
] as const)
|
||||||
|
const [accountHistory = [], setAccountHistory] = useStorage(account, [
|
||||||
|
currentAccount?.did ?? 'pwi',
|
||||||
|
'searchAccountHistory',
|
||||||
|
])
|
||||||
|
|
||||||
|
const {data: accountHistoryProfiles} = useProfilesQuery({
|
||||||
|
handles: accountHistory,
|
||||||
|
maintainData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSearchHistory = useCallback(
|
||||||
|
async (item: string) => {
|
||||||
|
if (!item) return
|
||||||
|
const newSearchHistory = [
|
||||||
|
item,
|
||||||
|
...termHistory.filter(search => search !== item),
|
||||||
|
].slice(0, 6)
|
||||||
|
setTermHistory(newSearchHistory)
|
||||||
|
},
|
||||||
|
[termHistory, setTermHistory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateProfileHistory = useCallback(
|
||||||
|
async (item: bsky.profile.AnyProfileView) => {
|
||||||
|
const newAccountHistory = [
|
||||||
|
item.did,
|
||||||
|
...accountHistory.filter(p => p !== item.did),
|
||||||
|
].slice(0, 5)
|
||||||
|
setAccountHistory(newAccountHistory)
|
||||||
|
},
|
||||||
|
[accountHistory, setAccountHistory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteSearchHistoryItem = useCallback(
|
||||||
|
async (item: string) => {
|
||||||
|
setTermHistory(termHistory.filter(search => search !== item))
|
||||||
|
},
|
||||||
|
[termHistory, setTermHistory],
|
||||||
|
)
|
||||||
|
const deleteProfileHistoryItem = useCallback(
|
||||||
|
async (item: bsky.profile.AnyProfileView) => {
|
||||||
|
setAccountHistory(accountHistory.filter(p => p !== item.did))
|
||||||
|
},
|
||||||
|
[accountHistory, setAccountHistory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const {params, query, queryWithParams} = useQueryManager({
|
||||||
|
initialQuery: queryParam,
|
||||||
|
fixedParams,
|
||||||
|
})
|
||||||
|
const showFilters = Boolean(queryWithParams && !showAutocomplete)
|
||||||
|
|
||||||
|
// web only - measure header height for sticky positioning
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(0)
|
||||||
|
const headerRef = useRef(null)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
if (!headerRef.current) return
|
||||||
|
const measurement = (headerRef.current as Element).getBoundingClientRect()
|
||||||
|
setHeaderHeight(measurement.height)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useNonReactiveCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
setSearchText(queryParam)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressClearQuery = useCallback(() => {
|
||||||
|
scrollToTopWeb()
|
||||||
|
setSearchText('')
|
||||||
|
textInput.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onChangeText = useCallback(async (text: string) => {
|
||||||
|
scrollToTopWeb()
|
||||||
|
setSearchText(text)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const navigateToItem = useCallback(
|
||||||
|
(item: string) => {
|
||||||
|
scrollToTopWeb()
|
||||||
|
setShowAutocomplete(false)
|
||||||
|
updateSearchHistory(item)
|
||||||
|
|
||||||
|
if (isWeb) {
|
||||||
|
// @ts-expect-error route is not typesafe
|
||||||
|
navigation.push(route.name, {...route.params, q: item})
|
||||||
|
} else {
|
||||||
|
textInput.current?.blur()
|
||||||
|
navigation.setParams({q: item})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateSearchHistory, navigation, route],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressCancelSearch = useCallback(() => {
|
||||||
|
scrollToTopWeb()
|
||||||
|
textInput.current?.blur()
|
||||||
|
setShowAutocomplete(false)
|
||||||
|
if (isWeb) {
|
||||||
|
// Empty params resets the URL to be /search rather than /search?q=
|
||||||
|
|
||||||
|
const {q: _q, ...parameters} = (route.params ?? {}) as {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
// @ts-expect-error route is not typesafe
|
||||||
|
navigation.replace(route.name, parameters)
|
||||||
|
} else {
|
||||||
|
setSearchText('')
|
||||||
|
navigation.setParams({q: ''})
|
||||||
|
}
|
||||||
|
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
|
||||||
|
|
||||||
|
const onSubmit = useCallback(() => {
|
||||||
|
navigateToItem(searchText)
|
||||||
|
}, [navigateToItem, searchText])
|
||||||
|
|
||||||
|
const onAutocompleteResultPress = useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
setShowAutocomplete(false)
|
||||||
|
} else {
|
||||||
|
textInput.current?.blur()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleHistoryItemClick = useCallback(
|
||||||
|
(item: string) => {
|
||||||
|
setSearchText(item)
|
||||||
|
navigateToItem(item)
|
||||||
|
},
|
||||||
|
[navigateToItem],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleProfileClick = useCallback(
|
||||||
|
(profile: bsky.profile.AnyProfileView) => {
|
||||||
|
unstableCacheProfileView(queryClient, profile)
|
||||||
|
// Slight delay to avoid updating during push nav animation.
|
||||||
|
setTimeout(() => {
|
||||||
|
updateProfileHistory(profile)
|
||||||
|
}, 400)
|
||||||
|
},
|
||||||
|
[updateProfileHistory, queryClient],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSoftReset = useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
// Empty params resets the URL to be /search rather than /search?q=
|
||||||
|
|
||||||
|
const {q: _q, ...parameters} = (route.params ?? {}) as {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
// @ts-expect-error route is not typesafe
|
||||||
|
navigation.replace(route.name, parameters)
|
||||||
|
} else {
|
||||||
|
setSearchText('')
|
||||||
|
navigation.setParams({q: ''})
|
||||||
|
textInput.current?.focus()
|
||||||
|
}
|
||||||
|
}, [navigation, route])
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setMinimalShellMode(false)
|
||||||
|
return listenSoftReset(onSoftReset)
|
||||||
|
}, [onSoftReset, setMinimalShellMode]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSearchInputFocus = useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
// Prevent a jump on iPad by ensuring that
|
||||||
|
// the initial focused render has no result list.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShowAutocomplete(true)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setShowAutocomplete(true)
|
||||||
|
}
|
||||||
|
}, [setShowAutocomplete])
|
||||||
|
|
||||||
|
const focusSearchInput = useCallback(() => {
|
||||||
|
textInput.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showHeader = !gtMobile || navButton !== 'menu'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Screen testID={testID}>
|
||||||
|
<View
|
||||||
|
ref={headerRef}
|
||||||
|
onLayout={evt => {
|
||||||
|
if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.z_10,
|
||||||
|
web({
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<Layout.Center style={t.atoms.bg}>
|
||||||
|
{showHeader && (
|
||||||
|
<View
|
||||||
|
// HACK: shift up search input. we can't remove the top padding
|
||||||
|
// on the search input because it messes up the layout animation
|
||||||
|
// if we add it only when the header is hidden
|
||||||
|
style={{marginBottom: tokens.space.xs * -1}}>
|
||||||
|
<Layout.Header.Outer noBottomBorder>
|
||||||
|
{navButton === 'menu' ? (
|
||||||
|
<Layout.Header.MenuButton />
|
||||||
|
) : (
|
||||||
|
<Layout.Header.BackButton />
|
||||||
|
)}
|
||||||
|
<Layout.Header.Content align="left">
|
||||||
|
<Layout.Header.TitleText>
|
||||||
|
<Trans>Search</Trans>
|
||||||
|
</Layout.Header.TitleText>
|
||||||
|
</Layout.Header.Content>
|
||||||
|
{showFilters ? (
|
||||||
|
<SearchLanguageDropdown
|
||||||
|
value={params.lang}
|
||||||
|
onChange={params.setLang}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Layout.Header.Slot />
|
||||||
|
)}
|
||||||
|
</Layout.Header.Outer>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<SearchInput
|
||||||
|
ref={textInput}
|
||||||
|
value={searchText}
|
||||||
|
onFocus={onSearchInputFocus}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onClearText={onPressClearQuery}
|
||||||
|
onSubmitEditing={onSubmit}
|
||||||
|
placeholder={
|
||||||
|
inputPlaceholder ??
|
||||||
|
_(msg`Search for posts, users, or feeds`)
|
||||||
|
}
|
||||||
|
hitSlop={{...HITSLOP_20, top: 0}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{showAutocomplete && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Cancel search`)}
|
||||||
|
size="large"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
style={[a.px_sm]}
|
||||||
|
onPress={onPressCancelSearch}
|
||||||
|
hitSlop={HITSLOP_10}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showFilters && !showHeader && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<SearchLanguageDropdown
|
||||||
|
value={params.lang}
|
||||||
|
onChange={params.setLang}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Layout.Center>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
|
||||||
|
flex: 1,
|
||||||
|
}}>
|
||||||
|
{searchText.length > 0 ? (
|
||||||
|
<AutocompleteResults
|
||||||
|
isAutocompleteFetching={isAutocompleteFetching}
|
||||||
|
autocompleteData={autocompleteData}
|
||||||
|
searchText={searchText}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onResultPress={onAutocompleteResultPress}
|
||||||
|
onProfileClick={handleProfileClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SearchHistory
|
||||||
|
searchHistory={termHistory}
|
||||||
|
selectedProfiles={accountHistoryProfiles?.profiles || []}
|
||||||
|
onItemClick={handleHistoryItemClick}
|
||||||
|
onProfileClick={handleProfileClick}
|
||||||
|
onRemoveItemClick={deleteSearchHistoryItem}
|
||||||
|
onRemoveProfileClick={deleteProfileHistoryItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
display: showAutocomplete ? 'none' : 'flex',
|
||||||
|
flex: 1,
|
||||||
|
}}>
|
||||||
|
<SearchScreenInner
|
||||||
|
query={query}
|
||||||
|
queryWithParams={queryWithParams}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
focusSearchInput={focusSearchInput}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Layout.Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let SearchScreenInner = ({
|
||||||
|
query,
|
||||||
|
queryWithParams,
|
||||||
|
headerHeight,
|
||||||
|
focusSearchInput,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
queryWithParams: string
|
||||||
|
headerHeight: number
|
||||||
|
focusSearchInput: () => void
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const {gtTablet} = useBreakpoints()
|
||||||
|
const [activeTab, setActiveTab] = useState(0)
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const onPageSelected = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setMinimalShellMode(false)
|
||||||
|
setActiveTab(index)
|
||||||
|
},
|
||||||
|
[setMinimalShellMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryWithParams ? (
|
||||||
|
<SearchResults
|
||||||
|
query={query}
|
||||||
|
queryWithParams={queryWithParams}
|
||||||
|
activeTab={activeTab}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
onPageSelected={onPageSelected}
|
||||||
|
/>
|
||||||
|
) : hasSession ? (
|
||||||
|
<Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
|
||||||
|
) : (
|
||||||
|
<Layout.Center>
|
||||||
|
<View style={a.flex_1}>
|
||||||
|
{gtTablet && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.px_lg,
|
||||||
|
a.pt_sm,
|
||||||
|
a.pb_lg,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_heavy]}>
|
||||||
|
<Trans>Search</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
|
||||||
|
<MagnifyingGlassIcon
|
||||||
|
strokeWidth={3}
|
||||||
|
size={60}
|
||||||
|
style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
|
||||||
|
/>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||||
|
<Trans>Find posts, users, and feeds on Bluesky</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Layout.Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchScreenInner = memo(SearchScreenInner)
|
||||||
|
|
||||||
|
function useQueryManager({
|
||||||
|
initialQuery,
|
||||||
|
fixedParams,
|
||||||
|
}: {
|
||||||
|
initialQuery: string
|
||||||
|
fixedParams?: Params
|
||||||
|
}) {
|
||||||
|
const {query, params: initialParams} = useMemo(() => {
|
||||||
|
return parseSearchQuery(initialQuery || '')
|
||||||
|
}, [initialQuery])
|
||||||
|
const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
|
||||||
|
const [lang, setLang] = useState(initialParams.lang || '')
|
||||||
|
|
||||||
|
if (initialQuery !== prevInitialQuery) {
|
||||||
|
// handle new queryParam change (from manual search entry)
|
||||||
|
setPrevInitialQuery(initialQuery)
|
||||||
|
setLang(initialParams.lang || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = useMemo(
|
||||||
|
() => ({
|
||||||
|
// default stuff
|
||||||
|
...initialParams,
|
||||||
|
// managed stuff
|
||||||
|
lang,
|
||||||
|
...fixedParams,
|
||||||
|
}),
|
||||||
|
[lang, initialParams, fixedParams],
|
||||||
|
)
|
||||||
|
const handlers = useMemo(
|
||||||
|
() => ({
|
||||||
|
setLang,
|
||||||
|
}),
|
||||||
|
[setLang],
|
||||||
|
)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
queryWithParams: makeSearchQuery(query, params),
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
...handlers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [query, params, handlers])
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTopWeb() {
|
||||||
|
if (isWeb) {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {memo} from 'react'
|
||||||
|
import {ActivityIndicator, View} from 'react-native'
|
||||||
|
import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
|
||||||
|
import {atoms as a, native} from '#/alf'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
|
||||||
|
let AutocompleteResults = ({
|
||||||
|
isAutocompleteFetching,
|
||||||
|
autocompleteData,
|
||||||
|
searchText,
|
||||||
|
onSubmit,
|
||||||
|
onResultPress,
|
||||||
|
onProfileClick,
|
||||||
|
}: {
|
||||||
|
isAutocompleteFetching: boolean
|
||||||
|
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
|
||||||
|
searchText: string
|
||||||
|
onSubmit: () => void
|
||||||
|
onResultPress: () => void
|
||||||
|
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const {_} = useLingui()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(isAutocompleteFetching && !autocompleteData?.length) ||
|
||||||
|
!moderationOpts ? (
|
||||||
|
<Layout.Content>
|
||||||
|
<View style={[a.py_xl]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
</Layout.Content>
|
||||||
|
) : (
|
||||||
|
<Layout.Content
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag">
|
||||||
|
<SearchLinkCard
|
||||||
|
label={_(msg`Search for "${searchText}"`)}
|
||||||
|
onPress={native(onSubmit)}
|
||||||
|
to={
|
||||||
|
isNative
|
||||||
|
? undefined
|
||||||
|
: `/search?q=${encodeURIComponent(searchText)}`
|
||||||
|
}
|
||||||
|
style={{borderBottomWidth: 1}}
|
||||||
|
/>
|
||||||
|
{autocompleteData?.map(item => (
|
||||||
|
<SearchProfileCard
|
||||||
|
key={item.did}
|
||||||
|
profile={item}
|
||||||
|
moderation={moderateProfile(item, moderationOpts)}
|
||||||
|
onPress={() => {
|
||||||
|
onProfileClick(item)
|
||||||
|
onResultPress()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<View style={{height: 200}} />
|
||||||
|
</Layout.Content>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AutocompleteResults = memo(AutocompleteResults)
|
||||||
|
export {AutocompleteResults}
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {
|
|
||||||
useTrendingSettings,
|
|
||||||
useTrendingSettingsApi,
|
|
||||||
} from '#/state/preferences/trending'
|
|
||||||
import {
|
|
||||||
DEFAULT_LIMIT as TRENDING_TOPICS_COUNT,
|
|
||||||
useTrendingTopics,
|
|
||||||
} from '#/state/queries/trending/useTrendingTopics'
|
|
||||||
import {useTrendingConfig} from '#/state/trending-config'
|
|
||||||
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
|
||||||
import {GradientFill} from '#/components/GradientFill'
|
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
|
||||||
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
|
|
||||||
import * as Prompt from '#/components/Prompt'
|
|
||||||
import {
|
|
||||||
TrendingTopic,
|
|
||||||
TrendingTopicLink,
|
|
||||||
TrendingTopicSkeleton,
|
|
||||||
} from '#/components/TrendingTopics'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
export function ExploreTrendingTopics() {
|
|
||||||
const {enabled} = useTrendingConfig()
|
|
||||||
const {trendingDisabled} = useTrendingSettings()
|
|
||||||
return enabled && !trendingDisabled ? <Inner /> : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inner() {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const gutters = useGutters([0, 'compact'])
|
|
||||||
const {data: trending, error, isLoading} = useTrendingTopics()
|
|
||||||
const noTopics = !isLoading && !error && !trending?.topics?.length
|
|
||||||
const {setTrendingDisabled} = useTrendingSettingsApi()
|
|
||||||
const trendingPrompt = Prompt.usePromptControl()
|
|
||||||
|
|
||||||
const onConfirmHide = React.useCallback(() => {
|
|
||||||
logEvent('trendingTopics:hide', {context: 'explore:trending'})
|
|
||||||
setTrendingDisabled(true)
|
|
||||||
}, [setTrendingDisabled])
|
|
||||||
|
|
||||||
return error || noTopics ? null : (
|
|
||||||
<>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
isWeb
|
|
||||||
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
|
||||||
: [a.p_lg, a.pt_2xl, a.gap_md],
|
|
||||||
a.border_b,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
]}>
|
|
||||||
<View style={[a.flex_1, a.gap_sm]}>
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<Trending
|
|
||||||
size="lg"
|
|
||||||
fill={t.palette.primary_500}
|
|
||||||
style={{marginLeft: -2}}
|
|
||||||
/>
|
|
||||||
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
|
|
||||||
<Trans>Trending</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
|
|
||||||
<GradientFill gradient={tokens.gradients.primary} />
|
|
||||||
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
|
|
||||||
<Trans>BETA</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
|
||||||
<Trans>What people are posting about.</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Hide trending topics`)}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
shape="round"
|
|
||||||
onPress={() => trendingPrompt.open()}>
|
|
||||||
<ButtonIcon icon={X} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[a.pt_md, a.pb_lg]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.justify_start,
|
|
||||||
a.flex_wrap,
|
|
||||||
{rowGap: 8, columnGap: 6},
|
|
||||||
gutters,
|
|
||||||
]}>
|
|
||||||
{isLoading ? (
|
|
||||||
Array(TRENDING_TOPICS_COUNT)
|
|
||||||
.fill(0)
|
|
||||||
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
|
|
||||||
) : !trending?.topics ? null : (
|
|
||||||
<>
|
|
||||||
{trending.topics.map(topic => (
|
|
||||||
<TrendingTopicLink
|
|
||||||
key={topic.link}
|
|
||||||
topic={topic}
|
|
||||||
onPress={() => {
|
|
||||||
logEvent('trendingTopic:click', {context: 'explore'})
|
|
||||||
}}>
|
|
||||||
{({hovered}) => (
|
|
||||||
<TrendingTopic
|
|
||||||
topic={topic}
|
|
||||||
style={[
|
|
||||||
hovered && [
|
|
||||||
t.atoms.border_contrast_high,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TrendingTopicLink>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Prompt.Basic
|
|
||||||
control={trendingPrompt}
|
|
||||||
title={_(msg`Hide trending topics?`)}
|
|
||||||
description={_(msg`You can update this later from your settings.`)}
|
|
||||||
confirmButtonCta={_(msg`Hide`)}
|
|
||||||
onConfirm={onConfirmHide}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||||
|
|
||||||
|
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||||
|
import {makeCustomFeedLink} from '#/lib/routes/links'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {
|
||||||
|
atoms as a,
|
||||||
|
native,
|
||||||
|
useGutters,
|
||||||
|
useTheme,
|
||||||
|
type ViewStyleProp,
|
||||||
|
web,
|
||||||
|
} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
import {sizes as iconSizes} from '#/components/icons/common'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {Text, type TextProps} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Container({
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
headerHeight,
|
||||||
|
}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
const gutters = useGutters([0, 'base'])
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
gutters,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.pt_2xl,
|
||||||
|
a.pb_md,
|
||||||
|
a.gap_sm,
|
||||||
|
t.atoms.bg,
|
||||||
|
headerHeight && web({position: 'sticky', top: headerHeight}),
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedLink({
|
||||||
|
feed,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {host: did, rkey} = useMemo(() => new AtUri(feed.uri), [feed.uri])
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={makeCustomFeedLink(did, rkey)}
|
||||||
|
label={feed.displayName}
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
{({focused, hovered, pressed}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
{gap: 10},
|
||||||
|
a.rounded_md,
|
||||||
|
a.p_xs,
|
||||||
|
{marginLeft: -6},
|
||||||
|
(focused || hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
|
||||||
|
return <UserAvatar type="algo" size={38} avatar={feed.avatar} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({
|
||||||
|
icon: Comp,
|
||||||
|
size = 'lg',
|
||||||
|
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
|
||||||
|
const iconSize = iconSizes[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
|
||||||
|
<Comp width={iconSize} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitleText({style, ...props}: TextProps) {
|
||||||
|
return (
|
||||||
|
<Text style={[a.font_bold, a.flex_1, a.text_xl, style]} emoji {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubtitleText({style, ...props}: TextProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.leading_tight,
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchButton({
|
||||||
|
label,
|
||||||
|
metricsTag,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
metricsTag: 'suggestedAccounts' | 'suggestedFeeds'
|
||||||
|
onPress?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
PressableComponent={native(PressableScale)}
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric(
|
||||||
|
'explore:module:searchButtonPress',
|
||||||
|
{module: metricsTag},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
|
onPress?.()
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
right: -4,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<ButtonIcon icon={SearchIcon} size="lg" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
|
||||||
|
return (
|
||||||
|
<View style={[a.z_20, {marginRight: -6}]}>
|
||||||
|
<FeedCard.SaveButton
|
||||||
|
pin
|
||||||
|
view={feed}
|
||||||
|
size="large"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
shape="square"
|
||||||
|
text={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {createHitslop, HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
import {Link} from '#/view/com/util/Link'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
|
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function SearchHistory({
|
||||||
|
searchHistory,
|
||||||
|
selectedProfiles,
|
||||||
|
onItemClick,
|
||||||
|
onProfileClick,
|
||||||
|
onRemoveItemClick,
|
||||||
|
onRemoveProfileClick,
|
||||||
|
}: {
|
||||||
|
searchHistory: string[]
|
||||||
|
selectedProfiles: bsky.profile.AnyProfileView[]
|
||||||
|
onItemClick: (item: string) => void
|
||||||
|
onProfileClick: (profile: bsky.profile.AnyProfileView) => void
|
||||||
|
onRemoveItemClick: (item: string) => void
|
||||||
|
onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void
|
||||||
|
}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.Content
|
||||||
|
keyboardDismissMode="interactive"
|
||||||
|
keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={[a.w_full, a.px_md]}>
|
||||||
|
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
|
||||||
|
<Text style={[a.text_md, a.font_bold, a.p_md]}>
|
||||||
|
<Trans>Recent Searches</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{selectedProfiles.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.selectedProfilesContainer,
|
||||||
|
!gtMobile && styles.selectedProfilesContainerMobile,
|
||||||
|
]}>
|
||||||
|
<BlockDrawerGesture>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.flex_nowrap,
|
||||||
|
{marginHorizontal: tokens.space._2xl * -1},
|
||||||
|
]}
|
||||||
|
contentContainerStyle={[a.px_2xl, a.border_0]}>
|
||||||
|
{selectedProfiles.slice(0, 5).map((profile, index) => (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.profileItem,
|
||||||
|
!gtMobile && styles.profileItemMobile,
|
||||||
|
]}>
|
||||||
|
<Link
|
||||||
|
href={makeProfileLink(profile)}
|
||||||
|
title={profile.handle}
|
||||||
|
asAnchor
|
||||||
|
anchorNoUnderline
|
||||||
|
onBeforePress={() => onProfileClick(profile)}
|
||||||
|
style={[a.align_center, a.w_full]}>
|
||||||
|
<UserAvatar
|
||||||
|
avatar={profile.avatar}
|
||||||
|
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
size={60}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[a.text_xs, a.text_center, styles.profileName]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
profile.displayName || profile.handle,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Remove profile`)}
|
||||||
|
accessibilityHint={_(
|
||||||
|
msg`Removes profile from search history`,
|
||||||
|
)}
|
||||||
|
onPress={() => onRemoveProfileClick(profile)}
|
||||||
|
hitSlop={createHitslop(6)}
|
||||||
|
style={styles.profileRemoveBtn}>
|
||||||
|
<XIcon size="xs" style={t.atoms.text_contrast_low} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</BlockDrawerGesture>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{searchHistory.length > 0 && (
|
||||||
|
<View style={[a.pl_md, a.pr_xs, a.mt_md]}>
|
||||||
|
{searchHistory.slice(0, 5).map((historyItem, index) => (
|
||||||
|
<View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={() => onItemClick(historyItem)}
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
style={[a.flex_1, a.py_md]}>
|
||||||
|
<Text style={[a.text_md]}>{historyItem}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Remove ${historyItem}`)}
|
||||||
|
onPress={() => onRemoveItemClick(historyItem)}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round">
|
||||||
|
<ButtonIcon icon={XIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Layout.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
selectedProfilesContainer: {
|
||||||
|
marginTop: 10,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 80,
|
||||||
|
},
|
||||||
|
selectedProfilesContainerMobile: {
|
||||||
|
height: 100,
|
||||||
|
},
|
||||||
|
profileItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 15,
|
||||||
|
width: 78,
|
||||||
|
},
|
||||||
|
profileItemMobile: {
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
profileName: {
|
||||||
|
width: 78,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
profileRemoveBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 5,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 10,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {languageName} from '#/locale/helpers'
|
||||||
|
import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
import {atoms as a, native, platform, tokens} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {
|
||||||
|
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||||
|
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||||
|
} from '#/components/icons/Chevron'
|
||||||
|
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
|
|
||||||
|
export function SearchLanguageDropdown({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange(value: string): void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {appLanguage, contentLanguages} = useLanguagePrefs()
|
||||||
|
|
||||||
|
const languages = useMemo(() => {
|
||||||
|
return LANGUAGES.filter(
|
||||||
|
(lang, index, self) =>
|
||||||
|
Boolean(lang.code2) && // reduce to the code2 varieties
|
||||||
|
index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen)
|
||||||
|
)
|
||||||
|
.map(l => ({
|
||||||
|
label: languageName(l, appLanguage),
|
||||||
|
value: l.code2,
|
||||||
|
key: l.code2 + l.code3,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// prioritize user's languages
|
||||||
|
const aIsUser = contentLanguages.includes(a.value)
|
||||||
|
const bIsUser = contentLanguages.includes(b.value)
|
||||||
|
if (aIsUser && !bIsUser) return -1
|
||||||
|
if (bIsUser && !aIsUser) return 1
|
||||||
|
// prioritize "common" langs in the network
|
||||||
|
const aIsCommon = !!APP_LANGUAGES.find(
|
||||||
|
al =>
|
||||||
|
// skip `ast`, because it uses a 3-letter code which conflicts with `as`
|
||||||
|
// it begins with `a` anyway so still is top of the list
|
||||||
|
al.code2 !== 'ast' && al.code2.startsWith(a.value),
|
||||||
|
)
|
||||||
|
const bIsCommon = !!APP_LANGUAGES.find(
|
||||||
|
al =>
|
||||||
|
// ditto
|
||||||
|
al.code2 !== 'ast' && al.code2.startsWith(b.value),
|
||||||
|
)
|
||||||
|
if (aIsCommon && !bIsCommon) return -1
|
||||||
|
if (bIsCommon && !aIsCommon) return 1
|
||||||
|
// fall back to alphabetical
|
||||||
|
return a.label.localeCompare(b.label)
|
||||||
|
})
|
||||||
|
}, [appLanguage, contentLanguages])
|
||||||
|
|
||||||
|
const currentLanguageLabel =
|
||||||
|
languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
label={_(
|
||||||
|
msg`Filter search by language (currently: ${currentLanguageLabel})`,
|
||||||
|
)}>
|
||||||
|
{({props}) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size="small"
|
||||||
|
color={platform({native: 'primary', default: 'secondary'})}
|
||||||
|
variant={platform({native: 'ghost', default: 'solid'})}
|
||||||
|
style={native([
|
||||||
|
a.py_sm,
|
||||||
|
a.px_sm,
|
||||||
|
{marginRight: tokens.space.sm * -1},
|
||||||
|
])}>
|
||||||
|
<ButtonIcon icon={EarthIcon} />
|
||||||
|
<ButtonText>{currentLanguageLabel}</ButtonText>
|
||||||
|
<ButtonIcon
|
||||||
|
icon={platform({
|
||||||
|
native: ChevronUpDownIcon,
|
||||||
|
default: ChevronDownIcon,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer>
|
||||||
|
<Menu.LabelText>
|
||||||
|
<Trans>Filter search by language</Trans>
|
||||||
|
</Menu.LabelText>
|
||||||
|
<Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>All languages</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={value === ''} />
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Group>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<Menu.Item
|
||||||
|
key={lang.key}
|
||||||
|
label={lang.label}
|
||||||
|
onPress={() => onChange(lang.value)}>
|
||||||
|
<Menu.ItemText>{lang.label}</Menu.ItemText>
|
||||||
|
<Menu.ItemRadio selected={value === lang.value} />
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu.Group>
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {
|
||||||
|
type AppBskyGraphDefs,
|
||||||
|
AppBskyGraphStarterpack,
|
||||||
|
moderateProfile,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
|
import {ButtonText} from '#/components/Button'
|
||||||
|
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function StarterPackCard({
|
||||||
|
view,
|
||||||
|
}: {
|
||||||
|
view: AppBskyGraphDefs.StarterPackView
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {gtPhone} = useBreakpoints()
|
||||||
|
const link = useStarterPackLink({view})
|
||||||
|
|
||||||
|
if (
|
||||||
|
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
|
||||||
|
view.record,
|
||||||
|
AppBskyGraphStarterpack.isRecord,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileCount = gtPhone ? 11 : 8
|
||||||
|
const profiles = view.listItemsSample
|
||||||
|
?.slice(0, profileCount)
|
||||||
|
.map(item => item.subject)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_lg,
|
||||||
|
a.gap_md,
|
||||||
|
a.border,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}>
|
||||||
|
<Link
|
||||||
|
to={link.to}
|
||||||
|
label={link.label}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
onHoverIn={link.precache}
|
||||||
|
onPress={link.precache}>
|
||||||
|
<View />
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AvatarStack
|
||||||
|
profiles={profiles ?? []}
|
||||||
|
numPending={profileCount}
|
||||||
|
total={view.list?.listItemCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_start,
|
||||||
|
a.gap_lg,
|
||||||
|
web({
|
||||||
|
position: 'static',
|
||||||
|
zIndex: 'unset',
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{view.record.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{view.creator?.did === currentAccount?.did
|
||||||
|
? _(msg`By you`)
|
||||||
|
: _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link
|
||||||
|
to={link.to}
|
||||||
|
label={link.label}
|
||||||
|
onHoverIn={link.precache}
|
||||||
|
onPress={link.precache}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
style={[a.z_50]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Open pack</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AvatarStack({
|
||||||
|
profiles,
|
||||||
|
numPending,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
|
numPending: number
|
||||||
|
total?: number
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtPhone} = useBreakpoints()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const computedTotal = (total ?? numPending) - numPending
|
||||||
|
const circlesCount = numPending + 1 // add total at end
|
||||||
|
const widthPerc = 100 / circlesCount
|
||||||
|
const [size, setSize] = React.useState<number | null>(null)
|
||||||
|
|
||||||
|
const isPending = (numPending && profiles.length === 0) || !moderationOpts
|
||||||
|
|
||||||
|
const items = isPending
|
||||||
|
? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({
|
||||||
|
key: i,
|
||||||
|
profile: null,
|
||||||
|
moderation: null,
|
||||||
|
}))
|
||||||
|
: profiles.map(item => ({
|
||||||
|
key: item.did,
|
||||||
|
profile: item,
|
||||||
|
moderation: moderateProfile(item, moderationOpts),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.relative,
|
||||||
|
{width: `${100 - widthPerc * 0.2}%`},
|
||||||
|
]}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<View
|
||||||
|
key={item.key}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: `${widthPerc}%`,
|
||||||
|
zIndex: 100 - i,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
{
|
||||||
|
width: '120%',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
onLayout={e => setSize(e.nativeEvent.layout.width)}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
paddingTop: '100%',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{size && item.profile ? (
|
||||||
|
<UserAvatar
|
||||||
|
size={size}
|
||||||
|
avatar={item.profile.avatar}
|
||||||
|
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
moderation={item.moderation.ui('avatar')}
|
||||||
|
style={[a.absolute, a.inset_0]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MediaInsetBorder style={[a.rounded_full]} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: `${widthPerc}%`,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
{
|
||||||
|
width: '120%',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingTop: '100%',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.rounded_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
backgroundColor: t.atoms.text_contrast_low.color,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{computedTotal > 0 ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
gtPhone ? a.text_md : a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
{color: 'white'},
|
||||||
|
]}>
|
||||||
|
<Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12">
|
||||||
|
+{computedTotal}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Plus fill="white" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarterPackCardSkeleton() {
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtPhone} = useBreakpoints()
|
||||||
|
|
||||||
|
const profileCount = gtPhone ? 11 : 8
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.p_lg,
|
||||||
|
a.gap_md,
|
||||||
|
a.border,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.overflow_hidden,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<AvatarStack profiles={[]} numPending={profileCount} />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_start,
|
||||||
|
a.gap_lg,
|
||||||
|
web({
|
||||||
|
position: 'static',
|
||||||
|
zIndex: 'unset',
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<LoadingPlaceholder width={180} height={18} />
|
||||||
|
<LoadingPlaceholder width={120} height={14} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<LoadingPlaceholder width={100} height={33} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
type NativeStackScreenProps,
|
||||||
|
type SearchTabNavigatorParams,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
|
import {SearchScreenShell} from './Shell'
|
||||||
|
|
||||||
|
export function SearchScreen(
|
||||||
|
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||||
|
) {
|
||||||
|
const queryParam = props.route?.params?.q ?? ''
|
||||||
|
|
||||||
|
return <SearchScreenShell queryParam={queryParam} testID="searchScreen" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {useMemo} from 'react'
|
||||||
|
import {type AppBskyFeedDefs, moderatePost} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {CustomFeedAPI} from '#/lib/api/feed/custom'
|
||||||
|
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||||
|
import {FeedTuner} from '#/lib/api/feed-manip'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {
|
||||||
|
type FeedPostSlice,
|
||||||
|
type FeedPostSliceItem,
|
||||||
|
} from '#/state/queries/post-feed'
|
||||||
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
|
const RQKEY_ROOT = 'feed-previews'
|
||||||
|
const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
|
||||||
|
|
||||||
|
const LIMIT = 8 // sliced to 6, overfetch to account for moderation
|
||||||
|
|
||||||
|
export type FeedPreviewItem =
|
||||||
|
| {
|
||||||
|
type: 'topBorder'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:loading'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:error'
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:loadMoreError'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:empty'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:header'
|
||||||
|
key: string
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:footer'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
// copied from PostFeed.tsx
|
||||||
|
| {
|
||||||
|
type: 'preview:sliceItem'
|
||||||
|
key: string
|
||||||
|
slice: FeedPostSlice
|
||||||
|
indexInSlice: number
|
||||||
|
showReplyTo: boolean
|
||||||
|
hideTopBorder: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'preview:sliceViewFullThread'
|
||||||
|
key: string
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) {
|
||||||
|
const uris = feeds.map(feed => feed.uri)
|
||||||
|
const {_} = useLingui()
|
||||||
|
const agent = useAgent()
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const userInterests = aggregateUserInterests(preferences)
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const enabled = feeds.length > 0
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
enabled,
|
||||||
|
queryKey: RQKEY(uris),
|
||||||
|
queryFn: async ({pageParam}) => {
|
||||||
|
const feed = feeds[pageParam]
|
||||||
|
const api = new CustomFeedAPI({
|
||||||
|
agent,
|
||||||
|
feedParams: {feed: feed.uri},
|
||||||
|
userInterests,
|
||||||
|
})
|
||||||
|
const data = await api.fetch({cursor: undefined, limit: LIMIT})
|
||||||
|
return {
|
||||||
|
feed,
|
||||||
|
posts: data.feed,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (_p, _a, count) =>
|
||||||
|
count < feeds.length ? count + 1 : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {data, isFetched, isError, isPending, error} = query
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
data: useMemo<FeedPreviewItem[]>(() => {
|
||||||
|
const items: FeedPreviewItem[] = []
|
||||||
|
|
||||||
|
if (!enabled) return items
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
!isPending && !data?.pages?.some(page => page.posts.length)
|
||||||
|
|
||||||
|
if (isFetched) {
|
||||||
|
if (isError && isEmpty) {
|
||||||
|
items.push({
|
||||||
|
type: 'preview:error',
|
||||||
|
key: 'error',
|
||||||
|
message: _(msg`An error occurred while fetching the feed.`),
|
||||||
|
error: cleanError(error),
|
||||||
|
})
|
||||||
|
} else if (isEmpty) {
|
||||||
|
items.push({
|
||||||
|
type: 'preview:empty',
|
||||||
|
key: 'empty',
|
||||||
|
})
|
||||||
|
} else if (data) {
|
||||||
|
for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) {
|
||||||
|
const page = data.pages[pageIndex]
|
||||||
|
// default feed tuner - we just want it to slice up the feed
|
||||||
|
const tuner = new FeedTuner([])
|
||||||
|
const slices: FeedPreviewItem[] = []
|
||||||
|
|
||||||
|
let rowIndex = 0
|
||||||
|
for (const item of tuner.tune(page.posts)) {
|
||||||
|
if (item.isFallbackMarker) continue
|
||||||
|
|
||||||
|
const moderations = item.items.map(item =>
|
||||||
|
moderatePost(item.post, moderationOpts!),
|
||||||
|
)
|
||||||
|
|
||||||
|
// apply moderation filters
|
||||||
|
item.items = item.items.filter((_, i) => {
|
||||||
|
return !moderations[i]?.ui('contentList').filter
|
||||||
|
})
|
||||||
|
|
||||||
|
const slice = {
|
||||||
|
_reactKey: item._reactKey,
|
||||||
|
_isFeedPostSlice: true,
|
||||||
|
isFallbackMarker: false,
|
||||||
|
isIncompleteThread: item.isIncompleteThread,
|
||||||
|
feedContext: item.feedContext,
|
||||||
|
reason: item.reason,
|
||||||
|
feedPostUri: item.feedPostUri,
|
||||||
|
items: item.items.slice(0, 6).map((subItem, i) => {
|
||||||
|
const feedPostSliceItem: FeedPostSliceItem = {
|
||||||
|
_reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
|
||||||
|
uri: subItem.post.uri,
|
||||||
|
post: subItem.post,
|
||||||
|
record: subItem.record,
|
||||||
|
moderation: moderations[i],
|
||||||
|
parentAuthor: subItem.parentAuthor,
|
||||||
|
isParentBlocked: subItem.isParentBlocked,
|
||||||
|
isParentNotFound: subItem.isParentNotFound,
|
||||||
|
}
|
||||||
|
return feedPostSliceItem
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
if (slice.isIncompleteThread && slice.items.length >= 3) {
|
||||||
|
const beforeLast = slice.items.length - 2
|
||||||
|
const last = slice.items.length - 1
|
||||||
|
slices.push({
|
||||||
|
type: 'preview:sliceItem',
|
||||||
|
key: slice.items[0]._reactKey,
|
||||||
|
slice: slice,
|
||||||
|
indexInSlice: 0,
|
||||||
|
showReplyTo: false,
|
||||||
|
hideTopBorder: rowIndex === 0,
|
||||||
|
})
|
||||||
|
slices.push({
|
||||||
|
type: 'preview:sliceViewFullThread',
|
||||||
|
key: slice._reactKey + '-viewFullThread',
|
||||||
|
uri: slice.items[0].uri,
|
||||||
|
})
|
||||||
|
slices.push({
|
||||||
|
type: 'preview:sliceItem',
|
||||||
|
key: slice.items[beforeLast]._reactKey,
|
||||||
|
slice: slice,
|
||||||
|
indexInSlice: beforeLast,
|
||||||
|
showReplyTo:
|
||||||
|
slice.items[beforeLast].parentAuthor?.did !==
|
||||||
|
slice.items[beforeLast].post.author.did,
|
||||||
|
hideTopBorder: false,
|
||||||
|
})
|
||||||
|
slices.push({
|
||||||
|
type: 'preview:sliceItem',
|
||||||
|
key: slice.items[last]._reactKey,
|
||||||
|
slice: slice,
|
||||||
|
indexInSlice: last,
|
||||||
|
showReplyTo: false,
|
||||||
|
hideTopBorder: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < slice.items.length; i++) {
|
||||||
|
slices.push({
|
||||||
|
type: 'preview:sliceItem',
|
||||||
|
key: slice.items[i]._reactKey,
|
||||||
|
slice: slice,
|
||||||
|
indexInSlice: i,
|
||||||
|
showReplyTo: i === 0,
|
||||||
|
hideTopBorder: i === 0 && rowIndex === 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slices.length > 0) {
|
||||||
|
if (pageIndex > 0) {
|
||||||
|
items.push({
|
||||||
|
type: 'topBorder',
|
||||||
|
key: `topBorder-${page.feed.uri}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
type: 'preview:footer',
|
||||||
|
key: `footer-${page.feed.uri}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'preview:header',
|
||||||
|
key: `header-${page.feed.uri}`,
|
||||||
|
feed: page.feed,
|
||||||
|
},
|
||||||
|
...slices,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isError && !isEmpty) {
|
||||||
|
items.push({
|
||||||
|
type: 'preview:loadMoreError',
|
||||||
|
key: 'loadMoreError',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
type: 'preview:loading',
|
||||||
|
key: 'loading',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
data,
|
||||||
|
isFetched,
|
||||||
|
isError,
|
||||||
|
isPending,
|
||||||
|
moderationOpts,
|
||||||
|
_,
|
||||||
|
error,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-3
@@ -1,8 +1,8 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyUnspeccedDefs} from '@atproto/api'
|
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {
|
import {
|
||||||
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
|
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
} from '#/components/TrendingTopics'
|
} from '#/components/TrendingTopics'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
// Note: This module is not currently used and may be removed in the future.
|
||||||
|
|
||||||
export function ExploreRecommendations() {
|
export function ExploreRecommendations() {
|
||||||
const {enabled} = useTrendingConfig()
|
const {enabled} = useTrendingConfig()
|
||||||
return enabled ? <Inner /> : null
|
return enabled ? <Inner /> : null
|
||||||
@@ -86,7 +88,11 @@ function Inner() {
|
|||||||
key={topic.link}
|
key={topic.link}
|
||||||
topic={topic}
|
topic={topic}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
logEvent('recommendedTopic:click', {context: 'explore'})
|
logger.metric(
|
||||||
|
'recommendedTopic:click',
|
||||||
|
{context: 'explore'},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
}}>
|
}}>
|
||||||
{({hovered}) => (
|
{({hovered}) => (
|
||||||
<TrendingTopic
|
<TrendingTopic
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import {memo, useEffect} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {type AppBskyActorSearchActors, type ModerationOpts} from '@atproto/api'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {type InfiniteData} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
|
import {
|
||||||
|
popularInterests,
|
||||||
|
useInterestsDisplayNames,
|
||||||
|
} from '#/screens/Onboarding/state'
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function useLoadEnoughProfiles({
|
||||||
|
interest,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
}: {
|
||||||
|
interest: string | null
|
||||||
|
data?: InfiniteData<AppBskyActorSearchActors.OutputSchema>
|
||||||
|
isLoading: boolean
|
||||||
|
isFetchingNextPage: boolean
|
||||||
|
hasNextPage: boolean
|
||||||
|
fetchNextPage: () => Promise<unknown>
|
||||||
|
}) {
|
||||||
|
const profileCount =
|
||||||
|
data?.pages.flatMap(page =>
|
||||||
|
page.actors.filter(actor => !actor.viewer?.following),
|
||||||
|
).length || 0
|
||||||
|
const isAnyLoading = isLoading || isFetchingNextPage
|
||||||
|
const isEnoughProfiles = profileCount > 3
|
||||||
|
const shouldFetchMore = !isEnoughProfiles && hasNextPage && !!interest
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldFetchMore && !isAnyLoading) {
|
||||||
|
logger.info('Not enough suggested accounts - fetching more')
|
||||||
|
fetchNextPage()
|
||||||
|
}
|
||||||
|
}, [shouldFetchMore, fetchNextPage, isAnyLoading, interest])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isReady: !shouldFetchMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestedAccountsTabBar({
|
||||||
|
selectedInterest,
|
||||||
|
onSelectInterest,
|
||||||
|
}: {
|
||||||
|
selectedInterest: string | null
|
||||||
|
onSelectInterest: (interest: string | null) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const personalizedInterests = preferences?.interests?.tags
|
||||||
|
const interests = Object.keys(interestsDisplayNames)
|
||||||
|
.sort(boostInterests(popularInterests))
|
||||||
|
.sort(boostInterests(personalizedInterests))
|
||||||
|
return (
|
||||||
|
<BlockDrawerGesture>
|
||||||
|
<Tabs
|
||||||
|
interests={['all', ...interests]}
|
||||||
|
selectedInterest={selectedInterest || 'all'}
|
||||||
|
onSelectTab={tab => {
|
||||||
|
logger.metric(
|
||||||
|
'explore:suggestedAccounts:tabPressed',
|
||||||
|
{tab: tab},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
|
onSelectInterest(tab === 'all' ? null : tab)
|
||||||
|
}}
|
||||||
|
hasSearchText={false}
|
||||||
|
interestsDisplayNames={{
|
||||||
|
all: _(msg`All`),
|
||||||
|
...interestsDisplayNames,
|
||||||
|
}}
|
||||||
|
TabComponent={Tab}
|
||||||
|
/>
|
||||||
|
</BlockDrawerGesture>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Tab = ({
|
||||||
|
onSelectTab,
|
||||||
|
interest,
|
||||||
|
active,
|
||||||
|
index,
|
||||||
|
interestsDisplayName,
|
||||||
|
onLayout,
|
||||||
|
}: {
|
||||||
|
onSelectTab: (index: number) => void
|
||||||
|
interest: string
|
||||||
|
active: boolean
|
||||||
|
index: number
|
||||||
|
interestsDisplayName: string
|
||||||
|
onLayout: (index: number, x: number, width: number) => void
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const activeText = active ? _(msg` (active)`) : ''
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={interest}
|
||||||
|
onLayout={e =>
|
||||||
|
onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
|
||||||
|
}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
|
||||||
|
onPress={() => onSelectTab(index)}>
|
||||||
|
{({hovered, pressed, focused}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.px_lg,
|
||||||
|
a.py_sm,
|
||||||
|
a.border,
|
||||||
|
active || hovered || pressed || focused
|
||||||
|
? [
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
|
||||||
|
]
|
||||||
|
: [t.atoms.bg, t.atoms.border_contrast_low],
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
/* TODO: medium weight */
|
||||||
|
active || hovered || pressed || focused
|
||||||
|
? t.atoms.text
|
||||||
|
: t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{interestsDisplayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Tab = memo(Tab)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile card for suggested accounts. Note: border is on the bottom edge
|
||||||
|
*/
|
||||||
|
let SuggestedProfileCard = ({
|
||||||
|
profile,
|
||||||
|
moderationOpts,
|
||||||
|
recId,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
profile: bsky.profile.AnyProfileView
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
recId?: number
|
||||||
|
position: number
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<ProfileCard.Link
|
||||||
|
profile={profile}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric(
|
||||||
|
'suggestedUser:press',
|
||||||
|
{
|
||||||
|
logContext: 'Explore',
|
||||||
|
recId,
|
||||||
|
position,
|
||||||
|
},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
|
}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.w_full,
|
||||||
|
a.py_lg,
|
||||||
|
a.px_lg,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.flex_1,
|
||||||
|
]}>
|
||||||
|
<ProfileCard.Outer>
|
||||||
|
<ProfileCard.Header>
|
||||||
|
<ProfileCard.Avatar
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.NameAndHandle
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
|
<ProfileCard.FollowButton
|
||||||
|
profile={profile}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
withIcon={false}
|
||||||
|
logContext="ExploreSuggestedAccounts"
|
||||||
|
onFollow={() => {
|
||||||
|
logger.metric(
|
||||||
|
'suggestedUser:follow',
|
||||||
|
{
|
||||||
|
logContext: 'Explore',
|
||||||
|
location: 'Card',
|
||||||
|
recId,
|
||||||
|
position,
|
||||||
|
},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProfileCard.Header>
|
||||||
|
<ProfileCard.Description profile={profile} numberOfLines={2} />
|
||||||
|
</ProfileCard.Outer>
|
||||||
|
</View>
|
||||||
|
</ProfileCard.Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SuggestedProfileCard = memo(SuggestedProfileCard)
|
||||||
|
export {SuggestedProfileCard}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||||
|
import {msg, plural, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||||
|
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
|
||||||
|
import {useTrendingConfig} from '#/state/trending-config'
|
||||||
|
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
|
import {formatCount} from '#/view/com/util/numeric/format'
|
||||||
|
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
|
||||||
|
import {AvatarStack} from '#/components/AvatarStack'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
|
||||||
|
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const TOPIC_COUNT = 5
|
||||||
|
|
||||||
|
export function ExploreTrendingTopics() {
|
||||||
|
const {enabled} = useTrendingConfig()
|
||||||
|
const {trendingDisabled} = useTrendingSettings()
|
||||||
|
return enabled && !trendingDisabled ? <Inner /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner() {
|
||||||
|
const {data: trending, error, isLoading} = useGetTrendsQuery()
|
||||||
|
const noTopics = !isLoading && !error && !trending?.trends?.length
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
Array.from({length: TOPIC_COUNT}).map((__, i) => (
|
||||||
|
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
|
||||||
|
))
|
||||||
|
) : error || !trending?.trends || noTopics ? null : (
|
||||||
|
<>
|
||||||
|
{trending.trends.map((trend, index) => (
|
||||||
|
<TrendRow
|
||||||
|
key={trend.link}
|
||||||
|
trend={trend}
|
||||||
|
rank={index + 1}
|
||||||
|
onPress={() => {
|
||||||
|
logger.metric('trendingTopic:click', {context: 'explore'})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendRow({
|
||||||
|
trend,
|
||||||
|
rank,
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
}: ViewStyleProp & {
|
||||||
|
trend: AppBskyUnspeccedDefs.TrendView
|
||||||
|
rank: number
|
||||||
|
children?: React.ReactNode
|
||||||
|
onPress?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const gutters = useGutters([0, 'base'])
|
||||||
|
|
||||||
|
const category = useCategoryDisplayName(trend?.category || 'other')
|
||||||
|
const age = Math.floor(
|
||||||
|
(Date.now() - new Date(trend.startedAt || Date.now()).getTime()) /
|
||||||
|
(1000 * 60 * 60),
|
||||||
|
)
|
||||||
|
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
|
||||||
|
const postCount = trend.postCount
|
||||||
|
? _(
|
||||||
|
plural(trend.postCount, {
|
||||||
|
other: `${formatCount(i18n, trend.postCount)} posts`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
testID={trend.link}
|
||||||
|
label={_(msg`Browse topic ${trend.displayName}`)}
|
||||||
|
to={trend.link}
|
||||||
|
onPress={onPress}
|
||||||
|
style={[a.border_b, t.atoms.border_contrast_low]}
|
||||||
|
PressableComponent={Pressable}>
|
||||||
|
{({hovered, pressed}) => (
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
gutters,
|
||||||
|
a.w_full,
|
||||||
|
a.py_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_2xs,
|
||||||
|
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}>
|
||||||
|
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
|
||||||
|
{rank}.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{trend.displayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
a.align_center,
|
||||||
|
{paddingLeft: 20},
|
||||||
|
]}>
|
||||||
|
{trend.actors.length > 0 && (
|
||||||
|
<AvatarStack size={20} profiles={trend.actors} />
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
web(a.leading_snug),
|
||||||
|
]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{postCount}
|
||||||
|
{postCount && category && <> · </>}
|
||||||
|
{category}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_shrink_0]}>
|
||||||
|
<TrendingIndicator type={badgeType} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrendingIndicatorType = 'hot' | 'new' | number
|
||||||
|
|
||||||
|
function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const pillStyles = [
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_xs,
|
||||||
|
a.rounded_full,
|
||||||
|
a.px_sm,
|
||||||
|
{
|
||||||
|
height: 28,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let Icon: React.ComponentType<SVGIconProps> | null = null
|
||||||
|
let text: string | null = null
|
||||||
|
let color: string | null = null
|
||||||
|
let backgroundColor: string | null = null
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'skeleton': {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
pillStyles,
|
||||||
|
{backgroundColor: t.palette.contrast_25, width: 65, height: 28},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'hot': {
|
||||||
|
Icon = FlameIcon
|
||||||
|
color =
|
||||||
|
t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950
|
||||||
|
backgroundColor =
|
||||||
|
t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200
|
||||||
|
text = _(msg`Hot`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'new': {
|
||||||
|
Icon = TrendingIcon
|
||||||
|
text = _(msg`New`)
|
||||||
|
color = t.palette.positive_700
|
||||||
|
backgroundColor = t.palette.positive_50
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
text = _(
|
||||||
|
msg({
|
||||||
|
message: `${type}h ago`,
|
||||||
|
comment:
|
||||||
|
'trending topic time spent trending. should be as short as possible to fit in a pill',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
color = t.atoms.text_contrast_medium.color
|
||||||
|
backgroundColor = t.atoms.bg_contrast_25.backgroundColor
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[pillStyles, {backgroundColor}]}>
|
||||||
|
{Icon && <Icon size="sm" style={{color}} />}
|
||||||
|
<Text style={[a.text_sm, {color}]}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCategoryDisplayName(
|
||||||
|
category: AppBskyUnspeccedDefs.TrendView['category'],
|
||||||
|
) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case 'sports':
|
||||||
|
return _(msg`Sports`)
|
||||||
|
case 'politics':
|
||||||
|
return _(msg`Politics`)
|
||||||
|
case 'video-games':
|
||||||
|
return _(msg`Video Games`)
|
||||||
|
case 'pop-culture':
|
||||||
|
return _(msg`Entertainment`)
|
||||||
|
case 'news':
|
||||||
|
return _(msg`News`)
|
||||||
|
case 'other':
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const gutters = useGutters([0, 'base'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
gutters,
|
||||||
|
a.w_full,
|
||||||
|
a.py_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_2xs,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_sm]}>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<View style={[{width: 20}]}>
|
||||||
|
<LoadingPlaceholder
|
||||||
|
width={12}
|
||||||
|
height={12}
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<LoadingPlaceholder width={90} height={18} />
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
|
||||||
|
<LoadingPlaceholder width={70} height={18} />
|
||||||
|
<LoadingPlaceholder width={40} height={18} />
|
||||||
|
<LoadingPlaceholder width={60} height={18} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_shrink_0]}>
|
||||||
|
<TrendingIndicator type="skeleton" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
+33
-70
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import {useMemo} from 'react'
|
||||||
import {ScrollView, View} from 'react-native'
|
import {ScrollView, View} from 'react-native'
|
||||||
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
@@ -8,18 +8,12 @@ import {useQueryClient} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {VIDEO_FEED_URI} from '#/lib/constants'
|
import {VIDEO_FEED_URI} from '#/lib/constants'
|
||||||
import {makeCustomFeedLink} from '#/lib/routes/links'
|
import {makeCustomFeedLink} from '#/lib/routes/links'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useSavedFeeds} from '#/state/queries/feed'
|
|
||||||
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
|
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
|
||||||
import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
|
|
||||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {ButtonIcon} from '#/components/Button'
|
||||||
import {GradientFill} from '#/components/GradientFill'
|
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
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 {Link} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +31,6 @@ const FEED_PARAMS: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreTrendingVideos() {
|
export function ExploreTrendingVideos() {
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const gutters = useGutters([0, 'base'])
|
const gutters = useGutters([0, 'base'])
|
||||||
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
|
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
|
||||||
@@ -55,30 +48,30 @@ export function ExploreTrendingVideos() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const {data: saved} = useSavedFeeds()
|
// const {data: saved} = useSavedFeeds()
|
||||||
const isSavedAlready = React.useMemo(() => {
|
// const isSavedAlready = useMemo(() => {
|
||||||
return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
|
// return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
|
||||||
}, [saved])
|
// }, [saved])
|
||||||
|
|
||||||
const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
|
// const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
|
||||||
useAddSavedFeedsMutation()
|
// useAddSavedFeedsMutation()
|
||||||
const pinFeed = React.useCallback(
|
// const pinFeed = useCallback(
|
||||||
(e: any) => {
|
// (e: any) => {
|
||||||
e.preventDefault()
|
// e.preventDefault()
|
||||||
|
|
||||||
addSavedFeeds([
|
// addSavedFeeds([
|
||||||
{
|
// {
|
||||||
type: 'feed',
|
// type: 'feed',
|
||||||
value: VIDEO_FEED_URI,
|
// value: VIDEO_FEED_URI,
|
||||||
pinned: true,
|
// pinned: true,
|
||||||
},
|
// },
|
||||||
])
|
// ])
|
||||||
|
|
||||||
// prevent navigation
|
// // prevent navigation
|
||||||
return false
|
// return false
|
||||||
},
|
// },
|
||||||
[addSavedFeeds],
|
// [addSavedFeeds],
|
||||||
)
|
// )
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return null
|
return null
|
||||||
@@ -86,38 +79,6 @@ export function ExploreTrendingVideos() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.pb_xl]}>
|
<View style={[a.pb_xl]}>
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
isWeb
|
|
||||||
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
|
||||||
: [a.p_lg, a.pt_xl, a.gap_md],
|
|
||||||
a.border_b,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
]}>
|
|
||||||
<View style={[a.flex_1, a.gap_sm]}>
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<Graph
|
|
||||||
size="lg"
|
|
||||||
fill={t.palette.primary_500}
|
|
||||||
style={{marginLeft: -2}}
|
|
||||||
/>
|
|
||||||
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
|
|
||||||
<Trans>Trending Videos</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
|
|
||||||
<GradientFill gradient={tokens.gradients.primary} />
|
|
||||||
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
|
|
||||||
<Trans>BETA</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
|
||||||
<Trans>Popular videos in your network.</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<BlockDrawerGesture>
|
<BlockDrawerGesture>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -153,7 +114,7 @@ export function ExploreTrendingVideos() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</BlockDrawerGesture>
|
</BlockDrawerGesture>
|
||||||
|
|
||||||
{!isSavedAlready && (
|
{/* {!isSavedAlready && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
gutters,
|
gutters,
|
||||||
@@ -179,7 +140,7 @@ export function ExploreTrendingVideos() {
|
|||||||
<ButtonIcon icon={Pin} position="right" />
|
<ButtonIcon icon={Pin} position="right" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)} */}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -191,7 +152,7 @@ function VideoCards({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const items = React.useMemo(() => {
|
const items = useMemo(() => {
|
||||||
return data.pages
|
return data.pages
|
||||||
.flatMap(page => page.slices)
|
.flatMap(page => page.slices)
|
||||||
.map(slice => slice.items[0])
|
.map(slice => slice.items[0])
|
||||||
@@ -199,7 +160,7 @@ function VideoCards({
|
|||||||
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
}, [data])
|
}, [data])
|
||||||
const href = React.useMemo(() => {
|
const href = useMemo(() => {
|
||||||
const urip = new AtUri(VIDEO_FEED_URI)
|
const urip = new AtUri(VIDEO_FEED_URI)
|
||||||
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
|
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
|
||||||
}, [])
|
}, [])
|
||||||
@@ -217,9 +178,11 @@ function VideoCards({
|
|||||||
sourceInterstitial: 'explore',
|
sourceInterstitial: 'explore',
|
||||||
}}
|
}}
|
||||||
onInteract={() => {
|
onInteract={() => {
|
||||||
logEvent('videoCard:click', {
|
logger.metric(
|
||||||
context: 'interstitial:explore',
|
'videoCard:click',
|
||||||
})
|
{context: 'interstitial:explore'},
|
||||||
|
{statsig: true},
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
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 {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home'
|
||||||
import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh'
|
import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh'
|
||||||
import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
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 {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
|
|
||||||
import {
|
import {
|
||||||
InfiniteData,
|
type AppBskyActorDefs,
|
||||||
|
type AppBskyActorSearchActors,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
QueryClient,
|
type QueryClient,
|
||||||
QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
@@ -15,7 +18,11 @@ const RQKEY_ROOT = 'actor-search'
|
|||||||
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
||||||
|
|
||||||
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
|
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({
|
export function useActorSearch({
|
||||||
query,
|
query,
|
||||||
@@ -42,10 +49,12 @@ export function useActorSearchPaginated({
|
|||||||
query,
|
query,
|
||||||
enabled,
|
enabled,
|
||||||
maintainData,
|
maintainData,
|
||||||
|
limit = 25,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
maintainData?: boolean
|
maintainData?: boolean
|
||||||
|
limit?: number
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
@@ -56,11 +65,11 @@ export function useActorSearchPaginated({
|
|||||||
string | undefined
|
string | undefined
|
||||||
>({
|
>({
|
||||||
staleTime: STALE.MINUTES.FIVE,
|
staleTime: STALE.MINUTES.FIVE,
|
||||||
queryKey: RQKEY_PAGINATED(query),
|
queryKey: RQKEY_PAGINATED(query, limit),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const res = await agent.searchActors({
|
const res = await agent.searchActors({
|
||||||
q: query,
|
q: query,
|
||||||
limit: 25,
|
limit,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
|
|||||||
@@ -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
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
AppState,
|
AppState,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
ListRenderItemInfo,
|
type ListRenderItemInfo,
|
||||||
StyleProp,
|
type StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
|
import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
@@ -24,21 +24,21 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
|||||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {
|
import {
|
||||||
AuthorFilter,
|
type AuthorFilter,
|
||||||
FeedDescriptor,
|
type FeedDescriptor,
|
||||||
FeedParams,
|
type FeedParams,
|
||||||
FeedPostSlice,
|
type FeedPostSlice,
|
||||||
FeedPostSliceItem,
|
type FeedPostSliceItem,
|
||||||
pollLatest,
|
pollLatest,
|
||||||
RQKEY,
|
RQKEY,
|
||||||
usePostFeedQuery,
|
usePostFeedQuery,
|
||||||
} from '#/state/queries/post-feed'
|
} from '#/state/queries/post-feed'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
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 {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||||
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
|
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 {useBreakpoints, useLayoutBreakpoints} from '#/alf'
|
||||||
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
|
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
|
||||||
import {
|
import {
|
||||||
@@ -767,14 +767,14 @@ const styles = StyleSheet.create({
|
|||||||
feedFooter: {paddingTop: 20},
|
feedFooter: {paddingTop: 20},
|
||||||
})
|
})
|
||||||
|
|
||||||
function isThreadParentAt<T>(arr: Array<T>, i: number) {
|
export function isThreadParentAt<T>(arr: Array<T>, i: number) {
|
||||||
if (arr.length === 1) {
|
if (arr.length === 1) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return i < arr.length - 1
|
return i < arr.length - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function isThreadChildAt<T>(arr: Array<T>, i: number) {
|
export function isThreadChildAt<T>(arr: Array<T>, i: number) {
|
||||||
if (arr.length === 1) {
|
if (arr.length === 1) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
|
|
||||||
export const formatCount = (i18n: I18n, num: number) => {
|
export const formatCount = (i18n: I18n, num: number) => {
|
||||||
return i18n.number(num, {
|
return i18n.number(num, {
|
||||||
|
|||||||
@@ -1,641 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
import {
|
|
||||||
AppBskyActorDefs,
|
|
||||||
AppBskyFeedDefs,
|
|
||||||
moderateProfile,
|
|
||||||
ModerationDecision,
|
|
||||||
ModerationOpts,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {isNative, isWeb} from '#/platform/detection'
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
||||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
|
||||||
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
|
||||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
|
||||||
import {List} from '#/view/com/util/List'
|
|
||||||
import {
|
|
||||||
FeedFeedLoadingPlaceholder,
|
|
||||||
ProfileCardFeedLoadingPlaceholder,
|
|
||||||
} from '#/view/com/util/LoadingPlaceholder'
|
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
||||||
import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
|
|
||||||
import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
|
|
||||||
import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos'
|
|
||||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
|
||||||
import {Button} from '#/components/Button'
|
|
||||||
import * as FeedCard from '#/components/FeedCard'
|
|
||||||
import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow'
|
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
|
||||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
|
||||||
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
|
|
||||||
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
|
|
||||||
import {Loader} from '#/components/Loader'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
function SuggestedItemsHeader({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
style,
|
|
||||||
icon: Icon,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
icon: React.ComponentType<SVGIconProps>
|
|
||||||
} & ViewStyleProp) {
|
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
isWeb
|
|
||||||
? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
|
||||||
: [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
|
|
||||||
a.border_b,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
style,
|
|
||||||
]}>
|
|
||||||
<View style={[a.flex_1, a.gap_sm]}>
|
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
|
||||||
<Icon
|
|
||||||
size="lg"
|
|
||||||
fill={t.palette.primary_500}
|
|
||||||
style={{marginLeft: -2}}
|
|
||||||
/>
|
|
||||||
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoadMoreItem =
|
|
||||||
| {
|
|
||||||
type: 'profile'
|
|
||||||
key: string
|
|
||||||
avatar: string | undefined
|
|
||||||
moderation: ModerationDecision
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'feed'
|
|
||||||
key: string
|
|
||||||
avatar: string | undefined
|
|
||||||
moderation: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadMore({
|
|
||||||
item,
|
|
||||||
moderationOpts,
|
|
||||||
}: {
|
|
||||||
item: ExploreScreenItems & {type: 'loadMore'}
|
|
||||||
moderationOpts?: ModerationOpts
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const items: LoadMoreItem[] = React.useMemo(() => {
|
|
||||||
return item.items
|
|
||||||
.map(_item => {
|
|
||||||
let loadMoreItem: LoadMoreItem | undefined
|
|
||||||
if (_item.type === 'profile') {
|
|
||||||
loadMoreItem = {
|
|
||||||
type: 'profile',
|
|
||||||
key: _item.profile.did,
|
|
||||||
avatar: _item.profile.avatar,
|
|
||||||
moderation: moderateProfile(_item.profile, moderationOpts!),
|
|
||||||
}
|
|
||||||
} else if (_item.type === 'feed') {
|
|
||||||
loadMoreItem = {
|
|
||||||
type: 'feed',
|
|
||||||
key: _item.feed.uri,
|
|
||||||
avatar: _item.feed.avatar,
|
|
||||||
moderation: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return loadMoreItem
|
|
||||||
})
|
|
||||||
.filter(n => !!n)
|
|
||||||
}, [item.items, moderationOpts])
|
|
||||||
|
|
||||||
if (items.length === 0) return null
|
|
||||||
|
|
||||||
const type = items[0].type
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[]}>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Load more`)}
|
|
||||||
onPress={item.onLoadMore}
|
|
||||||
style={[a.relative, a.w_full]}>
|
|
||||||
{({hovered, pressed}) => (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_1,
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.px_lg,
|
|
||||||
a.py_md,
|
|
||||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.relative,
|
|
||||||
{
|
|
||||||
height: 32,
|
|
||||||
width: 32 + 15 * items.length,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
left: 0,
|
|
||||||
borderWidth: 1,
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
borderColor: t.atoms.bg.backgroundColor,
|
|
||||||
borderRadius: type === 'profile' ? 999 : 4,
|
|
||||||
zIndex: 4,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<ArrowBottom fill={t.palette.white} />
|
|
||||||
</View>
|
|
||||||
{items.map((_item, i) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={_item.key}
|
|
||||||
style={[
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
left: (i + 1) * 15,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: t.atoms.bg.backgroundColor,
|
|
||||||
borderRadius: _item.type === 'profile' ? 999 : 4,
|
|
||||||
zIndex: 3 - i,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
{moderationOpts && (
|
|
||||||
<>
|
|
||||||
{_item.type === 'profile' ? (
|
|
||||||
<UserAvatar
|
|
||||||
size={28}
|
|
||||||
avatar={_item.avatar}
|
|
||||||
moderation={_item.moderation.ui('avatar')}
|
|
||||||
type="user"
|
|
||||||
/>
|
|
||||||
) : _item.type === 'feed' ? (
|
|
||||||
<UserAvatar
|
|
||||||
size={28}
|
|
||||||
avatar={_item.avatar}
|
|
||||||
type="algo"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.pl_sm,
|
|
||||||
a.leading_snug,
|
|
||||||
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
{type === 'profile' ? (
|
|
||||||
<Trans>Load more suggested follows</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Load more suggested feeds</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={[a.flex_1, a.align_end]}>
|
|
||||||
{item.isLoadingMore && <Loader size="lg" />}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExploreScreenItems =
|
|
||||||
| {
|
|
||||||
type: 'header'
|
|
||||||
key: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
style?: ViewStyleProp['style']
|
|
||||||
icon: React.ComponentType<SVGIconProps>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'trendingTopics'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'trendingVideos'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'recommendations'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'profile'
|
|
||||||
key: string
|
|
||||||
profile: AppBskyActorDefs.ProfileView
|
|
||||||
recId?: number
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'feed'
|
|
||||||
key: string
|
|
||||||
feed: AppBskyFeedDefs.GeneratorView
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'loadMore'
|
|
||||||
key: string
|
|
||||||
isLoadingMore: boolean
|
|
||||||
onLoadMore: () => void
|
|
||||||
items: ExploreScreenItems[]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'profilePlaceholder'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'feedPlaceholder'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'error'
|
|
||||||
key: string
|
|
||||||
message: string
|
|
||||||
error: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Explore() {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
const {
|
|
||||||
data: profiles,
|
|
||||||
hasNextPage: hasNextProfilesPage,
|
|
||||||
isLoading: isLoadingProfiles,
|
|
||||||
isFetchingNextPage: isFetchingNextProfilesPage,
|
|
||||||
error: profilesError,
|
|
||||||
fetchNextPage: fetchNextProfilesPage,
|
|
||||||
} = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10})
|
|
||||||
const {
|
|
||||||
data: feeds,
|
|
||||||
hasNextPage: hasNextFeedsPage,
|
|
||||||
isLoading: isLoadingFeeds,
|
|
||||||
isFetchingNextPage: isFetchingNextFeedsPage,
|
|
||||||
error: feedsError,
|
|
||||||
fetchNextPage: fetchNextFeedsPage,
|
|
||||||
} = useGetPopularFeedsQuery({limit: 10})
|
|
||||||
|
|
||||||
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
|
|
||||||
const onLoadMoreProfiles = React.useCallback(async () => {
|
|
||||||
if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError)
|
|
||||||
return
|
|
||||||
try {
|
|
||||||
await fetchNextProfilesPage()
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to load more suggested follows', {message: err})
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isFetchingNextProfilesPage,
|
|
||||||
hasNextProfilesPage,
|
|
||||||
profilesError,
|
|
||||||
fetchNextProfilesPage,
|
|
||||||
])
|
|
||||||
|
|
||||||
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
|
|
||||||
const onLoadMoreFeeds = React.useCallback(async () => {
|
|
||||||
if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return
|
|
||||||
try {
|
|
||||||
await fetchNextFeedsPage()
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to load more suggested follows', {message: err})
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isFetchingNextFeedsPage,
|
|
||||||
hasNextFeedsPage,
|
|
||||||
feedsError,
|
|
||||||
fetchNextFeedsPage,
|
|
||||||
])
|
|
||||||
|
|
||||||
const items = React.useMemo<ExploreScreenItems[]>(() => {
|
|
||||||
const i: ExploreScreenItems[] = []
|
|
||||||
|
|
||||||
i.push({
|
|
||||||
type: 'trendingTopics',
|
|
||||||
key: `trending-topics`,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isNative) {
|
|
||||||
i.push({
|
|
||||||
type: 'trendingVideos',
|
|
||||||
key: `trending-videos`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
i.push({
|
|
||||||
type: 'recommendations',
|
|
||||||
key: `recommendations`,
|
|
||||||
})
|
|
||||||
|
|
||||||
i.push({
|
|
||||||
type: 'header',
|
|
||||||
key: 'suggested-follows-header',
|
|
||||||
title: _(msg`Suggested accounts`),
|
|
||||||
description: _(
|
|
||||||
msg`Follow more accounts to get connected to your interests and build your network.`,
|
|
||||||
),
|
|
||||||
icon: Person,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (profiles) {
|
|
||||||
// Currently the responses contain duplicate items.
|
|
||||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
|
||||||
let seen = new Set()
|
|
||||||
const profileItems: ExploreScreenItems[] = []
|
|
||||||
for (const page of profiles.pages) {
|
|
||||||
for (const actor of page.actors) {
|
|
||||||
if (!seen.has(actor.did)) {
|
|
||||||
seen.add(actor.did)
|
|
||||||
profileItems.push({
|
|
||||||
type: 'profile',
|
|
||||||
key: actor.did,
|
|
||||||
profile: actor,
|
|
||||||
recId: page.recId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNextProfilesPage) {
|
|
||||||
// splice off 3 as previews if we have a next page
|
|
||||||
const previews = profileItems.splice(-3)
|
|
||||||
// push remainder
|
|
||||||
i.push(...profileItems)
|
|
||||||
i.push({
|
|
||||||
type: 'loadMore',
|
|
||||||
key: 'loadMoreProfiles',
|
|
||||||
isLoadingMore: isLoadingMoreProfiles,
|
|
||||||
onLoadMore: onLoadMoreProfiles,
|
|
||||||
items: previews,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
i.push(...profileItems)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (profilesError) {
|
|
||||||
i.push({
|
|
||||||
type: 'error',
|
|
||||||
key: 'profilesError',
|
|
||||||
message: _(msg`Failed to load suggested follows`),
|
|
||||||
error: cleanError(profilesError),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
i.push({
|
|
||||||
type: 'header',
|
|
||||||
key: 'suggested-feeds-header',
|
|
||||||
title: _(msg`Discover new feeds`),
|
|
||||||
description: _(
|
|
||||||
msg`Choose your own timeline! Feeds built by the community help you find content you love.`,
|
|
||||||
),
|
|
||||||
style: [a.pt_5xl],
|
|
||||||
icon: ListSparkle,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (feeds && preferences) {
|
|
||||||
// Currently the responses contain duplicate items.
|
|
||||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
|
||||||
let seen = new Set()
|
|
||||||
const feedItems: ExploreScreenItems[] = []
|
|
||||||
for (const page of feeds.pages) {
|
|
||||||
for (const feed of page.feeds) {
|
|
||||||
if (!seen.has(feed.uri)) {
|
|
||||||
seen.add(feed.uri)
|
|
||||||
feedItems.push({
|
|
||||||
type: 'feed',
|
|
||||||
key: feed.uri,
|
|
||||||
feed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// feeds errors can occur during pagination, so feeds is truthy
|
|
||||||
if (feedsError) {
|
|
||||||
i.push({
|
|
||||||
type: 'error',
|
|
||||||
key: 'feedsError',
|
|
||||||
message: _(msg`Failed to load suggested feeds`),
|
|
||||||
error: cleanError(feedsError),
|
|
||||||
})
|
|
||||||
} else if (preferencesError) {
|
|
||||||
i.push({
|
|
||||||
type: 'error',
|
|
||||||
key: 'preferencesError',
|
|
||||||
message: _(msg`Failed to load feeds preferences`),
|
|
||||||
error: cleanError(preferencesError),
|
|
||||||
})
|
|
||||||
} else if (hasNextFeedsPage) {
|
|
||||||
const preview = feedItems.splice(-3)
|
|
||||||
i.push(...feedItems)
|
|
||||||
i.push({
|
|
||||||
type: 'loadMore',
|
|
||||||
key: 'loadMoreFeeds',
|
|
||||||
isLoadingMore: isLoadingMoreFeeds,
|
|
||||||
onLoadMore: onLoadMoreFeeds,
|
|
||||||
items: preview,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
i.push(...feedItems)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (feedsError) {
|
|
||||||
i.push({
|
|
||||||
type: 'error',
|
|
||||||
key: 'feedsError',
|
|
||||||
message: _(msg`Failed to load suggested feeds`),
|
|
||||||
error: cleanError(feedsError),
|
|
||||||
})
|
|
||||||
} else if (preferencesError) {
|
|
||||||
i.push({
|
|
||||||
type: 'error',
|
|
||||||
key: 'preferencesError',
|
|
||||||
message: _(msg`Failed to load feeds preferences`),
|
|
||||||
error: cleanError(preferencesError),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return i
|
|
||||||
}, [
|
|
||||||
_,
|
|
||||||
profiles,
|
|
||||||
feeds,
|
|
||||||
preferences,
|
|
||||||
onLoadMoreFeeds,
|
|
||||||
onLoadMoreProfiles,
|
|
||||||
isLoadingMoreProfiles,
|
|
||||||
isLoadingMoreFeeds,
|
|
||||||
profilesError,
|
|
||||||
feedsError,
|
|
||||||
preferencesError,
|
|
||||||
hasNextProfilesPage,
|
|
||||||
hasNextFeedsPage,
|
|
||||||
])
|
|
||||||
|
|
||||||
const renderItem = React.useCallback(
|
|
||||||
({item, index}: {item: ExploreScreenItems; index: number}) => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'header': {
|
|
||||||
return (
|
|
||||||
<SuggestedItemsHeader
|
|
||||||
title={item.title}
|
|
||||||
description={item.description}
|
|
||||||
style={item.style}
|
|
||||||
icon={item.icon}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'trendingTopics': {
|
|
||||||
return <ExploreTrendingTopics />
|
|
||||||
}
|
|
||||||
case 'trendingVideos': {
|
|
||||||
return <ExploreTrendingVideos />
|
|
||||||
}
|
|
||||||
case 'recommendations': {
|
|
||||||
return <ExploreRecommendations />
|
|
||||||
}
|
|
||||||
case 'profile': {
|
|
||||||
return (
|
|
||||||
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
|
||||||
<ProfileCardWithFollowBtn
|
|
||||||
profile={item.profile}
|
|
||||||
noBg
|
|
||||||
noBorder
|
|
||||||
showKnownFollowers
|
|
||||||
onPress={() => {
|
|
||||||
logEvent('suggestedUser:press', {
|
|
||||||
logContext: 'Explore',
|
|
||||||
recId: item.recId,
|
|
||||||
position: index,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onFollow={() => {
|
|
||||||
logEvent('suggestedUser:follow', {
|
|
||||||
logContext: 'Explore',
|
|
||||||
location: 'Card',
|
|
||||||
recId: item.recId,
|
|
||||||
position: index,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'feed': {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.border_b,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
a.px_lg,
|
|
||||||
a.py_lg,
|
|
||||||
]}>
|
|
||||||
<FeedCard.Default view={item.feed} />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'loadMore': {
|
|
||||||
return <LoadMore item={item} moderationOpts={moderationOpts} />
|
|
||||||
}
|
|
||||||
case 'profilePlaceholder': {
|
|
||||||
return <ProfileCardFeedLoadingPlaceholder />
|
|
||||||
}
|
|
||||||
case 'feedPlaceholder': {
|
|
||||||
return <FeedFeedLoadingPlaceholder />
|
|
||||||
}
|
|
||||||
case 'error': {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.border_t,
|
|
||||||
a.pt_md,
|
|
||||||
a.px_md,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_md,
|
|
||||||
a.p_lg,
|
|
||||||
a.rounded_sm,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
]}>
|
|
||||||
<CircleInfo size="md" fill={t.palette.negative_400} />
|
|
||||||
<View style={[a.flex_1, a.gap_sm]}>
|
|
||||||
<Text style={[a.font_bold, a.leading_snug]}>
|
|
||||||
{item.message}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.italic,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
{item.error}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t, moderationOpts],
|
|
||||||
)
|
|
||||||
|
|
||||||
// note: actually not a screen, instead it's nested within
|
|
||||||
// the search screen. so we don't need Layout.Screen
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
data={items}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={item => item.key}
|
|
||||||
// @ts-ignore web only -prf
|
|
||||||
desktopFixedHeight
|
|
||||||
contentContainerStyle={{paddingBottom: 100}}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
keyboardDismissMode="on-drag"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
export {SearchScreen} from '#/view/screens/Search/Search'
|
|
||||||
@@ -14,7 +14,7 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
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 * as Prompt from '#/components/Prompt'
|
||||||
import {
|
import {
|
||||||
TrendingTopic,
|
TrendingTopic,
|
||||||
|
|||||||
@@ -80,15 +80,15 @@
|
|||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/api@^0.14.16":
|
"@atproto/api@^0.14.19":
|
||||||
version "0.14.16"
|
version "0.14.19"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.16.tgz#7b59eb83a27e906e0dc442d3de0f0d3869092b4a"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.19.tgz#fef8994e2b14e69a9e3a0aef043c7fcb34d6bf8c"
|
||||||
integrity sha512-xzUK3KVdp1TDJJ09Di2rvS/fisVctvMHO7Er0XhYviL3V4lxGQPNT3pHwbTbbb22QP7xH/d5ghCgfdIoS5Z8/A==
|
integrity sha512-YYTqM0K0qk2TP7PguktPzlAQGLTL1bEGz6PgY5kqKJNX4o1318kJYB22DzjJYqV2NUCq0JQ9Lb0oskLvTisEOg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.4.0"
|
"@atproto/common-web" "^0.4.1"
|
||||||
"@atproto/lexicon" "^0.4.9"
|
"@atproto/lexicon" "^0.4.10"
|
||||||
"@atproto/syntax" "^0.4.0"
|
"@atproto/syntax" "^0.4.0"
|
||||||
"@atproto/xrpc" "^0.6.11"
|
"@atproto/xrpc" "^0.6.12"
|
||||||
await-lock "^2.2.2"
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
@@ -182,6 +182,16 @@
|
|||||||
uint8arrays "3.0.0"
|
uint8arrays "3.0.0"
|
||||||
zod "^3.23.8"
|
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":
|
"@atproto/common@0.1.0":
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
||||||
@@ -291,6 +301,17 @@
|
|||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.23.8"
|
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":
|
"@atproto/lexicon@^0.4.7":
|
||||||
version "0.4.7"
|
version "0.4.7"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86"
|
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86"
|
||||||
@@ -302,17 +323,6 @@
|
|||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
zod "^3.23.8"
|
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":
|
"@atproto/oauth-provider@^0.2.17":
|
||||||
version "0.2.17"
|
version "0.2.17"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.17.tgz#4644d391eedbbbbe5825ecc0e8cc03f1c6433b95"
|
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.17.tgz#4644d391eedbbbbe5825ecc0e8cc03f1c6433b95"
|
||||||
@@ -480,12 +490,12 @@
|
|||||||
ws "^8.12.0"
|
ws "^8.12.0"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/xrpc@^0.6.11":
|
"@atproto/xrpc@^0.6.12":
|
||||||
version "0.6.11"
|
version "0.6.12"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.11.tgz#54c527e39a2f5ddd2655b11f7cb99b8f303d8364"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.12.tgz#a21ee5b87fde63994c98c34098d5e092252e25d0"
|
||||||
integrity sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA==
|
integrity sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/lexicon" "^0.4.9"
|
"@atproto/lexicon" "^0.4.10"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
||||||
"@atproto/xrpc@^0.6.9":
|
"@atproto/xrpc@^0.6.9":
|
||||||
@@ -3370,7 +3380,7 @@
|
|||||||
"@babel/parser" "^7.26.9"
|
"@babel/parser" "^7.26.9"
|
||||||
"@babel/types" "^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"
|
version "7.25.9"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
|
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
|
||||||
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
|
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
|
||||||
@@ -3431,19 +3441,6 @@
|
|||||||
debug "^4.3.1"
|
debug "^4.3.1"
|
||||||
globals "^11.1.0"
|
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":
|
"@babel/traverse@^7.26.10":
|
||||||
version "7.26.10"
|
version "7.26.10"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.10.tgz#43cca33d76005dbaa93024fae536cc1946a4c380"
|
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"
|
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
|
||||||
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"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==
|
|
||||||
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:
|
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@@ -18601,7 +18589,7 @@ string_decoder@~1.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.0"
|
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"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@@ -18615,13 +18603,6 @@ strip-ansi@^5.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex "^4.1.0"
|
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:
|
strip-ansi@^7.0.1:
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
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"
|
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
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"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
@@ -20072,15 +20053,6 @@ wrap-ansi@^6.2.0:
|
|||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.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:
|
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
|||||||
Reference in New Issue
Block a user