Merge branch 'bluesky-social:main' into zh

zio/stable
Kuwa Lee 2024-06-15 19:00:34 +08:00 committed by GitHub
commit 01f505d09f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1017 additions and 166 deletions

View File

@ -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

View File

@ -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"

View File

@ -1,9 +1,9 @@
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() {
private fun vibrate(type: HapticsVibrationType) { private fun vibrate(type: HapticsVibrationType) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1)) - vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))

View File

@ -1,11 +1,11 @@
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 {
status = UpdateStatus.StatusPending status = UpdateStatus.StatusPending
} }
+ // Instead of relying on various hacks to get the correct format for the specific + // Instead of relying on various hacks to get the correct format for the specific
+ // platform on the backend, we can just add this little patch.. + // platform on the backend, we can just add this little patch..
+ let dateFormatter = DateFormatter() + let dateFormatter = DateFormatter()

View File

@ -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"
/>
</>
)
}

View File

@ -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

View File

@ -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
}>) { }>) {

View File

@ -49,7 +49,7 @@ export function LeaveConvoPrompt({
)} )}
confirmButtonCta={_(msg`Leave`)} confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative" confirmButtonColor="negative"
onConfirm={leaveConvo} onConfirm={() => leaveConvo()}
/> />
) )
} }

View File

@ -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',
})

View File

@ -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>
) )

View File

@ -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)
} }

View File

@ -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
} }

View File

@ -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
}
}
}
}
}

View File

@ -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,
}, },
{ {

View File

@ -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} />

View File

@ -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"
/> />

View File

@ -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,
], ],
) )

View File

@ -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"
/>
)
}

View File

@ -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