Merge branch 'bluesky-social:main' into zh
commit
01f505d09f
|
@ -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="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 285 B |
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.86.0",
|
"version": "1.87.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||||
index 26c52af..b949a4c 100644
|
index 1520465..6ea988a 100644
|
||||||
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
--- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||||
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
+++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt
|
||||||
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
|
@@ -42,7 +42,7 @@ class HapticsModule : Module() {
|
|
@ -1,5 +1,5 @@
|
||||||
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||||
index b85291e..07a5d3c 100644
|
index b85291e..546709d 100644
|
||||||
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||||
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
+++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||||
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
|
@@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update {
|
|
@ -0,0 +1,198 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {GestureResponderEvent, View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||||
|
import {msg, plural, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {
|
||||||
|
useAddSavedFeedsMutation,
|
||||||
|
usePreferencesQuery,
|
||||||
|
useRemoveFeedMutation,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {useRichText} from '#/components/hooks/useRichText'
|
||||||
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
|
import {Link as InternalLink} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {RichText} from '#/components/RichText'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) {
|
||||||
|
return (
|
||||||
|
<Link feed={feed}>
|
||||||
|
<Outer>
|
||||||
|
<Header>
|
||||||
|
<Avatar src={feed.avatar} />
|
||||||
|
<TitleAndByline title={feed.displayName} creator={feed.creator} />
|
||||||
|
<Action uri={feed.uri} pin />
|
||||||
|
</Header>
|
||||||
|
<Description description={feed.description} />
|
||||||
|
<Likes count={feed.likeCount || 0} />
|
||||||
|
</Outer>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Link({
|
||||||
|
children,
|
||||||
|
feed,
|
||||||
|
}: {
|
||||||
|
children: React.ReactElement
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView
|
||||||
|
}) {
|
||||||
|
const href = React.useMemo(() => {
|
||||||
|
const urip = new AtUri(feed.uri)
|
||||||
|
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||||
|
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
||||||
|
}, [feed])
|
||||||
|
return <InternalLink to={href}>{children}</InternalLink>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outer({children}: {children: React.ReactNode}) {
|
||||||
|
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({children}: {children: React.ReactNode}) {
|
||||||
|
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({src}: {src: string | undefined}) {
|
||||||
|
return <UserAvatar type="algo" size={40} avatar={src} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TitleAndByline({
|
||||||
|
title,
|
||||||
|
creator,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
creator: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Description({description}: {description?: string}) {
|
||||||
|
const [rt, isResolving] = useRichText(description || '')
|
||||||
|
if (!description) return null
|
||||||
|
return isResolving ? (
|
||||||
|
<RichText value={description} style={[a.leading_snug]} />
|
||||||
|
) : (
|
||||||
|
<RichText value={rt} style={[a.leading_snug]} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Likes({count}: {count: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
{plural(count || 0, {
|
||||||
|
one: 'Liked by # user',
|
||||||
|
other: 'Liked by # users',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||||
|
useAddSavedFeedsMutation()
|
||||||
|
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||||
|
useRemoveFeedMutation()
|
||||||
|
const savedFeedConfig = React.useMemo(() => {
|
||||||
|
return preferences?.savedFeeds?.find(
|
||||||
|
feed => feed.type === 'feed' && feed.value === uri,
|
||||||
|
)
|
||||||
|
}, [preferences?.savedFeeds, uri])
|
||||||
|
const removePromptControl = Prompt.usePromptControl()
|
||||||
|
const isPending = isAddSavedFeedPending || isRemovePending
|
||||||
|
|
||||||
|
const toggleSave = React.useCallback(
|
||||||
|
async (e: GestureResponderEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (savedFeedConfig) {
|
||||||
|
await removeFeed(savedFeedConfig)
|
||||||
|
} else {
|
||||||
|
await saveFeeds([
|
||||||
|
{
|
||||||
|
type: 'feed',
|
||||||
|
value: uri,
|
||||||
|
pinned: pin || false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
Toast.show(_(msg`Feeds updated!`))
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
|
||||||
|
Toast.show(_(msg`Failed to update feeds`))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPrompRemoveFeed = React.useCallback(
|
||||||
|
async (e: GestureResponderEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
removePromptControl.open()
|
||||||
|
},
|
||||||
|
[removePromptControl],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={isPending}
|
||||||
|
label={_(msg`Add this feed to your feeds`)}
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="square"
|
||||||
|
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
||||||
|
{savedFeedConfig ? (
|
||||||
|
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
|
||||||
|
) : (
|
||||||
|
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Prompt.Basic
|
||||||
|
control={removePromptControl}
|
||||||
|
title={_(msg`Remove from my feeds?`)}
|
||||||
|
description={_(
|
||||||
|
msg`Are you sure you want to remove this from your feeds?`,
|
||||||
|
)}
|
||||||
|
onConfirm={toggleSave}
|
||||||
|
confirmButtonCta={_(msg`Remove`)}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -100,7 +100,7 @@ function KnownFollowersInner({
|
||||||
moderation,
|
moderation,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const count = cachedKnownFollowers.count - Math.min(slice.length, 2)
|
const count = cachedKnownFollowers.count
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {GestureResponderEvent, View} from 'react-native'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
@ -136,7 +136,7 @@ export function Action({
|
||||||
* Note: The dialog will close automatically when the action is pressed, you
|
* Note: The dialog will close automatically when the action is pressed, you
|
||||||
* should NOT close the dialog as a side effect of this method.
|
* should NOT close the dialog as a side effect of this method.
|
||||||
*/
|
*/
|
||||||
onPress: () => void
|
onPress: ButtonProps['onPress']
|
||||||
color?: ButtonColor
|
color?: ButtonColor
|
||||||
/**
|
/**
|
||||||
* Optional i18n string. If undefined, it will default to "Confirm".
|
* Optional i18n string. If undefined, it will default to "Confirm".
|
||||||
|
@ -147,9 +147,12 @@ export function Action({
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {close} = Dialog.useDialogContext()
|
const {close} = Dialog.useDialogContext()
|
||||||
const handleOnPress = React.useCallback(() => {
|
const handleOnPress = React.useCallback(
|
||||||
close(onPress)
|
(e: GestureResponderEvent) => {
|
||||||
}, [close, onPress])
|
close(() => onPress?.(e))
|
||||||
|
},
|
||||||
|
[close, onPress],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
@ -186,7 +189,7 @@ export function Basic({
|
||||||
* Note: The dialog will close automatically when the action is pressed, you
|
* Note: The dialog will close automatically when the action is pressed, you
|
||||||
* should NOT close the dialog as a side effect of this method.
|
* should NOT close the dialog as a side effect of this method.
|
||||||
*/
|
*/
|
||||||
onConfirm: () => void
|
onConfirm: ButtonProps['onPress']
|
||||||
confirmButtonColor?: ButtonColor
|
confirmButtonColor?: ButtonColor
|
||||||
showCancel?: boolean
|
showCancel?: boolean
|
||||||
}>) {
|
}>) {
|
||||||
|
|
|
@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
|
||||||
)}
|
)}
|
||||||
confirmButtonCta={_(msg`Leave`)}
|
confirmButtonCta={_(msg`Leave`)}
|
||||||
confirmButtonColor="negative"
|
confirmButtonColor="negative"
|
||||||
onConfirm={leaveConvo}
|
onConfirm={() => leaveConvo()}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,3 +7,7 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
|
||||||
|
})
|
||||||
|
|
|
@ -333,7 +333,7 @@ function CantSubscribePrompt({
|
||||||
</Trans>
|
</Trans>
|
||||||
</Prompt.DescriptionText>
|
</Prompt.DescriptionText>
|
||||||
<Prompt.Actions>
|
<Prompt.Actions>
|
||||||
<Prompt.Action onPress={control.close} cta={_(msg`OK`)} />
|
<Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} />
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</Prompt.Outer>
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
import {batchedUpdates} from '#/lib/batchedUpdates'
|
import {batchedUpdates} from '#/lib/batchedUpdates'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search'
|
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search'
|
||||||
|
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '../queries/known-followers'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
|
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations'
|
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations'
|
||||||
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
|
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts'
|
||||||
|
@ -111,4 +112,5 @@ function* findProfilesInCache(
|
||||||
yield* findAllProfilesInListConvosQueryData(queryClient, did)
|
yield* findAllProfilesInListConvosQueryData(queryClient, did)
|
||||||
yield* findAllProfilesInFeedsQueryData(queryClient, did)
|
yield* findAllProfilesInFeedsQueryData(queryClient, did)
|
||||||
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
|
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
|
||||||
|
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
AppBskyActorDefs,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
@ -171,28 +172,119 @@ export function useFeedSourceInfoQuery({uri}: {uri: string}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGetPopularFeedsQueryKey = ['getPopularFeeds']
|
// HACK
|
||||||
|
// the protocol doesn't yet tell us which feeds are personalized
|
||||||
|
// this list is used to filter out feed recommendations from logged out users
|
||||||
|
// for the ones we know need it
|
||||||
|
// -prf
|
||||||
|
export const KNOWN_AUTHED_ONLY_FEEDS = [
|
||||||
|
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app
|
||||||
|
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed
|
||||||
|
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed
|
||||||
|
'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow
|
||||||
|
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz
|
||||||
|
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky
|
||||||
|
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz
|
||||||
|
'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
|
||||||
|
]
|
||||||
|
|
||||||
export function useGetPopularFeedsQuery() {
|
type GetPopularFeedsOptions = {limit?: number}
|
||||||
|
|
||||||
|
export function createGetPopularFeedsQueryKey(
|
||||||
|
options?: GetPopularFeedsOptions,
|
||||||
|
) {
|
||||||
|
return ['getPopularFeeds', options]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
||||||
|
const {hasSession} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
const limit = options?.limit || 10
|
||||||
|
const {data: preferences} = usePreferencesQuery()
|
||||||
|
|
||||||
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
|
const selectArgs = useMemo(
|
||||||
|
() => ({
|
||||||
|
hasSession,
|
||||||
|
savedFeeds: preferences?.savedFeeds || [],
|
||||||
|
}),
|
||||||
|
[hasSession, preferences?.savedFeeds],
|
||||||
|
)
|
||||||
|
const lastPageCountRef = useRef(0)
|
||||||
|
|
||||||
|
const query = useInfiniteQuery<
|
||||||
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
|
AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema,
|
||||||
Error,
|
Error,
|
||||||
InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
string | undefined
|
string | undefined
|
||||||
>({
|
>({
|
||||||
queryKey: useGetPopularFeedsQueryKey,
|
queryKey: createGetPopularFeedsQueryKey(options),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
limit: 10,
|
limit,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
|
select: useCallback(
|
||||||
|
(
|
||||||
|
data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
||||||
|
) => {
|
||||||
|
const {savedFeeds, hasSession: hasSessionInner} = selectArgs
|
||||||
|
data?.pages.map(page => {
|
||||||
|
page.feeds = page.feeds.filter(feed => {
|
||||||
|
if (
|
||||||
|
!hasSessionInner &&
|
||||||
|
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const alreadySaved = Boolean(
|
||||||
|
savedFeeds?.find(f => {
|
||||||
|
return f.value === feed.uri
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return !alreadySaved
|
||||||
|
})
|
||||||
|
|
||||||
|
return page
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
[selectArgs /* Don't change. Everything needs to go into selectArgs. */],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const {isFetching, hasNextPage, data} = query
|
||||||
|
if (isFetching || !hasNextPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// avoid double-fires of fetchNextPage()
|
||||||
|
if (
|
||||||
|
lastPageCountRef.current !== 0 &&
|
||||||
|
lastPageCountRef.current === data?.pages?.length
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch next page if we haven't gotten a full page of content
|
||||||
|
let count = 0
|
||||||
|
for (const page of data?.pages || []) {
|
||||||
|
count += page.feeds.length
|
||||||
|
}
|
||||||
|
if (count < limit && (data?.pages.length || 0) < 6) {
|
||||||
|
query.fetchNextPage()
|
||||||
|
lastPageCountRef.current = data?.pages?.length || 0
|
||||||
|
}
|
||||||
|
}, [query, limit])
|
||||||
|
|
||||||
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchPopularFeedsMutation() {
|
export function useSearchPopularFeedsMutation() {
|
||||||
|
@ -209,6 +301,34 @@ export function useSearchPopularFeedsMutation() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch'
|
||||||
|
export const createPopularFeedsSearchQueryKey = (query: string) => [
|
||||||
|
popularFeedsSearchQueryKeyRoot,
|
||||||
|
query,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function usePopularFeedsSearch({
|
||||||
|
query,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
enabled?: boolean
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
return useQuery({
|
||||||
|
enabled,
|
||||||
|
queryKey: createPopularFeedsSearchQueryKey(query),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
|
limit: 10,
|
||||||
|
query: query,
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.data.feeds
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type SavedFeedSourceInfo = FeedSourceInfo & {
|
export type SavedFeedSourceInfo = FeedSourceInfo & {
|
||||||
savedFeed: AppBskyActorDefs.SavedFeed
|
savedFeed: AppBskyActorDefs.SavedFeed
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import {AppBskyGraphGetKnownFollowers} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyGraphGetKnownFollowers} from '@atproto/api'
|
||||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
import {
|
||||||
|
InfiniteData,
|
||||||
|
QueryClient,
|
||||||
|
QueryKey,
|
||||||
|
useInfiniteQuery,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
|
@ -32,3 +37,26 @@ export function useProfileKnownFollowersQuery(did: string | undefined) {
|
||||||
enabled: !!did,
|
enabled: !!did,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function* findAllProfilesInQueryData(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
did: string,
|
||||||
|
): Generator<AppBskyActorDefs.ProfileView, void> {
|
||||||
|
const queryDatas = queryClient.getQueriesData<
|
||||||
|
InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema>
|
||||||
|
>({
|
||||||
|
queryKey: [RQKEY_ROOT],
|
||||||
|
})
|
||||||
|
for (const [_queryKey, queryData] of queryDatas) {
|
||||||
|
if (!queryData?.pages) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const page of queryData?.pages) {
|
||||||
|
for (const follow of page.followers) {
|
||||||
|
if (follow.did === did) {
|
||||||
|
yield follow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -23,7 +23,10 @@ import {useAgent, useSession} from '#/state/session'
|
||||||
import {useModerationOpts} from '../preferences/moderation-opts'
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
|
|
||||||
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
|
const suggestedFollowsQueryKeyRoot = 'suggested-follows'
|
||||||
const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot]
|
const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [
|
||||||
|
suggestedFollowsQueryKeyRoot,
|
||||||
|
options,
|
||||||
|
]
|
||||||
|
|
||||||
const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
|
const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor'
|
||||||
const suggestedFollowsByActorQueryKey = (did: string) => [
|
const suggestedFollowsByActorQueryKey = (did: string) => [
|
||||||
|
@ -31,7 +34,9 @@ const suggestedFollowsByActorQueryKey = (did: string) => [
|
||||||
did,
|
did,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useSuggestedFollowsQuery() {
|
type SuggestedFollowsOptions = {limit?: number}
|
||||||
|
|
||||||
|
export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
@ -46,12 +51,12 @@ export function useSuggestedFollowsQuery() {
|
||||||
>({
|
>({
|
||||||
enabled: !!moderationOpts && !!preferences,
|
enabled: !!moderationOpts && !!preferences,
|
||||||
staleTime: STALE.HOURS.ONE,
|
staleTime: STALE.HOURS.ONE,
|
||||||
queryKey: suggestedFollowsQueryKey,
|
queryKey: suggestedFollowsQueryKey(options),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const contentLangs = getContentLanguages().join(',')
|
const contentLangs = getContentLanguages().join(',')
|
||||||
const res = await agent.app.bsky.actor.getSuggestions(
|
const res = await agent.app.bsky.actor.getSuggestions(
|
||||||
{
|
{
|
||||||
limit: 25,
|
limit: options?.limit || 25,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -57,6 +57,7 @@ function HomeHeaderLayoutDesktopAndTablet({
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
styles.bar,
|
styles.bar,
|
||||||
|
kawaii && {paddingTop: 22, paddingBottom: 16},
|
||||||
]}>
|
]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -66,7 +67,7 @@ function HomeHeaderLayoutDesktopAndTablet({
|
||||||
a.m_auto,
|
a.m_auto,
|
||||||
kawaii && {paddingTop: 4, paddingBottom: 0},
|
kawaii && {paddingTop: 4, paddingBottom: 0},
|
||||||
{
|
{
|
||||||
width: kawaii ? 60 : 28,
|
width: kawaii ? 84 : 28,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Logo width={kawaii ? 60 : 28} />
|
<Logo width={kawaii ? 60 : 28} />
|
||||||
|
|
|
@ -181,7 +181,7 @@ function AltText({text}: {text: string}) {
|
||||||
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
|
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
|
||||||
<Prompt.Actions>
|
<Prompt.Actions>
|
||||||
<Prompt.Action
|
<Prompt.Action
|
||||||
onPress={control.close}
|
onPress={() => control.close()}
|
||||||
cta={_(msg`Close`)}
|
cta={_(msg`Close`)}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
|
import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native'
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
@ -25,7 +25,6 @@ import {ComposeIcon2} from 'lib/icons'
|
||||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
|
||||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||||
import {FAB} from 'view/com/util/fab/FAB'
|
import {FAB} from 'view/com/util/fab/FAB'
|
||||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
|
@ -46,6 +45,8 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon
|
||||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
|
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
|
||||||
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
|
import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
import hairlineWidth = StyleSheet.hairlineWidth
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import * as FeedCard from '#/components/FeedCard'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
|
||||||
|
|
||||||
|
@ -94,6 +95,7 @@ type FlatlistSlice =
|
||||||
type: 'popularFeed'
|
type: 'popularFeed'
|
||||||
key: string
|
key: string
|
||||||
feedUri: string
|
feedUri: string
|
||||||
|
feed: AppBskyFeedDefs.GeneratorView
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'popularFeedsLoadingMore'
|
type: 'popularFeedsLoadingMore'
|
||||||
|
@ -104,22 +106,6 @@ type FlatlistSlice =
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HACK
|
|
||||||
// the protocol doesn't yet tell us which feeds are personalized
|
|
||||||
// this list is used to filter out feed recommendations from logged out users
|
|
||||||
// for the ones we know need it
|
|
||||||
// -prf
|
|
||||||
const KNOWN_AUTHED_ONLY_FEEDS = [
|
|
||||||
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app
|
|
||||||
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed
|
|
||||||
'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed
|
|
||||||
'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow
|
|
||||||
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz
|
|
||||||
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky
|
|
||||||
'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz
|
|
||||||
'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
|
|
||||||
]
|
|
||||||
|
|
||||||
export function FeedsScreen(_props: Props) {
|
export function FeedsScreen(_props: Props) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {openComposer} = useComposerControls()
|
const {openComposer} = useComposerControls()
|
||||||
|
@ -316,6 +302,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
key: `popularFeed:${feed.uri}`,
|
key: `popularFeed:${feed.uri}`,
|
||||||
type: 'popularFeed',
|
type: 'popularFeed',
|
||||||
feedUri: feed.uri,
|
feedUri: feed.uri,
|
||||||
|
feed,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -327,10 +314,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
type: 'popularFeedsLoading',
|
type: 'popularFeedsLoading',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (
|
if (!popularFeeds?.pages) {
|
||||||
!popularFeeds?.pages ||
|
|
||||||
popularFeeds?.pages[0]?.feeds?.length === 0
|
|
||||||
) {
|
|
||||||
slices.push({
|
slices.push({
|
||||||
key: 'popularFeedsNoResults',
|
key: 'popularFeedsNoResults',
|
||||||
type: 'popularFeedsNoResults',
|
type: 'popularFeedsNoResults',
|
||||||
|
@ -338,26 +322,12 @@ export function FeedsScreen(_props: Props) {
|
||||||
} else {
|
} else {
|
||||||
for (const page of popularFeeds.pages || []) {
|
for (const page of popularFeeds.pages || []) {
|
||||||
slices = slices.concat(
|
slices = slices.concat(
|
||||||
page.feeds
|
page.feeds.map(feed => ({
|
||||||
.filter(feed => {
|
key: `popularFeed:${feed.uri}`,
|
||||||
if (
|
type: 'popularFeed',
|
||||||
!hasSession &&
|
feedUri: feed.uri,
|
||||||
KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
|
feed,
|
||||||
) {
|
})),
|
||||||
return false
|
|
||||||
}
|
|
||||||
const alreadySaved = Boolean(
|
|
||||||
preferences?.savedFeeds?.find(f => {
|
|
||||||
return f.value === feed.uri
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return !alreadySaved
|
|
||||||
})
|
|
||||||
.map(feed => ({
|
|
||||||
key: `popularFeed:${feed.uri}`,
|
|
||||||
type: 'popularFeed',
|
|
||||||
feedUri: feed.uri,
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -495,7 +465,7 @@ export function FeedsScreen(_props: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FeedsAboutHeader />
|
<FeedsAboutHeader />
|
||||||
<View style={{paddingHorizontal: 12, paddingBottom: 12}}>
|
<View style={{paddingHorizontal: 12, paddingBottom: 4}}>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
query={query}
|
query={query}
|
||||||
onChangeQuery={onChangeQuery}
|
onChangeQuery={onChangeQuery}
|
||||||
|
@ -510,13 +480,10 @@ export function FeedsScreen(_props: Props) {
|
||||||
return <FeedFeedLoadingPlaceholder />
|
return <FeedFeedLoadingPlaceholder />
|
||||||
} else if (item.type === 'popularFeed') {
|
} else if (item.type === 'popularFeed') {
|
||||||
return (
|
return (
|
||||||
<FeedSourceCard
|
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
|
||||||
feedUri={item.feedUri}
|
<FeedCard.Default feed={item.feed} />
|
||||||
showSaveBtn={hasSession}
|
<Divider />
|
||||||
showDescription
|
</View>
|
||||||
showLikes
|
|
||||||
pinOnSave
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
} else if (item.type === 'popularFeedsNoResults') {
|
} else if (item.type === 'popularFeedsNoResults') {
|
||||||
return (
|
return (
|
||||||
|
@ -559,7 +526,6 @@ export function FeedsScreen(_props: Props) {
|
||||||
onPressCancelSearch,
|
onPressCancelSearch,
|
||||||
onSubmitQuery,
|
onSubmitQuery,
|
||||||
onChangeSearchFocus,
|
onChangeSearchFocus,
|
||||||
hasSession,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,556 @@
|
||||||
|
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 {logger} from '#/logger'
|
||||||
|
import {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 {useSession} from '#/state/session'
|
||||||
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||||
|
import {List} from '#/view/com/util/List'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||||
|
import {
|
||||||
|
FeedFeedLoadingPlaceholder,
|
||||||
|
ProfileCardFeedLoadingPlaceholder,
|
||||||
|
} from 'view/com/util/LoadingPlaceholder'
|
||||||
|
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
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_bold, t.atoms.text]}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadMoreItems =
|
||||||
|
| {
|
||||||
|
type: 'profile'
|
||||||
|
key: string
|
||||||
|
avatar: string
|
||||||
|
moderation: ModerationDecision
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'feed'
|
||||||
|
key: string
|
||||||
|
avatar: string
|
||||||
|
moderation: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadMore({
|
||||||
|
item,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
item: ExploreScreenItems & {type: 'loadMore'}
|
||||||
|
moderationOpts?: ModerationOpts
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const items = React.useMemo(() => {
|
||||||
|
return item.items
|
||||||
|
.map(_item => {
|
||||||
|
if (_item.type === 'profile') {
|
||||||
|
return {
|
||||||
|
type: 'profile',
|
||||||
|
key: _item.profile.did,
|
||||||
|
avatar: _item.profile.avatar,
|
||||||
|
moderation: moderateProfile(_item.profile, moderationOpts!),
|
||||||
|
}
|
||||||
|
} else if (_item.type === 'feed') {
|
||||||
|
return {
|
||||||
|
type: 'feed',
|
||||||
|
key: _item.feed.uri,
|
||||||
|
avatar: _item.feed.avatar,
|
||||||
|
moderation: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
.filter(Boolean) as LoadMoreItems[]
|
||||||
|
}, [item.items, moderationOpts])
|
||||||
|
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 * 3,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.border,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
left: 0,
|
||||||
|
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={[
|
||||||
|
a.border,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
left: (i + 1) * 15,
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
) : _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: 'profile'
|
||||||
|
key: string
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
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 {hasSession} = useSession()
|
||||||
|
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const {
|
||||||
|
data: profiles,
|
||||||
|
hasNextPage: hasNextProfilesPage,
|
||||||
|
isLoading: isLoadingProfiles,
|
||||||
|
isFetchingNextPage: isFetchingNextProfilesPage,
|
||||||
|
error: profilesError,
|
||||||
|
fetchNextPage: fetchNextProfilesPage,
|
||||||
|
} = useSuggestedFollowsQuery({limit: 3})
|
||||||
|
const {
|
||||||
|
data: feeds,
|
||||||
|
hasNextPage: hasNextFeedsPage,
|
||||||
|
isLoading: isLoadingFeeds,
|
||||||
|
isFetchingNextPage: isFetchingNextFeedsPage,
|
||||||
|
error: feedsError,
|
||||||
|
fetchNextPage: fetchNextFeedsPage,
|
||||||
|
} = useGetPopularFeedsQuery({limit: 3})
|
||||||
|
|
||||||
|
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[] = [
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
for (const page of profiles.pages) {
|
||||||
|
for (const actor of page.actors) {
|
||||||
|
if (!seen.has(actor.did)) {
|
||||||
|
seen.add(actor.did)
|
||||||
|
i.push({
|
||||||
|
type: 'profile',
|
||||||
|
key: actor.did,
|
||||||
|
profile: actor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i.push({
|
||||||
|
type: 'loadMore',
|
||||||
|
key: 'loadMoreProfiles',
|
||||||
|
isLoadingMore: isLoadingMoreProfiles,
|
||||||
|
onLoadMore: onLoadMoreProfiles,
|
||||||
|
items: i.filter(item => item.type === 'profile').slice(-3),
|
||||||
|
})
|
||||||
|
} 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`Custom feeds built by the community bring you new experiences and help you find the 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()
|
||||||
|
for (const page of feeds.pages) {
|
||||||
|
for (const feed of page.feeds) {
|
||||||
|
if (!seen.has(feed.uri)) {
|
||||||
|
seen.add(feed.uri)
|
||||||
|
i.push({
|
||||||
|
type: 'feed',
|
||||||
|
key: feed.uri,
|
||||||
|
feed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'loadMore',
|
||||||
|
key: 'loadMoreFeeds',
|
||||||
|
isLoadingMore: isLoadingMoreFeeds,
|
||||||
|
onLoadMore: onLoadMoreFeeds,
|
||||||
|
items: i.filter(item => item.type === 'feed').slice(-3),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderItem = React.useCallback(
|
||||||
|
({item}: {item: ExploreScreenItems}) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'header': {
|
||||||
|
return (
|
||||||
|
<SuggestedItemsHeader
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
style={item.style}
|
||||||
|
icon={item.icon}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'profile': {
|
||||||
|
return (
|
||||||
|
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
||||||
|
<ProfileCardWithFollowBtn profile={item.profile} noBg noBorder />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'feed': {
|
||||||
|
return (
|
||||||
|
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
||||||
|
<FeedSourceCard
|
||||||
|
feedUri={item.feed.uri}
|
||||||
|
showSaveBtn={hasSession}
|
||||||
|
showDescription
|
||||||
|
showLikes
|
||||||
|
pinOnSave
|
||||||
|
hideTopBorder
|
||||||
|
/>
|
||||||
|
</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, hasSession, moderationOpts],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
data={items}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={item => item.key}
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
desktopFixedHeight
|
||||||
|
contentContainerStyle={{paddingBottom: 200}}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -29,15 +29,14 @@ import {MagnifyingGlassIcon} from '#/lib/icons'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||||
import {s} from '#/lib/styles'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isNative, isWeb} from '#/platform/detection'
|
import {isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
import {useActorSearch} from '#/state/queries/actor-search'
|
import {useActorSearch} from '#/state/queries/actor-search'
|
||||||
|
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
||||||
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
||||||
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
|
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useSetDrawerOpen} from '#/state/shell'
|
import {useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
|
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
@ -56,8 +55,9 @@ import {Link} from '#/view/com/util/Link'
|
||||||
import {List} from '#/view/com/util/List'
|
import {List} from '#/view/com/util/List'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
import {CenteredView, ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {Explore} from '#/view/screens/Search/Explore'
|
||||||
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
|
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
|
||||||
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||||
|
|
||||||
|
@ -122,70 +122,6 @@ function EmptyState({message, error}: {message: string; error?: string}) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSuggestedFollows(): [
|
|
||||||
AppBskyActorDefs.ProfileViewBasic[],
|
|
||||||
() => void,
|
|
||||||
] {
|
|
||||||
const {
|
|
||||||
data: suggestions,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
isError,
|
|
||||||
fetchNextPage,
|
|
||||||
} = useSuggestedFollowsQuery()
|
|
||||||
|
|
||||||
const onEndReached = React.useCallback(async () => {
|
|
||||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
|
||||||
try {
|
|
||||||
await fetchNextPage()
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to load more suggested follows', {message: err})
|
|
||||||
}
|
|
||||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
|
||||||
|
|
||||||
const items: AppBskyActorDefs.ProfileViewBasic[] = []
|
|
||||||
if (suggestions) {
|
|
||||||
// Currently the responses contain duplicate items.
|
|
||||||
// Needs to be fixed on backend, but let's dedupe to be safe.
|
|
||||||
let seen = new Set()
|
|
||||||
for (const page of suggestions.pages) {
|
|
||||||
for (const actor of page.actors) {
|
|
||||||
if (!seen.has(actor.did)) {
|
|
||||||
seen.add(actor.did)
|
|
||||||
items.push(actor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [items, onEndReached]
|
|
||||||
}
|
|
||||||
|
|
||||||
let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const [suggestions, onEndReached] = useSuggestedFollows()
|
|
||||||
|
|
||||||
return suggestions.length ? (
|
|
||||||
<List
|
|
||||||
data={suggestions}
|
|
||||||
renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />}
|
|
||||||
keyExtractor={item => item.did}
|
|
||||||
// @ts-ignore web only -prf
|
|
||||||
desktopFixedHeight
|
|
||||||
contentContainerStyle={{paddingBottom: 200}}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
keyboardDismissMode="on-drag"
|
|
||||||
onEndReached={onEndReached}
|
|
||||||
onEndReachedThreshold={2}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CenteredView sideBorders style={[pal.border, s.hContentRegion]}>
|
|
||||||
<ProfileCardFeedLoadingPlaceholder />
|
|
||||||
<ProfileCardFeedLoadingPlaceholder />
|
|
||||||
</CenteredView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows)
|
|
||||||
|
|
||||||
type SearchResultSlice =
|
type SearchResultSlice =
|
||||||
| {
|
| {
|
||||||
type: 'post'
|
type: 'post'
|
||||||
|
@ -342,6 +278,50 @@ let SearchScreenUserResults = ({
|
||||||
}
|
}
|
||||||
SearchScreenUserResults = React.memo(SearchScreenUserResults)
|
SearchScreenUserResults = React.memo(SearchScreenUserResults)
|
||||||
|
|
||||||
|
let SearchScreenFeedsResults = ({
|
||||||
|
query,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
active: boolean
|
||||||
|
}): React.ReactNode => {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
|
||||||
|
const {data: results, isFetched} = usePopularFeedsSearch({
|
||||||
|
query,
|
||||||
|
enabled: active,
|
||||||
|
})
|
||||||
|
|
||||||
|
return isFetched && results ? (
|
||||||
|
<>
|
||||||
|
{results.length ? (
|
||||||
|
<List
|
||||||
|
data={results}
|
||||||
|
renderItem={({item}) => (
|
||||||
|
<FeedSourceCard
|
||||||
|
feedUri={item.uri}
|
||||||
|
showSaveBtn={hasSession}
|
||||||
|
showDescription
|
||||||
|
showLikes
|
||||||
|
pinOnSave
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={item => item.uri}
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
desktopFixedHeight
|
||||||
|
contentContainerStyle={{paddingBottom: 100}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState message={_(msg`No results found for ${query}`)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
|
||||||
|
|
||||||
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
@ -389,6 +369,12 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||||
<SearchScreenUserResults query={query} active={activeTab === 2} />
|
<SearchScreenUserResults query={query} active={activeTab === 2} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: _(msg`Feeds`),
|
||||||
|
component: (
|
||||||
|
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}, [_, query, activeTab])
|
}, [_, query, activeTab])
|
||||||
|
|
||||||
|
@ -408,26 +394,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||||
))}
|
))}
|
||||||
</Pager>
|
</Pager>
|
||||||
) : hasSession ? (
|
) : hasSession ? (
|
||||||
<View>
|
<Explore />
|
||||||
<CenteredView sideBorders style={pal.border}>
|
|
||||||
<Text
|
|
||||||
type="title"
|
|
||||||
style={[
|
|
||||||
pal.text,
|
|
||||||
pal.border,
|
|
||||||
{
|
|
||||||
display: 'flex',
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Trans>Suggested Follows</Trans>
|
|
||||||
</Text>
|
|
||||||
</CenteredView>
|
|
||||||
|
|
||||||
<SearchScreenSuggestedFollows />
|
|
||||||
</View>
|
|
||||||
) : (
|
) : (
|
||||||
<CenteredView sideBorders style={pal.border}>
|
<CenteredView sideBorders style={pal.border}>
|
||||||
<View
|
<View
|
||||||
|
|
Loading…
Reference in New Issue