deer social fork for personal usage. but you might see a use idk. github mirror

Redo explore page (#4491)

* Redo explore page, wip

* Remove circle icons

* Load more styling

* Lower limit

* Some styling tweaks

* Abstract

* Add tab, query, factor out

* Revert unneeded change

* Revert unneeded change v2

* Update copy

* Load more styling

* Header styles

* The thin blue line

* Make sure it's hairline

* Update query keys

* Border

* Expand avis

* Very load more copy

authored by Eric Bailey and committed by GitHub 36e976fe 94c1f496

Changed files
+656 -93
assets
src
components
icons
state
view
screens
+1
assets/icons/arrowBottom_stroke2_corner0_rounded.svg
··· 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>
+4
src/components/icons/Arrow.tsx
··· 7 7 export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 8 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', 9 9 }) 10 + 11 + export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 12 + 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', 13 + })
+32 -2
src/state/queries/feed.ts
··· 190 190 191 191 type GetPopularFeedsOptions = {limit?: number} 192 192 193 - export function createGetPopularFeedsQueryKey(...args: any[]) { 194 - return ['getPopularFeeds', ...args] 193 + export function createGetPopularFeedsQueryKey( 194 + options?: GetPopularFeedsOptions, 195 + ) { 196 + return ['getPopularFeeds', options] 195 197 } 196 198 197 199 export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { ··· 289 291 const agent = useAgent() 290 292 return useMutation({ 291 293 mutationFn: async (query: string) => { 294 + const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 295 + limit: 10, 296 + query: query, 297 + }) 298 + 299 + return res.data.feeds 300 + }, 301 + }) 302 + } 303 + 304 + const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch' 305 + export const createPopularFeedsSearchQueryKey = (query: string) => [ 306 + popularFeedsSearchQueryKeyRoot, 307 + query, 308 + ] 309 + 310 + export function usePopularFeedsSearch({ 311 + query, 312 + enabled, 313 + }: { 314 + query: string 315 + enabled?: boolean 316 + }) { 317 + const agent = useAgent() 318 + return useQuery({ 319 + enabled, 320 + queryKey: createPopularFeedsSearchQueryKey(query), 321 + queryFn: async () => { 292 322 const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 293 323 limit: 10, 294 324 query: query,
+9 -4
src/state/queries/suggested-follows.ts
··· 23 23 import {useModerationOpts} from '../preferences/moderation-opts' 24 24 25 25 const suggestedFollowsQueryKeyRoot = 'suggested-follows' 26 - const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] 26 + const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [ 27 + suggestedFollowsQueryKeyRoot, 28 + options, 29 + ] 27 30 28 31 const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor' 29 32 const suggestedFollowsByActorQueryKey = (did: string) => [ ··· 31 34 did, 32 35 ] 33 36 34 - export function useSuggestedFollowsQuery() { 37 + type SuggestedFollowsOptions = {limit?: number} 38 + 39 + export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { 35 40 const {currentAccount} = useSession() 36 41 const agent = useAgent() 37 42 const moderationOpts = useModerationOpts() ··· 46 51 >({ 47 52 enabled: !!moderationOpts && !!preferences, 48 53 staleTime: STALE.HOURS.ONE, 49 - queryKey: suggestedFollowsQueryKey, 54 + queryKey: suggestedFollowsQueryKey(options), 50 55 queryFn: async ({pageParam}) => { 51 56 const contentLangs = getContentLanguages().join(',') 52 57 const res = await agent.app.bsky.actor.getSuggestions( 53 58 { 54 - limit: 25, 59 + limit: options?.limit || 25, 55 60 cursor: pageParam, 56 61 }, 57 62 {
+556
src/view/screens/Search/Explore.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyFeedDefs, 6 + moderateProfile, 7 + ModerationDecision, 8 + ModerationOpts, 9 + } from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {logger} from '#/logger' 14 + import {isWeb} from '#/platform/detection' 15 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 + import {useGetPopularFeedsQuery} from '#/state/queries/feed' 17 + import {usePreferencesQuery} from '#/state/queries/preferences' 18 + import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 19 + import {useSession} from '#/state/session' 20 + import {cleanError} from 'lib/strings/errors' 21 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 22 + import {List} from '#/view/com/util/List' 23 + import {UserAvatar} from '#/view/com/util/UserAvatar' 24 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 25 + import { 26 + FeedFeedLoadingPlaceholder, 27 + ProfileCardFeedLoadingPlaceholder, 28 + } from 'view/com/util/LoadingPlaceholder' 29 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 30 + import {Button} from '#/components/Button' 31 + import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' 32 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 33 + import {Props as SVGIconProps} from '#/components/icons/common' 34 + import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 35 + import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 36 + import {Loader} from '#/components/Loader' 37 + import {Text} from '#/components/Typography' 38 + 39 + function SuggestedItemsHeader({ 40 + title, 41 + description, 42 + style, 43 + icon: Icon, 44 + }: { 45 + title: string 46 + description: string 47 + icon: React.ComponentType<SVGIconProps> 48 + } & ViewStyleProp) { 49 + const t = useTheme() 50 + 51 + return ( 52 + <View 53 + style={[ 54 + isWeb 55 + ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 56 + : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], 57 + a.border_b, 58 + t.atoms.border_contrast_low, 59 + style, 60 + ]}> 61 + <View style={[a.flex_1, a.gap_sm]}> 62 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 63 + <Icon 64 + size="lg" 65 + fill={t.palette.primary_500} 66 + style={{marginLeft: -2}} 67 + /> 68 + <Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text> 69 + </View> 70 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 71 + {description} 72 + </Text> 73 + </View> 74 + </View> 75 + ) 76 + } 77 + 78 + type LoadMoreItems = 79 + | { 80 + type: 'profile' 81 + key: string 82 + avatar: string 83 + moderation: ModerationDecision 84 + } 85 + | { 86 + type: 'feed' 87 + key: string 88 + avatar: string 89 + moderation: undefined 90 + } 91 + 92 + function LoadMore({ 93 + item, 94 + moderationOpts, 95 + }: { 96 + item: ExploreScreenItems & {type: 'loadMore'} 97 + moderationOpts?: ModerationOpts 98 + }) { 99 + const t = useTheme() 100 + const {_} = useLingui() 101 + const items = React.useMemo(() => { 102 + return item.items 103 + .map(_item => { 104 + if (_item.type === 'profile') { 105 + return { 106 + type: 'profile', 107 + key: _item.profile.did, 108 + avatar: _item.profile.avatar, 109 + moderation: moderateProfile(_item.profile, moderationOpts!), 110 + } 111 + } else if (_item.type === 'feed') { 112 + return { 113 + type: 'feed', 114 + key: _item.feed.uri, 115 + avatar: _item.feed.avatar, 116 + moderation: undefined, 117 + } 118 + } 119 + return undefined 120 + }) 121 + .filter(Boolean) as LoadMoreItems[] 122 + }, [item.items, moderationOpts]) 123 + const type = items[0].type 124 + 125 + return ( 126 + <View style={[]}> 127 + <Button 128 + label={_(msg`Load more`)} 129 + onPress={item.onLoadMore} 130 + style={[a.relative, a.w_full]}> 131 + {({hovered, pressed}) => ( 132 + <View 133 + style={[ 134 + a.flex_1, 135 + a.flex_row, 136 + a.align_center, 137 + a.px_lg, 138 + a.py_md, 139 + (hovered || pressed) && t.atoms.bg_contrast_25, 140 + ]}> 141 + <View 142 + style={[ 143 + a.relative, 144 + { 145 + height: 32, 146 + width: 32 + 15 * 3, 147 + }, 148 + ]}> 149 + <View 150 + style={[ 151 + a.align_center, 152 + a.justify_center, 153 + a.border, 154 + t.atoms.bg_contrast_25, 155 + a.absolute, 156 + { 157 + width: 30, 158 + height: 30, 159 + left: 0, 160 + backgroundColor: t.palette.primary_500, 161 + borderColor: t.atoms.bg.backgroundColor, 162 + borderRadius: type === 'profile' ? 999 : 4, 163 + zIndex: 4, 164 + }, 165 + ]}> 166 + <ArrowBottom fill={t.palette.white} /> 167 + </View> 168 + {items.map((_item, i) => { 169 + return ( 170 + <View 171 + key={_item.key} 172 + style={[ 173 + a.border, 174 + t.atoms.bg_contrast_25, 175 + a.absolute, 176 + { 177 + width: 30, 178 + height: 30, 179 + left: (i + 1) * 15, 180 + borderColor: t.atoms.bg.backgroundColor, 181 + borderRadius: _item.type === 'profile' ? 999 : 4, 182 + zIndex: 3 - i, 183 + }, 184 + ]}> 185 + {moderationOpts && ( 186 + <> 187 + {_item.type === 'profile' ? ( 188 + <UserAvatar 189 + size={28} 190 + avatar={_item.avatar} 191 + moderation={_item.moderation.ui('avatar')} 192 + /> 193 + ) : _item.type === 'feed' ? ( 194 + <UserAvatar 195 + size={28} 196 + avatar={_item.avatar} 197 + type="algo" 198 + /> 199 + ) : null} 200 + </> 201 + )} 202 + </View> 203 + ) 204 + })} 205 + </View> 206 + 207 + <Text 208 + style={[ 209 + a.pl_sm, 210 + a.leading_snug, 211 + hovered ? t.atoms.text : t.atoms.text_contrast_medium, 212 + ]}> 213 + {type === 'profile' ? ( 214 + <Trans>Load more suggested follows</Trans> 215 + ) : ( 216 + <Trans>Load more suggested feeds</Trans> 217 + )} 218 + </Text> 219 + 220 + <View style={[a.flex_1, a.align_end]}> 221 + {item.isLoadingMore && <Loader size="lg" />} 222 + </View> 223 + </View> 224 + )} 225 + </Button> 226 + </View> 227 + ) 228 + } 229 + 230 + type ExploreScreenItems = 231 + | { 232 + type: 'header' 233 + key: string 234 + title: string 235 + description: string 236 + style?: ViewStyleProp['style'] 237 + icon: React.ComponentType<SVGIconProps> 238 + } 239 + | { 240 + type: 'profile' 241 + key: string 242 + profile: AppBskyActorDefs.ProfileViewBasic 243 + } 244 + | { 245 + type: 'feed' 246 + key: string 247 + feed: AppBskyFeedDefs.GeneratorView 248 + } 249 + | { 250 + type: 'loadMore' 251 + key: string 252 + isLoadingMore: boolean 253 + onLoadMore: () => void 254 + items: ExploreScreenItems[] 255 + } 256 + | { 257 + type: 'profilePlaceholder' 258 + key: string 259 + } 260 + | { 261 + type: 'feedPlaceholder' 262 + key: string 263 + } 264 + | { 265 + type: 'error' 266 + key: string 267 + message: string 268 + error: string 269 + } 270 + 271 + export function Explore() { 272 + const {_} = useLingui() 273 + const t = useTheme() 274 + const {hasSession} = useSession() 275 + const {data: preferences, error: preferencesError} = usePreferencesQuery() 276 + const moderationOpts = useModerationOpts() 277 + const { 278 + data: profiles, 279 + hasNextPage: hasNextProfilesPage, 280 + isLoading: isLoadingProfiles, 281 + isFetchingNextPage: isFetchingNextProfilesPage, 282 + error: profilesError, 283 + fetchNextPage: fetchNextProfilesPage, 284 + } = useSuggestedFollowsQuery({limit: 3}) 285 + const { 286 + data: feeds, 287 + hasNextPage: hasNextFeedsPage, 288 + isLoading: isLoadingFeeds, 289 + isFetchingNextPage: isFetchingNextFeedsPage, 290 + error: feedsError, 291 + fetchNextPage: fetchNextFeedsPage, 292 + } = useGetPopularFeedsQuery({limit: 3}) 293 + 294 + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles 295 + const onLoadMoreProfiles = React.useCallback(async () => { 296 + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) 297 + return 298 + try { 299 + await fetchNextProfilesPage() 300 + } catch (err) { 301 + logger.error('Failed to load more suggested follows', {message: err}) 302 + } 303 + }, [ 304 + isFetchingNextProfilesPage, 305 + hasNextProfilesPage, 306 + profilesError, 307 + fetchNextProfilesPage, 308 + ]) 309 + 310 + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds 311 + const onLoadMoreFeeds = React.useCallback(async () => { 312 + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return 313 + try { 314 + await fetchNextFeedsPage() 315 + } catch (err) { 316 + logger.error('Failed to load more suggested follows', {message: err}) 317 + } 318 + }, [ 319 + isFetchingNextFeedsPage, 320 + hasNextFeedsPage, 321 + feedsError, 322 + fetchNextFeedsPage, 323 + ]) 324 + 325 + const items = React.useMemo<ExploreScreenItems[]>(() => { 326 + const i: ExploreScreenItems[] = [ 327 + { 328 + type: 'header', 329 + key: 'suggested-follows-header', 330 + title: _(msg`Suggested accounts`), 331 + description: _( 332 + msg`Follow more accounts to get connected to your interests and build your network.`, 333 + ), 334 + icon: Person, 335 + }, 336 + ] 337 + 338 + if (profiles) { 339 + // Currently the responses contain duplicate items. 340 + // Needs to be fixed on backend, but let's dedupe to be safe. 341 + let seen = new Set() 342 + for (const page of profiles.pages) { 343 + for (const actor of page.actors) { 344 + if (!seen.has(actor.did)) { 345 + seen.add(actor.did) 346 + i.push({ 347 + type: 'profile', 348 + key: actor.did, 349 + profile: actor, 350 + }) 351 + } 352 + } 353 + } 354 + 355 + i.push({ 356 + type: 'loadMore', 357 + key: 'loadMoreProfiles', 358 + isLoadingMore: isLoadingMoreProfiles, 359 + onLoadMore: onLoadMoreProfiles, 360 + items: i.filter(item => item.type === 'profile').slice(-3), 361 + }) 362 + } else { 363 + if (profilesError) { 364 + i.push({ 365 + type: 'error', 366 + key: 'profilesError', 367 + message: _(msg`Failed to load suggested follows`), 368 + error: cleanError(profilesError), 369 + }) 370 + } else { 371 + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 372 + } 373 + } 374 + 375 + i.push({ 376 + type: 'header', 377 + key: 'suggested-feeds-header', 378 + title: _(msg`Discover new feeds`), 379 + description: _( 380 + msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`, 381 + ), 382 + style: [a.pt_5xl], 383 + icon: ListSparkle, 384 + }) 385 + 386 + if (feeds && preferences) { 387 + // Currently the responses contain duplicate items. 388 + // Needs to be fixed on backend, but let's dedupe to be safe. 389 + let seen = new Set() 390 + for (const page of feeds.pages) { 391 + for (const feed of page.feeds) { 392 + if (!seen.has(feed.uri)) { 393 + seen.add(feed.uri) 394 + i.push({ 395 + type: 'feed', 396 + key: feed.uri, 397 + feed, 398 + }) 399 + } 400 + } 401 + } 402 + 403 + if (feedsError) { 404 + i.push({ 405 + type: 'error', 406 + key: 'feedsError', 407 + message: _(msg`Failed to load suggested feeds`), 408 + error: cleanError(feedsError), 409 + }) 410 + } else if (preferencesError) { 411 + i.push({ 412 + type: 'error', 413 + key: 'preferencesError', 414 + message: _(msg`Failed to load feeds preferences`), 415 + error: cleanError(preferencesError), 416 + }) 417 + } else { 418 + i.push({ 419 + type: 'loadMore', 420 + key: 'loadMoreFeeds', 421 + isLoadingMore: isLoadingMoreFeeds, 422 + onLoadMore: onLoadMoreFeeds, 423 + items: i.filter(item => item.type === 'feed').slice(-3), 424 + }) 425 + } 426 + } else { 427 + if (feedsError) { 428 + i.push({ 429 + type: 'error', 430 + key: 'feedsError', 431 + message: _(msg`Failed to load suggested feeds`), 432 + error: cleanError(feedsError), 433 + }) 434 + } else if (preferencesError) { 435 + i.push({ 436 + type: 'error', 437 + key: 'preferencesError', 438 + message: _(msg`Failed to load feeds preferences`), 439 + error: cleanError(preferencesError), 440 + }) 441 + } else { 442 + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) 443 + } 444 + } 445 + 446 + return i 447 + }, [ 448 + _, 449 + profiles, 450 + feeds, 451 + preferences, 452 + onLoadMoreFeeds, 453 + onLoadMoreProfiles, 454 + isLoadingMoreProfiles, 455 + isLoadingMoreFeeds, 456 + profilesError, 457 + feedsError, 458 + preferencesError, 459 + ]) 460 + 461 + const renderItem = React.useCallback( 462 + ({item}: {item: ExploreScreenItems}) => { 463 + switch (item.type) { 464 + case 'header': { 465 + return ( 466 + <SuggestedItemsHeader 467 + title={item.title} 468 + description={item.description} 469 + style={item.style} 470 + icon={item.icon} 471 + /> 472 + ) 473 + } 474 + case 'profile': { 475 + return ( 476 + <View style={[a.border_b, t.atoms.border_contrast_low]}> 477 + <ProfileCardWithFollowBtn profile={item.profile} noBg noBorder /> 478 + </View> 479 + ) 480 + } 481 + case 'feed': { 482 + return ( 483 + <View style={[a.border_b, t.atoms.border_contrast_low]}> 484 + <FeedSourceCard 485 + feedUri={item.feed.uri} 486 + showSaveBtn={hasSession} 487 + showDescription 488 + showLikes 489 + pinOnSave 490 + hideTopBorder 491 + /> 492 + </View> 493 + ) 494 + } 495 + case 'loadMore': { 496 + return <LoadMore item={item} moderationOpts={moderationOpts} /> 497 + } 498 + case 'profilePlaceholder': { 499 + return <ProfileCardFeedLoadingPlaceholder /> 500 + } 501 + case 'feedPlaceholder': { 502 + return <FeedFeedLoadingPlaceholder /> 503 + } 504 + case 'error': { 505 + return ( 506 + <View 507 + style={[ 508 + a.border_t, 509 + a.pt_md, 510 + a.px_md, 511 + t.atoms.border_contrast_low, 512 + ]}> 513 + <View 514 + style={[ 515 + a.flex_row, 516 + a.gap_md, 517 + a.p_lg, 518 + a.rounded_sm, 519 + t.atoms.bg_contrast_25, 520 + ]}> 521 + <CircleInfo size="md" fill={t.palette.negative_400} /> 522 + <View style={[a.flex_1, a.gap_sm]}> 523 + <Text style={[a.font_bold, a.leading_snug]}> 524 + {item.message} 525 + </Text> 526 + <Text 527 + style={[ 528 + a.italic, 529 + a.leading_snug, 530 + t.atoms.text_contrast_medium, 531 + ]}> 532 + {item.error} 533 + </Text> 534 + </View> 535 + </View> 536 + </View> 537 + ) 538 + } 539 + } 540 + }, 541 + [t, hasSession, moderationOpts], 542 + ) 543 + 544 + return ( 545 + <List 546 + data={items} 547 + renderItem={renderItem} 548 + keyExtractor={item => item.key} 549 + // @ts-ignore web only -prf 550 + desktopFixedHeight 551 + contentContainerStyle={{paddingBottom: 200}} 552 + keyboardShouldPersistTaps="handled" 553 + keyboardDismissMode="on-drag" 554 + /> 555 + ) 556 + }
+54 -87
src/view/screens/Search/Search.tsx
··· 29 29 import {makeProfileLink} from '#/lib/routes/links' 30 30 import {NavigationProp} from '#/lib/routes/types' 31 31 import {augmentSearchQuery} from '#/lib/strings/helpers' 32 - import {s} from '#/lib/styles' 33 32 import {logger} from '#/logger' 34 33 import {isIOS, isNative, isWeb} from '#/platform/detection' 35 34 import {listenSoftReset} from '#/state/events' 36 35 import {useModerationOpts} from '#/state/preferences/moderation-opts' 37 36 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 38 37 import {useActorSearch} from '#/state/queries/actor-search' 38 + import {usePopularFeedsSearch} from '#/state/queries/feed' 39 39 import {useSearchPostsQuery} from '#/state/queries/search-posts' 40 - import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 41 40 import {useSession} from '#/state/session' 42 41 import {useSetDrawerOpen} from '#/state/shell' 43 42 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' ··· 56 55 import {List} from '#/view/com/util/List' 57 56 import {Text} from '#/view/com/util/text/Text' 58 57 import {CenteredView, ScrollView} from '#/view/com/util/Views' 58 + import {Explore} from '#/view/screens/Search/Explore' 59 59 import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 60 - import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 60 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 61 61 import {atoms as a} from '#/alf' 62 62 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' 63 63 ··· 121 121 </CenteredView> 122 122 ) 123 123 } 124 - 125 - function useSuggestedFollows(): [ 126 - AppBskyActorDefs.ProfileViewBasic[], 127 - () => void, 128 - ] { 129 - const { 130 - data: suggestions, 131 - hasNextPage, 132 - isFetchingNextPage, 133 - isError, 134 - fetchNextPage, 135 - } = useSuggestedFollowsQuery() 136 - 137 - const onEndReached = React.useCallback(async () => { 138 - if (isFetchingNextPage || !hasNextPage || isError) return 139 - try { 140 - await fetchNextPage() 141 - } catch (err) { 142 - logger.error('Failed to load more suggested follows', {message: err}) 143 - } 144 - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 145 - 146 - const items: AppBskyActorDefs.ProfileViewBasic[] = [] 147 - if (suggestions) { 148 - // Currently the responses contain duplicate items. 149 - // Needs to be fixed on backend, but let's dedupe to be safe. 150 - let seen = new Set() 151 - for (const page of suggestions.pages) { 152 - for (const actor of page.actors) { 153 - if (!seen.has(actor.did)) { 154 - seen.add(actor.did) 155 - items.push(actor) 156 - } 157 - } 158 - } 159 - } 160 - return [items, onEndReached] 161 - } 162 - 163 - let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { 164 - const pal = usePalette('default') 165 - const [suggestions, onEndReached] = useSuggestedFollows() 166 - 167 - return suggestions.length ? ( 168 - <List 169 - data={suggestions} 170 - renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />} 171 - keyExtractor={item => item.did} 172 - // @ts-ignore web only -prf 173 - desktopFixedHeight 174 - contentContainerStyle={{paddingBottom: 200}} 175 - keyboardShouldPersistTaps="handled" 176 - keyboardDismissMode="on-drag" 177 - onEndReached={onEndReached} 178 - onEndReachedThreshold={2} 179 - /> 180 - ) : ( 181 - <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> 182 - <ProfileCardFeedLoadingPlaceholder /> 183 - <ProfileCardFeedLoadingPlaceholder /> 184 - </CenteredView> 185 - ) 186 - } 187 - SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) 188 124 189 125 type SearchResultSlice = 190 126 | { ··· 342 278 } 343 279 SearchScreenUserResults = React.memo(SearchScreenUserResults) 344 280 281 + let SearchScreenFeedsResults = ({ 282 + query, 283 + active, 284 + }: { 285 + query: string 286 + active: boolean 287 + }): React.ReactNode => { 288 + const {_} = useLingui() 289 + const {hasSession} = useSession() 290 + 291 + const {data: results, isFetched} = usePopularFeedsSearch({ 292 + query, 293 + enabled: active, 294 + }) 295 + 296 + return isFetched && results ? ( 297 + <> 298 + {results.length ? ( 299 + <List 300 + data={results} 301 + renderItem={({item}) => ( 302 + <FeedSourceCard 303 + feedUri={item.uri} 304 + showSaveBtn={hasSession} 305 + showDescription 306 + showLikes 307 + pinOnSave 308 + /> 309 + )} 310 + keyExtractor={item => item.did} 311 + // @ts-ignore web only -prf 312 + desktopFixedHeight 313 + contentContainerStyle={{paddingBottom: 100}} 314 + /> 315 + ) : ( 316 + <EmptyState message={_(msg`No results found for ${query}`)} /> 317 + )} 318 + </> 319 + ) : ( 320 + <Loader /> 321 + ) 322 + } 323 + SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) 324 + 345 325 let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { 346 326 const pal = usePalette('default') 347 327 const setMinimalShellMode = useSetMinimalShellMode() ··· 389 369 <SearchScreenUserResults query={query} active={activeTab === 2} /> 390 370 ), 391 371 }, 372 + { 373 + title: _(msg`Feeds`), 374 + component: ( 375 + <SearchScreenFeedsResults query={query} active={activeTab === 3} /> 376 + ), 377 + }, 392 378 ] 393 379 }, [_, query, activeTab]) 394 380 ··· 408 394 ))} 409 395 </Pager> 410 396 ) : hasSession ? ( 411 - <View> 412 - <CenteredView sideBorders style={pal.border}> 413 - <Text 414 - type="title" 415 - style={[ 416 - pal.text, 417 - pal.border, 418 - { 419 - display: 'flex', 420 - paddingVertical: 12, 421 - paddingHorizontal: 18, 422 - fontWeight: 'bold', 423 - }, 424 - ]}> 425 - <Trans>Suggested Follows</Trans> 426 - </Text> 427 - </CenteredView> 428 - 429 - <SearchScreenSuggestedFollows /> 430 - </View> 397 + <Explore /> 431 398 ) : ( 432 399 <CenteredView sideBorders style={pal.border}> 433 400 <View