Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[Explore] Base (#8053)

* migrate to #/screens

* rm unneeded import

* block drawer gesture on recent profiles

* rm recommendations (#8056)

* [Explore] Disable Trending videos (#8054)

* remove giant header

* disable

* [Explore] Dynamic module ordering (#8066)

* Dynamic module ordering

* [Explore] New headers, metrics (#8067)

* new sticky headers

* improve spacing between modules

* view metric on modules

* update metrics names

* [Explore] Suggested accounts module (#8072)

* use modern profile card, update load more

* add tab bar

* tabbed suggested accounts

* [Explore] Discover feeds module (#8073)

* cap number of feeds to 3

* change feed pin button

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* restore statsig to log events

* filter out followed profiles, make suer enough are loaded (#8090)

* [Explore] Trending topics (#8055)

* redesigned trending topics

* rm borders on web

* get post count / age / ranking from api

* spacing tweaks

* fetch more topics then slice

* use api data for avis/category

* rm top border

* Integrate new SDK, part out components

* Clean up

* Use status field

* Bump SDK

* Send up interests and langs

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Clean up module spacing and borders

(cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c)
(cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1)

* Switch back border ordering

(cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9)

* [Explore] Starter Packs (#8095)

* Temp WIP

(cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18)

* New SP card

* Load state

* Revert change

* Cleanup

* Interests and caching

* Count total

* Format

* Caching

* [Explore] Feed previews module (#8075)

* wip new hook

* get fetching working, maybe

* get feed previews rendering!

* fix header height

* working pin button

* extract out FeedLink

* add loader

* only make preview:header sticky

* Fix headers

* Header tweaks

* Fix moderation filter

* Fix threading

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Space it out

* Fix query key

* Mock new endpoint, filter saved feeds

* Make sure we're pinning, lower cache time

* add news category

* Remove log

* Improve suggested accounts load state

* Integrate new app view endpoint

* fragment

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Search/modules/ExploreTrendingTopics.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* lint

* maybe fix this

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Hailey <me@haileyok.com>

authored by samuel.fm

surfdude29
Eric Bailey
Hailey
and committed by
GitHub
87da619a 8d1f97b5

+3834 -2178
+1
assets/icons/flame_stroke2_corner1_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="M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z" clip-rule="evenodd"/></svg>
+1
assets/icons/trending3_stroke2_corner1_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="M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
+1 -1
package.json
··· 58 "icons:optimize": "svgo -f ./assets/icons" 59 }, 60 "dependencies": { 61 - "@atproto/api": "^0.14.16", 62 "@bitdrift/react-native": "^0.6.8", 63 "@braintree/sanitize-url": "^6.0.2", 64 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
··· 58 "icons:optimize": "svgo -f ./assets/icons" 59 }, 60 "dependencies": { 61 + "@atproto/api": "^0.14.19", 62 "@bitdrift/react-native": "^0.6.8", 63 "@braintree/sanitize-url": "^6.0.2", 64 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+12 -13
src/Navigation.tsx
··· 1 import * as React from 'react' 2 - import {JSX} from 'react/jsx-runtime' 3 - import {i18n, MessageDescriptor} from '@lingui/core' 4 import {msg} from '@lingui/macro' 5 import { 6 - BottomTabBarProps, 7 createBottomTabNavigator, 8 } from '@react-navigation/bottom-tabs' 9 import { ··· 20 import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' 21 import {buildStateObject} from '#/lib/routes/helpers' 22 import { 23 - AllNavigatorParams, 24 - BottomTabNavigatorParams, 25 - FlatNavigatorParams, 26 - HomeTabNavigatorParams, 27 - MessagesTabNavigatorParams, 28 - MyProfileTabNavigatorParams, 29 - NotificationsTabNavigatorParams, 30 - SearchTabNavigatorParams, 31 } from '#/lib/routes/types' 32 - import {RouteParams, State} from '#/lib/routes/types' 33 import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' 34 import {bskyTitle} from '#/lib/strings/headings' 35 import {logger} from '#/logger' ··· 59 import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' 60 import {ProfileListScreen} from '#/view/screens/ProfileList' 61 import {SavedFeeds} from '#/view/screens/SavedFeeds' 62 - import {SearchScreen} from '#/view/screens/Search' 63 import {Storybook} from '#/view/screens/Storybook' 64 import {SupportScreen} from '#/view/screens/Support' 65 import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' ··· 81 import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' 82 import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' 83 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 84 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' 85 import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' 86 import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
··· 1 import * as React from 'react' 2 + import {i18n, type MessageDescriptor} from '@lingui/core' 3 import {msg} from '@lingui/macro' 4 import { 5 + type BottomTabBarProps, 6 createBottomTabNavigator, 7 } from '@react-navigation/bottom-tabs' 8 import { ··· 19 import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' 20 import {buildStateObject} from '#/lib/routes/helpers' 21 import { 22 + type AllNavigatorParams, 23 + type BottomTabNavigatorParams, 24 + type FlatNavigatorParams, 25 + type HomeTabNavigatorParams, 26 + type MessagesTabNavigatorParams, 27 + type MyProfileTabNavigatorParams, 28 + type NotificationsTabNavigatorParams, 29 + type SearchTabNavigatorParams, 30 } from '#/lib/routes/types' 31 + import {type RouteParams, type State} from '#/lib/routes/types' 32 import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' 33 import {bskyTitle} from '#/lib/strings/headings' 34 import {logger} from '#/logger' ··· 58 import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' 59 import {ProfileListScreen} from '#/view/screens/ProfileList' 60 import {SavedFeeds} from '#/view/screens/SavedFeeds' 61 import {Storybook} from '#/view/screens/Storybook' 62 import {SupportScreen} from '#/view/screens/Support' 63 import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' ··· 79 import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' 80 import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' 81 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 82 + import {SearchScreen} from '#/screens/Search' 83 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' 84 import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' 85 import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
+48 -20
src/components/FeedCard.tsx
··· 1 import React from 'react' 2 - import {GestureResponderEvent, View} from 'react-native' 3 import { 4 - AppBskyFeedDefs, 5 - AppBskyGraphDefs, 6 AtUri, 7 RichText as RichTextApi, 8 } from '@atproto/api' ··· 23 import {UserAvatar} from '#/view/com/util/UserAvatar' 24 import {useTheme} from '#/alf' 25 import {atoms as a} from '#/alf' 26 - import {Button, ButtonIcon} from '#/components/Button' 27 - import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 28 - import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 29 - import {Link as InternalLink, LinkProps} from '#/components/Link' 30 import {Loader} from '#/components/Loader' 31 import * as Prompt from '#/components/Prompt' 32 - import {RichText, RichTextProps} from '#/components/RichText' 33 import {Text} from '#/components/Typography' 34 - import * as bsky from '#/types/bsky' 35 36 type Props = { 37 view: AppBskyFeedDefs.GeneratorView ··· 81 } 82 83 export function Outer({children}: {children: React.ReactNode}) { 84 - return <View style={[a.w_full, a.gap_md]}>{children}</View> 85 } 86 87 export function Header({children}: {children: React.ReactNode}) { 88 - return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> 89 } 90 91 export type AvatarProps = {src: string | undefined; size?: number} ··· 220 export function SaveButton({ 221 view, 222 pin, 223 }: { 224 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 225 pin?: boolean 226 - }) { 227 const {hasSession} = useSession() 228 if (!hasSession) return null 229 - return <SaveButtonInner view={view} pin={pin} /> 230 } 231 232 function SaveButtonInner({ 233 view, 234 pin, 235 }: { 236 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 237 pin?: boolean 238 - }) { 239 const {_} = useLingui() 240 const {data: preferences} = usePreferencesQuery() 241 const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = ··· 294 disabled={isPending} 295 label={_(msg`Add this feed to your feeds`)} 296 size="small" 297 - variant="ghost" 298 - color="secondary" 299 - shape="square" 300 - onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> 301 {savedFeedConfig ? ( 302 - <ButtonIcon size="md" icon={isPending ? Loader : Trash} /> 303 ) : ( 304 - <ButtonIcon size="md" icon={isPending ? Loader : Plus} /> 305 )} 306 </Button> 307
··· 1 import React from 'react' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 import { 4 + type AppBskyFeedDefs, 5 + type AppBskyGraphDefs, 6 AtUri, 7 RichText as RichTextApi, 8 } from '@atproto/api' ··· 23 import {UserAvatar} from '#/view/com/util/UserAvatar' 24 import {useTheme} from '#/alf' 25 import {atoms as a} from '#/alf' 26 + import { 27 + Button, 28 + ButtonIcon, 29 + type ButtonProps, 30 + ButtonText, 31 + } from '#/components/Button' 32 + import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 33 + import {Link as InternalLink, type LinkProps} from '#/components/Link' 34 import {Loader} from '#/components/Loader' 35 import * as Prompt from '#/components/Prompt' 36 + import {RichText, type RichTextProps} from '#/components/RichText' 37 import {Text} from '#/components/Typography' 38 + import type * as bsky from '#/types/bsky' 39 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash' 40 41 type Props = { 42 view: AppBskyFeedDefs.GeneratorView ··· 86 } 87 88 export function Outer({children}: {children: React.ReactNode}) { 89 + return <View style={[a.w_full, a.gap_sm]}>{children}</View> 90 } 91 92 export function Header({children}: {children: React.ReactNode}) { 93 + return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View> 94 } 95 96 export type AvatarProps = {src: string | undefined; size?: number} ··· 225 export function SaveButton({ 226 view, 227 pin, 228 + ...props 229 }: { 230 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 231 pin?: boolean 232 + text?: boolean 233 + } & Partial<ButtonProps>) { 234 const {hasSession} = useSession() 235 if (!hasSession) return null 236 + return <SaveButtonInner view={view} pin={pin} {...props} /> 237 } 238 239 function SaveButtonInner({ 240 view, 241 pin, 242 + text = true, 243 + ...buttonProps 244 }: { 245 view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView 246 pin?: boolean 247 + text?: boolean 248 + } & Partial<ButtonProps>) { 249 const {_} = useLingui() 250 const {data: preferences} = usePreferencesQuery() 251 const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = ··· 304 disabled={isPending} 305 label={_(msg`Add this feed to your feeds`)} 306 size="small" 307 + variant="solid" 308 + color={savedFeedConfig ? 'secondary' : 'primary'} 309 + onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave} 310 + {...buttonProps}> 311 {savedFeedConfig ? ( 312 + <> 313 + {isPending ? ( 314 + <ButtonIcon size="md" icon={Loader} /> 315 + ) : ( 316 + !text && <ButtonIcon size="md" icon={TrashIcon} /> 317 + )} 318 + {text && ( 319 + <ButtonText> 320 + <Trans>Unpin Feed</Trans> 321 + </ButtonText> 322 + )} 323 + </> 324 ) : ( 325 + <> 326 + <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} /> 327 + {text && ( 328 + <ButtonText> 329 + <Trans>Pin Feed</Trans> 330 + </ButtonText> 331 + )} 332 + </> 333 )} 334 </Button> 335
+23 -12
src/components/ProfileCard.tsx
··· 1 import React from 'react' 2 - import {GestureResponderEvent, View} from 'react-native' 3 import { 4 moderateProfile, 5 - ModerationOpts, 6 RichText as RichTextApi, 7 } from '@atproto/api' 8 import {msg} from '@lingui/macro' 9 import {useLingui} from '@lingui/react' 10 11 - import {LogEvents} from '#/lib/statsig/statsig' 12 import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 import {sanitizeHandle} from '#/lib/strings/handles' 14 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 18 import * as Toast from '#/view/com/util/Toast' 19 import {UserAvatar} from '#/view/com/util/UserAvatar' 20 import {atoms as a, useTheme} from '#/alf' 21 - import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button' 22 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 23 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 24 - import {Link as InternalLink, LinkProps} from '#/components/Link' 25 import {RichText} from '#/components/RichText' 26 import {Text} from '#/components/Typography' 27 - import * as bsky from '#/types/bsky' 28 29 export function Default({ 30 profile, ··· 133 134 return ( 135 <UserAvatar 136 - size={42} 137 avatar={profile.avatar} 138 type={profile.associated?.labeler ? 'labeler' : 'user'} 139 moderation={moderation.ui('avatar')} ··· 149 a.rounded_full, 150 t.atoms.bg_contrast_50, 151 { 152 - width: 42, 153 - height: 42, 154 }, 155 ]} 156 /> ··· 261 }) { 262 const t = useTheme() 263 return ( 264 - <View style={[{gap: 8}]}> 265 {Array(numberOfLines) 266 .fill(0) 267 .map((_, i) => ( ··· 286 LogEvents['profile:unfollow']['logContext'] 287 colorInverted?: boolean 288 onFollow?: () => void 289 } & Partial<ButtonProps> 290 291 export function FollowButton(props: FollowButtonProps) { ··· 301 onPress: onPressProp, 302 onFollow, 303 colorInverted, 304 ...rest 305 }: FollowButtonProps) { 306 const {_} = useLingui() ··· 386 color="secondary" 387 {...rest} 388 onPress={onPressUnfollow}> 389 - <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 390 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 391 </Button> 392 ) : ( ··· 397 color={colorInverted ? 'secondary_inverted' : 'primary'} 398 {...rest} 399 onPress={onPressFollow}> 400 - <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 401 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 402 </Button> 403 )}
··· 1 import React from 'react' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 import { 4 moderateProfile, 5 + type ModerationOpts, 6 RichText as RichTextApi, 7 } from '@atproto/api' 8 import {msg} from '@lingui/macro' 9 import {useLingui} from '@lingui/react' 10 11 + import {type LogEvents} from '#/lib/statsig/statsig' 12 import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 import {sanitizeHandle} from '#/lib/strings/handles' 14 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 18 import * as Toast from '#/view/com/util/Toast' 19 import {UserAvatar} from '#/view/com/util/UserAvatar' 20 import {atoms as a, useTheme} from '#/alf' 21 + import { 22 + Button, 23 + ButtonIcon, 24 + type ButtonProps, 25 + ButtonText, 26 + } from '#/components/Button' 27 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 28 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 29 + import {Link as InternalLink, type LinkProps} from '#/components/Link' 30 import {RichText} from '#/components/RichText' 31 import {Text} from '#/components/Typography' 32 + import type * as bsky from '#/types/bsky' 33 34 export function Default({ 35 profile, ··· 138 139 return ( 140 <UserAvatar 141 + size={40} 142 avatar={profile.avatar} 143 type={profile.associated?.labeler ? 'labeler' : 'user'} 144 moderation={moderation.ui('avatar')} ··· 154 a.rounded_full, 155 t.atoms.bg_contrast_50, 156 { 157 + width: 40, 158 + height: 40, 159 }, 160 ]} 161 /> ··· 266 }) { 267 const t = useTheme() 268 return ( 269 + <View style={[a.pt_2xs, {gap: 6}]}> 270 {Array(numberOfLines) 271 .fill(0) 272 .map((_, i) => ( ··· 291 LogEvents['profile:unfollow']['logContext'] 292 colorInverted?: boolean 293 onFollow?: () => void 294 + withIcon?: boolean 295 } & Partial<ButtonProps> 296 297 export function FollowButton(props: FollowButtonProps) { ··· 307 onPress: onPressProp, 308 onFollow, 309 colorInverted, 310 + withIcon = true, 311 ...rest 312 }: FollowButtonProps) { 313 const {_} = useLingui() ··· 393 color="secondary" 394 {...rest} 395 onPress={onPressUnfollow}> 396 + {withIcon && ( 397 + <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 398 + )} 399 {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 400 </Button> 401 ) : ( ··· 406 color={colorInverted ? 'secondary_inverted' : 'primary'} 407 {...rest} 408 onPress={onPressFollow}> 409 + {withIcon && ( 410 + <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 411 + )} 412 {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 413 </Button> 414 )}
+9 -6
src/components/ProgressGuide/FollowDialog.tsx
··· 5 LinearTransition, 6 ZoomInEasyDown, 7 } from 'react-native-reanimated' 8 - import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' 9 import {msg, Trans} from '@lingui/macro' 10 import {useLingui} from '@lingui/react' 11 ··· 19 import {usePreferencesQuery} from '#/state/queries/preferences' 20 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 21 import {useSession} from '#/state/session' 22 - import {Follow10ProgressGuide} from '#/state/shell/progress-guide' 23 - import {ListMethods} from '#/view/com/util/List' 24 import { 25 popularInterests, 26 useInterestsDisplayNames, ··· 31 tokens, 32 useBreakpoints, 33 useTheme, 34 - ViewStyleProp, 35 web, 36 } from '#/alf' 37 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 452 selectedInterest, 453 hasSearchText, 454 interestsDisplayNames, 455 }: { 456 onSelectTab: (tab: string) => void 457 interests: string[] 458 selectedInterest: string 459 hasSearchText: boolean 460 interestsDisplayNames: Record<string, string> 461 }): React.ReactNode => { 462 const listRef = useRef<ScrollView>(null) 463 const [scrollX, setScrollX] = useState(0) ··· 532 {interests.map((interest, i) => { 533 const active = interest === selectedInterest && !hasSearchText 534 return ( 535 - <Tab 536 key={interest} 537 onSelectTab={handleSelectTab} 538 active={active} ··· 547 ) 548 } 549 Tabs = memo(Tabs) 550 551 let Tab = ({ 552 onSelectTab, ··· 822 ) 823 } 824 825 - function boostInterests(boosts?: string[]) { 826 return (_a: string, _b: string) => { 827 const indexA = boosts?.indexOf(_a) ?? -1 828 const indexB = boosts?.indexOf(_b) ?? -1
··· 5 LinearTransition, 6 ZoomInEasyDown, 7 } from 'react-native-reanimated' 8 + import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' 9 import {msg, Trans} from '@lingui/macro' 10 import {useLingui} from '@lingui/react' 11 ··· 19 import {usePreferencesQuery} from '#/state/queries/preferences' 20 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 21 import {useSession} from '#/state/session' 22 + import {type Follow10ProgressGuide} from '#/state/shell/progress-guide' 23 + import {type ListMethods} from '#/view/com/util/List' 24 import { 25 popularInterests, 26 useInterestsDisplayNames, ··· 31 tokens, 32 useBreakpoints, 33 useTheme, 34 + type ViewStyleProp, 35 web, 36 } from '#/alf' 37 import {Button, ButtonIcon, ButtonText} from '#/components/Button' ··· 452 selectedInterest, 453 hasSearchText, 454 interestsDisplayNames, 455 + TabComponent = Tab, 456 }: { 457 onSelectTab: (tab: string) => void 458 interests: string[] 459 selectedInterest: string 460 hasSearchText: boolean 461 interestsDisplayNames: Record<string, string> 462 + TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> 463 }): React.ReactNode => { 464 const listRef = useRef<ScrollView>(null) 465 const [scrollX, setScrollX] = useState(0) ··· 534 {interests.map((interest, i) => { 535 const active = interest === selectedInterest && !hasSearchText 536 return ( 537 + <TabComponent 538 key={interest} 539 onSelectTab={handleSelectTab} 540 active={active} ··· 549 ) 550 } 551 Tabs = memo(Tabs) 552 + export {Tabs} 553 554 let Tab = ({ 555 onSelectTab, ··· 825 ) 826 } 827 828 + export function boostInterests(boosts?: string[]) { 829 return (_a: string, _b: string) => { 830 const indexA = boosts?.indexOf(_a) ?? -1 831 const indexB = boosts?.indexOf(_b) ?? -1
+30 -1
src/components/StarterPack/StarterPackCard.tsx
··· 13 import {useSession} from '#/state/session' 14 import {atoms as a, useTheme} from '#/alf' 15 import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' 16 - import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link' 17 import {Text} from '#/components/Typography' 18 import * as bsky from '#/types/bsky' 19 ··· 102 )} 103 </View> 104 ) 105 } 106 107 export function Link({
··· 13 import {useSession} from '#/state/session' 14 import {atoms as a, useTheme} from '#/alf' 15 import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' 16 + import { 17 + Link as BaseLink, 18 + type LinkProps as BaseLinkProps, 19 + } from '#/components/Link' 20 import {Text} from '#/components/Typography' 21 import * as bsky from '#/types/bsky' 22 ··· 105 )} 106 </View> 107 ) 108 + } 109 + 110 + export function useStarterPackLink({ 111 + view, 112 + }: { 113 + view: bsky.starterPack.AnyStarterPackView 114 + }) { 115 + const {_} = useLingui() 116 + const qc = useQueryClient() 117 + const {rkey, handleOrDid} = React.useMemo(() => { 118 + const rkey = new AtUri(view.uri).rkey 119 + const {creator} = view 120 + return {rkey, handleOrDid: creator.handle || creator.did} 121 + }, [view]) 122 + const precache = () => { 123 + precacheResolvedUri(qc, view.creator.handle, view.creator.did) 124 + precacheStarterPack(qc, view) 125 + } 126 + 127 + return { 128 + to: `/starter-pack/${handleOrDid}/${rkey}`, 129 + label: AppBskyGraphStarterpack.isRecord(view.record) 130 + ? _(msg`Navigate to ${view.record.name}`) 131 + : _(msg`Navigate to starter pack`), 132 + precache, 133 + } 134 } 135 136 export function Link({
+5
src/components/icons/Flame.tsx
···
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Flame_Stroke2_Corner1_Rounded = createSinglePathSVG({ 4 + path: 'M11.158 2.879c.584-.835 1.757-1.137 2.673-.507.951.654 2.597 1.92 4.013 3.694S20.5 10.194 20.5 13c0 4.997-3.752 9-8.5 9s-8.5-4.003-8.5-9c0-2.035.874-4.636 2.578-6.712.746-.91 2.034-.855 2.786-.133l2.294-3.276Zm-3.04 15.758C6.538 17.386 5.5 15.37 5.5 13c0-1.511.666-3.616 2.042-5.342.87.797 2.254.653 2.939-.325l2.286-3.265c.871.606 2.299 1.723 3.514 3.246C17.53 8.879 18.5 10.804 18.5 13c0 2.369-1.038 4.386-2.618 5.637q.117-.518.118-1.061c0-2.601-2.038-4.382-2.911-5.04a1.8 1.8 0 0 0-2.177 0C10.038 13.195 8 14.976 8 17.577q0 .543.118 1.061ZM12 14.222c-.825.648-2 1.859-2 3.354C10 19.043 11.016 20 12 20s2-.957 2-2.424c0-1.495-1.175-2.706-2-3.354Z', 5 + })
+4
src/components/icons/Trending2.tsx src/components/icons/Trending.tsx
··· 3 export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z', 5 })
··· 3 export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z', 5 }) 6 + 7 + export const Trending3_Stroke2_Corner1_Rounded = createSinglePathSVG({ 8 + path: 'M15 7a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V9.414L14.414 15a2 2 0 0 1-2.828 0L9 12.414l-5.293 5.293a1 1 0 0 1-1.414-1.414L7.586 11a2 2 0 0 1 2.828 0L13 13.586 18.586 8H16a1 1 0 0 1-1-1Z', 9 + })
+3 -3
src/components/icons/common.tsx
··· 1 - import {StyleSheet, TextProps} from 'react-native' 2 - import type {PathProps, SvgProps} from 'react-native-svg' 3 import {Defs, LinearGradient, Stop} from 'react-native-svg' 4 import {nanoid} from 'nanoid/non-secure' 5 ··· 19 lg: 24, 20 xl: 28, 21 '2xl': 32, 22 - } 23 24 export function useCommonSVGProps(props: Props) { 25 const t = useTheme()
··· 1 + import {StyleSheet, type TextProps} from 'react-native' 2 + import {type PathProps, type SvgProps} from 'react-native-svg' 3 import {Defs, LinearGradient, Stop} from 'react-native-svg' 4 import {nanoid} from 'nanoid/non-secure' 5 ··· 19 lg: 24, 20 xl: 28, 21 '2xl': 32, 22 + } as const 23 24 export function useCommonSVGProps(props: Props) { 25 const t = useTheme()
+1 -1
src/components/interstitials/Trending.tsx
··· 15 import {atoms as a, useGutters, useTheme} from '#/alf' 16 import {Button, ButtonIcon} from '#/components/Button' 17 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 18 - import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 19 import * as Prompt from '#/components/Prompt' 20 import {TrendingTopicLink} from '#/components/TrendingTopics' 21 import {Text} from '#/components/Typography'
··· 15 import {atoms as a, useGutters, useTheme} from '#/alf' 16 import {Button, ButtonIcon} from '#/components/Button' 17 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 18 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 19 import * as Prompt from '#/components/Prompt' 20 import {TrendingTopicLink} from '#/components/TrendingTopics' 21 import {Text} from '#/components/Typography'
+1 -1
src/components/interstitials/TrendingVideos.tsx
··· 16 import {Button, ButtonIcon} from '#/components/Button' 17 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 18 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 19 - import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 20 import {Link} from '#/components/Link' 21 import * as Prompt from '#/components/Prompt' 22 import {Text} from '#/components/Typography'
··· 16 import {Button, ButtonIcon} from '#/components/Button' 17 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 18 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 19 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 20 import {Link} from '#/components/Link' 21 import * as Prompt from '#/components/Prompt' 22 import {Text} from '#/components/Typography'
+1 -1
src/lib/icons.tsx
··· 1 - import {StyleProp, TextStyle, ViewStyle} from 'react-native' 2 import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg' 3 4 // Copyright (c) 2020 Refactoring UI Inc.
··· 1 + import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native' 2 import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg' 3 4 // Copyright (c) 2020 Refactoring UI Inc.
+1
src/lib/statsig/gates.ts
··· 2 // Keep this alphabetic please. 3 | 'debug_show_feedcontext' 4 | 'debug_subscriptions' 5 | 'old_postonboarding' 6 | 'onboarding_add_video_feed' 7 | 'remove_show_latest_button'
··· 2 // Keep this alphabetic please. 3 | 'debug_show_feedcontext' 4 | 'debug_subscriptions' 5 + | 'explore_show_suggested_feeds' 6 | 'old_postonboarding' 7 | 'onboarding_add_video_feed' 8 | 'remove_show_latest_button'
+20
src/logger/metrics.ts
··· 1 export type MetricEvents = { 2 // App events 3 init: { ··· 202 | 'ProfileHeaderSuggestedFollows' 203 | 'PostOnboardingFindFollows' 204 | 'ImmersiveVideo' 205 } 206 'suggestedUser:follow': { 207 logContext: ··· 239 | 'ProfileHeaderSuggestedFollows' 240 | 'PostOnboardingFindFollows' 241 | 'ImmersiveVideo' 242 } 243 'chat:create': { 244 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' ··· 316 } 317 'videoCard:click': { 318 context: 'interstitial:discover' | 'interstitial:explore' | 'feed' 319 } 320 321 'progressGuide:hide': {}
··· 1 + import {type FeedDescriptor} from '#/state/queries/post-feed' 2 + 3 export type MetricEvents = { 4 // App events 5 init: { ··· 204 | 'ProfileHeaderSuggestedFollows' 205 | 'PostOnboardingFindFollows' 206 | 'ImmersiveVideo' 207 + | 'ExploreSuggestedAccounts' 208 } 209 'suggestedUser:follow': { 210 logContext: ··· 242 | 'ProfileHeaderSuggestedFollows' 243 | 'PostOnboardingFindFollows' 244 | 'ImmersiveVideo' 245 + | 'ExploreSuggestedAccounts' 246 } 247 'chat:create': { 248 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' ··· 320 } 321 'videoCard:click': { 322 context: 'interstitial:discover' | 'interstitial:explore' | 'feed' 323 + } 324 + 325 + 'explore:module:seen': { 326 + module: 327 + | 'trendingTopics' 328 + | 'trendingVideos' 329 + | 'suggestedAccounts' 330 + | 'suggestedFeeds' 331 + | 'suggestedStarterPacks' 332 + | `feed:${FeedDescriptor}` 333 + } 334 + 'explore:module:searchButtonPress': { 335 + module: 'suggestedAccounts' | 'suggestedFeeds' 336 + } 337 + 'explore:suggestedAccounts:tabPressed': { 338 + tab: string 339 } 340 341 'progressGuide:hide': {}
+5 -5
src/screens/Onboarding/StepFinished.tsx
··· 1 import React from 'react' 2 import {View} from 'react-native' 3 import { 4 - AppBskyActorProfile, 5 - AppBskyGraphDefs, 6 AppBskyGraphStarterpack, 7 - Un$Typed, 8 } from '@atproto/api' 9 - import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 10 import {TID} from '@atproto/common-web' 11 import {msg, Trans} from '@lingui/macro' 12 import {useLingui} from '@lingui/react' ··· 46 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 47 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 48 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' 49 - import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' 50 import {Loader} from '#/components/Loader' 51 import {Text} from '#/components/Typography' 52 import * as bsky from '#/types/bsky'
··· 1 import React from 'react' 2 import {View} from 'react-native' 3 import { 4 + type AppBskyActorProfile, 5 + type AppBskyGraphDefs, 6 AppBskyGraphStarterpack, 7 + type Un$Typed, 8 } from '@atproto/api' 9 + import {type SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 10 import {TID} from '@atproto/common-web' 11 import {msg, Trans} from '@lingui/macro' 12 import {useLingui} from '@lingui/react' ··· 46 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 47 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 48 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' 49 + import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending' 50 import {Loader} from '#/components/Loader' 51 import {Text} from '#/components/Typography' 52 import * as bsky from '#/types/bsky'
+5 -2
src/screens/Profile/ProfileSearch.tsx
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 6 import {useProfileQuery} from '#/state/queries/profile' 7 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 8 import {useSession} from '#/state/session' 9 - import {SearchScreenShell} from '#/view/screens/Search/Search' 10 11 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'> 12 export const ProfileSearchScreen = ({route}: Props) => {
··· 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 + import { 6 + type CommonNavigatorParams, 7 + type NativeStackScreenProps, 8 + } from '#/lib/routes/types' 9 import {useProfileQuery} from '#/state/queries/profile' 10 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 11 import {useSession} from '#/state/session' 12 + import {SearchScreenShell} from '#/screens/Search/Shell' 13 14 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileSearch'> 15 export const ProfileSearchScreen = ({route}: Props) => {
+923
src/screens/Search/Explore.tsx
···
··· 1 + import {useCallback, useMemo, useRef, useState} from 'react' 2 + import {View, type ViewabilityConfig, type ViewToken} from 'react-native' 3 + import { 4 + type AppBskyActorDefs, 5 + type AppBskyFeedDefs, 6 + type AppBskyGraphDefs, 7 + } from '@atproto/api' 8 + import {msg, Trans} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {useGate} from '#/lib/statsig/statsig' 12 + import {cleanError} from '#/lib/strings/errors' 13 + import {sanitizeHandle} from '#/lib/strings/handles' 14 + import {logger} from '#/logger' 15 + import {type MetricEvents} from '#/logger/metrics' 16 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 17 + import {useActorSearchPaginated} from '#/state/queries/actor-search' 18 + import {useGetPopularFeedsQuery} from '#/state/queries/feed' 19 + import {usePreferencesQuery} from '#/state/queries/preferences' 20 + import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 21 + import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery' 22 + import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery' 23 + import {useProgressGuide} from '#/state/shell/progress-guide' 24 + import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed' 25 + import {PostFeedItem} from '#/view/com/posts/PostFeedItem' 26 + import {ViewFullThread} from '#/view/com/posts/ViewFullThread' 27 + import {List} from '#/view/com/util/List' 28 + import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 29 + import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 30 + import { 31 + StarterPackCard, 32 + StarterPackCardSkeleton, 33 + } from '#/screens/Search/components/StarterPackCard' 34 + import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' 35 + import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' 36 + import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' 37 + import {atoms as a, native, useTheme, web} from '#/alf' 38 + import {Button} from '#/components/Button' 39 + import * as FeedCard from '#/components/FeedCard' 40 + import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' 41 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 42 + import {type Props as SVGIconProps} from '#/components/icons/common' 43 + import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 44 + import {StarterPack} from '#/components/icons/StarterPack' 45 + import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 46 + import {Loader} from '#/components/Loader' 47 + import * as ProfileCard from '#/components/ProfileCard' 48 + import {Text} from '#/components/Typography' 49 + import * as ModuleHeader from './components/ModuleHeader' 50 + import { 51 + type FeedPreviewItem, 52 + useFeedPreviews, 53 + } from './modules/ExploreFeedPreviews' 54 + import { 55 + SuggestedAccountsTabBar, 56 + SuggestedProfileCard, 57 + useLoadEnoughProfiles, 58 + } from './modules/ExploreSuggestedAccounts' 59 + 60 + function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { 61 + const t = useTheme() 62 + const {_} = useLingui() 63 + 64 + return ( 65 + <Button 66 + label={_(msg`Load more`)} 67 + onPress={item.onLoadMore} 68 + style={[a.relative, a.w_full]}> 69 + {({hovered, pressed}) => ( 70 + <View 71 + style={[ 72 + a.flex_1, 73 + a.flex_row, 74 + a.align_center, 75 + a.justify_center, 76 + a.px_lg, 77 + a.py_md, 78 + a.gap_sm, 79 + (hovered || pressed) && t.atoms.bg_contrast_25, 80 + ]}> 81 + <Text 82 + style={[ 83 + a.leading_snug, 84 + hovered ? t.atoms.text : t.atoms.text_contrast_medium, 85 + ]}> 86 + {item.message} 87 + </Text> 88 + {item.isLoadingMore ? ( 89 + <Loader size="sm" /> 90 + ) : ( 91 + <ChevronDownIcon 92 + size="sm" 93 + style={hovered ? t.atoms.text : t.atoms.text_contrast_medium} 94 + /> 95 + )} 96 + </View> 97 + )} 98 + </Button> 99 + ) 100 + } 101 + 102 + type ExploreScreenItems = 103 + | { 104 + type: 'topBorder' 105 + key: string 106 + } 107 + | { 108 + type: 'header' 109 + key: string 110 + title: string 111 + icon: React.ComponentType<SVGIconProps> 112 + searchButton?: { 113 + label: string 114 + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] 115 + tab: 'user' | 'profile' | 'feed' 116 + } 117 + } 118 + | { 119 + type: 'tabbedHeader' 120 + key: string 121 + title: string 122 + icon: React.ComponentType<SVGIconProps> 123 + searchButton?: { 124 + label: string 125 + metricsTag: MetricEvents['explore:module:searchButtonPress']['module'] 126 + tab: 'user' | 'profile' | 'feed' 127 + } 128 + } 129 + | { 130 + type: 'trendingTopics' 131 + key: string 132 + } 133 + | { 134 + type: 'trendingVideos' 135 + key: string 136 + } 137 + | { 138 + type: 'recommendations' 139 + key: string 140 + } 141 + | { 142 + type: 'profile' 143 + key: string 144 + profile: AppBskyActorDefs.ProfileView 145 + recId?: number 146 + } 147 + | { 148 + type: 'feed' 149 + key: string 150 + feed: AppBskyFeedDefs.GeneratorView 151 + } 152 + | { 153 + type: 'loadMore' 154 + key: string 155 + message: string 156 + isLoadingMore: boolean 157 + onLoadMore: () => void 158 + } 159 + | { 160 + type: 'profilePlaceholder' 161 + key: string 162 + } 163 + | { 164 + type: 'feedPlaceholder' 165 + key: string 166 + } 167 + | { 168 + type: 'error' 169 + key: string 170 + message: string 171 + error: string 172 + } 173 + | { 174 + type: 'starterPack' 175 + key: string 176 + view: AppBskyGraphDefs.StarterPackView 177 + } 178 + | { 179 + type: 'starterPackSkeleton' 180 + key: string 181 + } 182 + | FeedPreviewItem 183 + 184 + export function Explore({ 185 + focusSearchInput, 186 + headerHeight, 187 + }: { 188 + focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void 189 + headerHeight: number 190 + }) { 191 + const {_} = useLingui() 192 + const t = useTheme() 193 + const {data: preferences, error: preferencesError} = usePreferencesQuery() 194 + const moderationOpts = useModerationOpts() 195 + const gate = useGate() 196 + const guide = useProgressGuide('follow-10') 197 + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 198 + const { 199 + data: suggestedProfiles, 200 + hasNextPage: hasNextSuggestedProfilesPage, 201 + isLoading: isLoadingSuggestedProfiles, 202 + isFetchingNextPage: isFetchingNextSuggestedProfilesPage, 203 + error: suggestedProfilesError, 204 + fetchNextPage: fetchNextSuggestedProfilesPage, 205 + } = useSuggestedFollowsQuery({limit: 3, subsequentPageLimit: 10}) 206 + const { 207 + data: interestProfiles, 208 + hasNextPage: hasNextInterestProfilesPage, 209 + isLoading: isLoadingInterestProfiles, 210 + isFetchingNextPage: isFetchingNextInterestProfilesPage, 211 + error: interestProfilesError, 212 + fetchNextPage: fetchNextInterestProfilesPage, 213 + } = useActorSearchPaginated({ 214 + query: selectedInterest || '', 215 + enabled: !!selectedInterest, 216 + limit: 10, 217 + }) 218 + const {isReady: canShowSuggestedProfiles} = useLoadEnoughProfiles({ 219 + interest: selectedInterest, 220 + data: interestProfiles, 221 + isLoading: isLoadingInterestProfiles, 222 + isFetchingNextPage: isFetchingNextInterestProfilesPage, 223 + hasNextPage: hasNextInterestProfilesPage, 224 + fetchNextPage: fetchNextInterestProfilesPage, 225 + }) 226 + const { 227 + data: feeds, 228 + hasNextPage: hasNextFeedsPage, 229 + isLoading: isLoadingFeeds, 230 + isFetchingNextPage: isFetchingNextFeedsPage, 231 + error: feedsError, 232 + fetchNextPage: fetchNextFeedsPage, 233 + } = useGetPopularFeedsQuery({limit: 10}) 234 + 235 + const profiles: typeof suggestedProfiles & typeof interestProfiles = 236 + !selectedInterest ? suggestedProfiles : interestProfiles 237 + const hasNextProfilesPage = !selectedInterest 238 + ? hasNextSuggestedProfilesPage 239 + : hasNextInterestProfilesPage 240 + const isLoadingProfiles = !selectedInterest 241 + ? isLoadingSuggestedProfiles 242 + : !canShowSuggestedProfiles 243 + const isFetchingNextProfilesPage = !selectedInterest 244 + ? isFetchingNextSuggestedProfilesPage 245 + : !canShowSuggestedProfiles 246 + const profilesError = !selectedInterest 247 + ? suggestedProfilesError 248 + : interestProfilesError 249 + const fetchNextProfilesPage = !selectedInterest 250 + ? fetchNextSuggestedProfilesPage 251 + : fetchNextInterestProfilesPage 252 + 253 + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles 254 + const onLoadMoreProfiles = useCallback(async () => { 255 + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) 256 + return 257 + try { 258 + await fetchNextProfilesPage() 259 + } catch (err) { 260 + logger.error('Failed to load more suggested follows', {message: err}) 261 + } 262 + }, [ 263 + isFetchingNextProfilesPage, 264 + hasNextProfilesPage, 265 + profilesError, 266 + fetchNextProfilesPage, 267 + ]) 268 + const { 269 + data: suggestedSPs, 270 + isLoading: isLoadingSuggestedSPs, 271 + error: suggestedSPsError, 272 + } = useSuggestedStarterPacksQuery() 273 + 274 + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds 275 + const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false) 276 + const onLoadMoreFeeds = useCallback(async () => { 277 + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return 278 + if (!hasPressedLoadMoreFeeds) { 279 + setHasPressedLoadMoreFeeds(true) 280 + return 281 + } 282 + try { 283 + await fetchNextFeedsPage() 284 + } catch (err) { 285 + logger.error('Failed to load more suggested follows', {message: err}) 286 + } 287 + }, [ 288 + isFetchingNextFeedsPage, 289 + hasNextFeedsPage, 290 + feedsError, 291 + fetchNextFeedsPage, 292 + hasPressedLoadMoreFeeds, 293 + ]) 294 + 295 + const {data: suggestedFeeds} = useGetSuggestedFeedsQuery() 296 + const { 297 + data: feedPreviewSlices, 298 + query: { 299 + isPending: isPendingFeedPreviews, 300 + isFetchingNextPage: isFetchingNextPageFeedPreviews, 301 + fetchNextPage: fetchNextPageFeedPreviews, 302 + hasNextPage: hasNextPageFeedPreviews, 303 + error: feedPreviewSlicesError, 304 + }, 305 + } = useFeedPreviews(suggestedFeeds?.feeds ?? []) 306 + 307 + const onLoadMoreFeedPreviews = useCallback(async () => { 308 + if ( 309 + isPendingFeedPreviews || 310 + isFetchingNextPageFeedPreviews || 311 + !hasNextPageFeedPreviews || 312 + feedPreviewSlicesError 313 + ) 314 + return 315 + try { 316 + await fetchNextPageFeedPreviews() 317 + } catch (err) { 318 + logger.error('Failed to load more feed previews', {message: err}) 319 + } 320 + }, [ 321 + isPendingFeedPreviews, 322 + isFetchingNextPageFeedPreviews, 323 + hasNextPageFeedPreviews, 324 + feedPreviewSlicesError, 325 + fetchNextPageFeedPreviews, 326 + ]) 327 + 328 + const items = useMemo<ExploreScreenItems[]>(() => { 329 + const i: ExploreScreenItems[] = [] 330 + 331 + const addTopBorder = () => { 332 + i.push({type: 'topBorder', key: 'top-border'}) 333 + } 334 + 335 + const addTrendingTopicsModule = () => { 336 + i.push({ 337 + type: 'trendingTopics', 338 + key: `trending-topics`, 339 + }) 340 + 341 + // temp - disable trending videos 342 + // if (isNative) { 343 + // i.push({ 344 + // type: 'trendingVideos', 345 + // key: `trending-videos`, 346 + // }) 347 + // } 348 + } 349 + 350 + const addSuggestedFollowsModule = () => { 351 + i.push({ 352 + type: 'tabbedHeader', 353 + key: 'suggested-accounts-header', 354 + title: _(msg`Suggested Accounts`), 355 + icon: Person, 356 + searchButton: { 357 + label: _(msg`Search for more accounts`), 358 + metricsTag: 'suggestedAccounts', 359 + tab: 'user', 360 + }, 361 + }) 362 + 363 + if (!canShowSuggestedProfiles) { 364 + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 365 + } else if (profilesError) { 366 + i.push({ 367 + type: 'error', 368 + key: 'profilesError', 369 + message: _(msg`Failed to load suggested follows`), 370 + error: cleanError(profilesError), 371 + }) 372 + } else { 373 + if (profiles !== undefined) { 374 + if (profiles.pages.length > 0 && moderationOpts) { 375 + // Currently the responses contain duplicate items. 376 + // Needs to be fixed on backend, but let's dedupe to be safe. 377 + let seen = new Set() 378 + const profileItems: ExploreScreenItems[] = [] 379 + for (const page of profiles.pages) { 380 + for (const actor of page.actors) { 381 + if (!seen.has(actor.did) && !actor.viewer?.following) { 382 + seen.add(actor.did) 383 + profileItems.push({ 384 + type: 'profile', 385 + key: actor.did, 386 + profile: actor, 387 + recId: page.recId, 388 + }) 389 + } 390 + } 391 + } 392 + 393 + if (profileItems.length === 0) { 394 + if (!hasNextProfilesPage) { 395 + // no items! remove the header 396 + i.pop() 397 + } 398 + } else { 399 + i.push(...profileItems) 400 + } 401 + if (hasNextProfilesPage) { 402 + i.push({ 403 + type: 'loadMore', 404 + key: 'loadMoreProfiles', 405 + message: _(msg`Load more suggested accounts`), 406 + isLoadingMore: isLoadingMoreProfiles, 407 + onLoadMore: onLoadMoreProfiles, 408 + }) 409 + } 410 + } else { 411 + console.log('no pages') 412 + } 413 + } else { 414 + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 415 + } 416 + } 417 + } 418 + 419 + const addSuggestedFeedsModule = () => { 420 + i.push({ 421 + type: 'header', 422 + key: 'suggested-feeds-header', 423 + title: _(msg`Discover Feeds`), 424 + icon: ListSparkle, 425 + searchButton: { 426 + label: _(msg`Search for more feeds`), 427 + metricsTag: 'suggestedFeeds', 428 + tab: 'feed', 429 + }, 430 + }) 431 + 432 + if (feeds && preferences) { 433 + // Currently the responses contain duplicate items. 434 + // Needs to be fixed on backend, but let's dedupe to be safe. 435 + let seen = new Set() 436 + const feedItems: ExploreScreenItems[] = [] 437 + for (const page of feeds.pages) { 438 + for (const feed of page.feeds) { 439 + if (!seen.has(feed.uri)) { 440 + seen.add(feed.uri) 441 + feedItems.push({ 442 + type: 'feed', 443 + key: feed.uri, 444 + feed, 445 + }) 446 + } 447 + } 448 + } 449 + 450 + // feeds errors can occur during pagination, so feeds is truthy 451 + if (feedsError) { 452 + i.push({ 453 + type: 'error', 454 + key: 'feedsError', 455 + message: _(msg`Failed to load suggested feeds`), 456 + error: cleanError(feedsError), 457 + }) 458 + } else if (preferencesError) { 459 + i.push({ 460 + type: 'error', 461 + key: 'preferencesError', 462 + message: _(msg`Failed to load feeds preferences`), 463 + error: cleanError(preferencesError), 464 + }) 465 + } else { 466 + if (feedItems.length === 0) { 467 + if (!hasNextFeedsPage) { 468 + i.pop() 469 + } 470 + } else { 471 + // This query doesn't follow the limit very well, so the first press of the 472 + // load more button just unslices the array back to ~10 items 473 + if (!hasPressedLoadMoreFeeds) { 474 + i.push(...feedItems.slice(0, 3)) 475 + } else { 476 + i.push(...feedItems) 477 + } 478 + } 479 + if (hasNextFeedsPage) { 480 + i.push({ 481 + type: 'loadMore', 482 + key: 'loadMoreFeeds', 483 + message: _(msg`Load more suggested feeds`), 484 + isLoadingMore: isLoadingMoreFeeds, 485 + onLoadMore: onLoadMoreFeeds, 486 + }) 487 + } 488 + } 489 + } else { 490 + if (feedsError) { 491 + i.push({ 492 + type: 'error', 493 + key: 'feedsError', 494 + message: _(msg`Failed to load suggested feeds`), 495 + error: cleanError(feedsError), 496 + }) 497 + } else if (preferencesError) { 498 + i.push({ 499 + type: 'error', 500 + key: 'preferencesError', 501 + message: _(msg`Failed to load feeds preferences`), 502 + error: cleanError(preferencesError), 503 + }) 504 + } else { 505 + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) 506 + } 507 + } 508 + } 509 + 510 + const addSuggestedStarterPacksModule = () => { 511 + i.push({ 512 + type: 'header', 513 + key: 'suggested-starterPacks-header', 514 + title: _(msg`Starter Packs`), 515 + icon: StarterPack, 516 + }) 517 + 518 + if (isLoadingSuggestedSPs) { 519 + Array.from({length: 3}).forEach((_, index) => 520 + i.push({ 521 + type: 'starterPackSkeleton', 522 + key: `starterPackSkeleton-${index}`, 523 + }), 524 + ) 525 + } else if (suggestedSPsError || !suggestedSPs) { 526 + // just get rid of the section 527 + i.pop() 528 + } else { 529 + suggestedSPs.starterPacks.map(s => { 530 + i.push({ 531 + type: 'starterPack', 532 + key: s.uri, 533 + view: s, 534 + }) 535 + }) 536 + } 537 + } 538 + 539 + const addFeedPreviews = () => { 540 + i.push(...feedPreviewSlices) 541 + if (isFetchingNextPageFeedPreviews) { 542 + i.push({ 543 + type: 'preview:loading', 544 + key: 'preview-loading-more', 545 + }) 546 + } 547 + } 548 + 549 + // Dynamic module ordering 550 + 551 + addTopBorder() 552 + 553 + if (guide?.guide === 'follow-10' && !guide.isComplete) { 554 + addSuggestedFollowsModule() 555 + addSuggestedStarterPacksModule() 556 + addTrendingTopicsModule() 557 + } else { 558 + addTrendingTopicsModule() 559 + addSuggestedFollowsModule() 560 + addSuggestedStarterPacksModule() 561 + } 562 + 563 + if (gate('explore_show_suggested_feeds')) { 564 + addSuggestedFeedsModule() 565 + } 566 + 567 + addFeedPreviews() 568 + 569 + return i 570 + }, [ 571 + _, 572 + profiles, 573 + feeds, 574 + preferences, 575 + onLoadMoreFeeds, 576 + onLoadMoreProfiles, 577 + isLoadingMoreProfiles, 578 + isLoadingMoreFeeds, 579 + profilesError, 580 + feedsError, 581 + preferencesError, 582 + hasNextProfilesPage, 583 + hasNextFeedsPage, 584 + guide, 585 + gate, 586 + moderationOpts, 587 + hasPressedLoadMoreFeeds, 588 + suggestedSPs, 589 + isLoadingSuggestedSPs, 590 + suggestedSPsError, 591 + feedPreviewSlices, 592 + isFetchingNextPageFeedPreviews, 593 + canShowSuggestedProfiles, 594 + ]) 595 + 596 + const renderItem = useCallback( 597 + ({item, index}: {item: ExploreScreenItems; index: number}) => { 598 + switch (item.type) { 599 + case 'topBorder': 600 + return ( 601 + <View 602 + style={[ 603 + a.w_full, 604 + t.atoms.border_contrast_low, 605 + a.border_t, 606 + headerHeight && 607 + web({ 608 + position: 'sticky', 609 + top: headerHeight, 610 + }), 611 + ]} 612 + /> 613 + ) 614 + case 'header': { 615 + return ( 616 + <ModuleHeader.Container> 617 + <ModuleHeader.Icon icon={item.icon} /> 618 + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> 619 + {item.searchButton && ( 620 + <ModuleHeader.SearchButton 621 + {...item.searchButton} 622 + onPress={() => 623 + focusSearchInput(item.searchButton?.tab || 'user') 624 + } 625 + /> 626 + )} 627 + </ModuleHeader.Container> 628 + ) 629 + } 630 + case 'tabbedHeader': { 631 + return ( 632 + <View style={[a.pb_md]}> 633 + <ModuleHeader.Container style={[a.pb_xs]}> 634 + <ModuleHeader.Icon icon={item.icon} /> 635 + <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText> 636 + {item.searchButton && ( 637 + <ModuleHeader.SearchButton 638 + {...item.searchButton} 639 + onPress={() => 640 + focusSearchInput(item.searchButton?.tab || 'user') 641 + } 642 + /> 643 + )} 644 + </ModuleHeader.Container> 645 + <SuggestedAccountsTabBar 646 + selectedInterest={selectedInterest} 647 + onSelectInterest={setSelectedInterest} 648 + /> 649 + </View> 650 + ) 651 + } 652 + case 'trendingTopics': { 653 + return ( 654 + <View style={[a.pb_md]}> 655 + <ExploreTrendingTopics /> 656 + </View> 657 + ) 658 + } 659 + case 'trendingVideos': { 660 + return <ExploreTrendingVideos /> 661 + } 662 + case 'recommendations': { 663 + return <ExploreRecommendations /> 664 + } 665 + case 'profile': { 666 + return ( 667 + <SuggestedProfileCard 668 + profile={item.profile} 669 + moderationOpts={moderationOpts!} 670 + recId={item.recId} 671 + position={index} 672 + /> 673 + ) 674 + } 675 + case 'feed': { 676 + return ( 677 + <View 678 + style={[ 679 + a.border_t, 680 + t.atoms.border_contrast_low, 681 + a.px_lg, 682 + a.py_lg, 683 + ]}> 684 + <FeedCard.Default view={item.feed} /> 685 + </View> 686 + ) 687 + } 688 + case 'starterPack': { 689 + return ( 690 + <View style={[a.px_lg, a.pb_lg]}> 691 + <StarterPackCard view={item.view} /> 692 + </View> 693 + ) 694 + } 695 + case 'starterPackSkeleton': { 696 + return ( 697 + <View style={[a.px_lg, a.pb_lg]}> 698 + <StarterPackCardSkeleton /> 699 + </View> 700 + ) 701 + } 702 + case 'loadMore': { 703 + return ( 704 + <View style={[a.border_t, t.atoms.border_contrast_low]}> 705 + <LoadMore item={item} /> 706 + </View> 707 + ) 708 + } 709 + case 'profilePlaceholder': { 710 + return ( 711 + <> 712 + {Array.from({length: 3}).map((_, index) => ( 713 + <View 714 + style={[ 715 + a.px_lg, 716 + a.py_lg, 717 + a.border_t, 718 + t.atoms.border_contrast_low, 719 + ]} 720 + key={index}> 721 + <ProfileCard.Outer> 722 + <ProfileCard.Header> 723 + <ProfileCard.AvatarPlaceholder /> 724 + <ProfileCard.NameAndHandlePlaceholder /> 725 + </ProfileCard.Header> 726 + <ProfileCard.DescriptionPlaceholder numberOfLines={2} /> 727 + </ProfileCard.Outer> 728 + </View> 729 + ))} 730 + </> 731 + ) 732 + } 733 + case 'feedPlaceholder': { 734 + return <FeedFeedLoadingPlaceholder /> 735 + } 736 + case 'error': 737 + case 'preview:error': { 738 + return ( 739 + <View 740 + style={[ 741 + a.border_t, 742 + a.pt_md, 743 + a.px_md, 744 + t.atoms.border_contrast_low, 745 + ]}> 746 + <View 747 + style={[ 748 + a.flex_row, 749 + a.gap_md, 750 + a.p_lg, 751 + a.rounded_sm, 752 + t.atoms.bg_contrast_25, 753 + ]}> 754 + <CircleInfo size="md" fill={t.palette.negative_400} /> 755 + <View style={[a.flex_1, a.gap_sm]}> 756 + <Text style={[a.font_bold, a.leading_snug]}> 757 + {item.message} 758 + </Text> 759 + <Text 760 + style={[ 761 + a.italic, 762 + a.leading_snug, 763 + t.atoms.text_contrast_medium, 764 + ]}> 765 + {item.error} 766 + </Text> 767 + </View> 768 + </View> 769 + </View> 770 + ) 771 + } 772 + // feed previews 773 + case 'preview:empty': { 774 + return null // what should we do here? 775 + } 776 + case 'preview:loading': { 777 + return ( 778 + <View style={[a.py_2xl, a.flex_1, a.align_center]}> 779 + <Loader size="lg" /> 780 + </View> 781 + ) 782 + } 783 + case 'preview:header': { 784 + return ( 785 + <ModuleHeader.Container 786 + headerHeight={headerHeight} 787 + style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}> 788 + <ModuleHeader.FeedLink feed={item.feed}> 789 + <ModuleHeader.FeedAvatar feed={item.feed} /> 790 + <View style={[a.flex_1, a.gap_xs]}> 791 + <ModuleHeader.TitleText style={[a.text_lg]}> 792 + {item.feed.displayName} 793 + </ModuleHeader.TitleText> 794 + <ModuleHeader.SubtitleText> 795 + <Trans> 796 + By {sanitizeHandle(item.feed.creator.handle, '@')} 797 + </Trans> 798 + </ModuleHeader.SubtitleText> 799 + </View> 800 + </ModuleHeader.FeedLink> 801 + <ModuleHeader.PinButton feed={item.feed} /> 802 + </ModuleHeader.Container> 803 + ) 804 + } 805 + case 'preview:footer': { 806 + return <View style={[a.w_full, a.pt_2xl]} /> 807 + } 808 + case 'preview:sliceItem': { 809 + const slice = item.slice 810 + const indexInSlice = item.indexInSlice 811 + const subItem = slice.items[indexInSlice] 812 + return ( 813 + <PostFeedItem 814 + post={subItem.post} 815 + record={subItem.record} 816 + reason={indexInSlice === 0 ? slice.reason : undefined} 817 + feedContext={slice.feedContext} 818 + moderation={subItem.moderation} 819 + parentAuthor={subItem.parentAuthor} 820 + showReplyTo={item.showReplyTo} 821 + isThreadParent={isThreadParentAt(slice.items, indexInSlice)} 822 + isThreadChild={isThreadChildAt(slice.items, indexInSlice)} 823 + isThreadLastChild={ 824 + isThreadChildAt(slice.items, indexInSlice) && 825 + slice.items.length === indexInSlice + 1 826 + } 827 + isParentBlocked={subItem.isParentBlocked} 828 + isParentNotFound={subItem.isParentNotFound} 829 + hideTopBorder={item.hideTopBorder} 830 + rootPost={slice.items[0].post} 831 + /> 832 + ) 833 + } 834 + case 'preview:sliceViewFullThread': { 835 + return <ViewFullThread uri={item.uri} /> 836 + } 837 + case 'preview:loadMoreError': { 838 + return ( 839 + <LoadMoreRetryBtn 840 + label={_( 841 + msg`There was an issue fetching posts. Tap here to try again.`, 842 + )} 843 + onPress={fetchNextPageFeedPreviews} 844 + /> 845 + ) 846 + } 847 + } 848 + }, 849 + [ 850 + t, 851 + focusSearchInput, 852 + moderationOpts, 853 + selectedInterest, 854 + _, 855 + fetchNextPageFeedPreviews, 856 + headerHeight, 857 + ], 858 + ) 859 + 860 + const stickyHeaderIndices = useMemo( 861 + () => 862 + items.reduce( 863 + (acc, curr) => 864 + ['topBorder', 'preview:header'].includes(curr.type) 865 + ? acc.concat(items.indexOf(curr)) 866 + : acc, 867 + [] as number[], 868 + ), 869 + [items], 870 + ) 871 + 872 + // track headers and report module viewability 873 + const alreadyReportedRef = useRef<Map<string, string>>(new Map()) 874 + const onViewableItemsChanged = useCallback( 875 + ({ 876 + viewableItems, 877 + }: { 878 + viewableItems: ViewToken<ExploreScreenItems>[] 879 + changed: ViewToken<ExploreScreenItems>[] 880 + }) => { 881 + for (const {item} of viewableItems.filter(vi => vi.isViewable)) { 882 + let module: MetricEvents['explore:module:seen']['module'] 883 + if (item.type === 'trendingTopics' || item.type === 'trendingVideos') { 884 + module = item.type 885 + } else if (item.type === 'profile') { 886 + module = 'suggestedAccounts' 887 + } else if (item.type === 'feed') { 888 + module = 'suggestedFeeds' 889 + } else if (item.type === 'preview:header') { 890 + module = `feed:feedgen|${item.feed.uri}` 891 + } else { 892 + continue 893 + } 894 + if (!alreadyReportedRef.current.has(module)) { 895 + alreadyReportedRef.current.set(module, module) 896 + logger.metric('explore:module:seen', {module}) 897 + } 898 + } 899 + }, 900 + [], 901 + ) 902 + 903 + return ( 904 + <List 905 + data={items} 906 + renderItem={renderItem} 907 + keyExtractor={item => item.key} 908 + desktopFixedHeight 909 + contentContainerStyle={{paddingBottom: 100}} 910 + keyboardShouldPersistTaps="handled" 911 + keyboardDismissMode="on-drag" 912 + stickyHeaderIndices={native(stickyHeaderIndices)} 913 + viewabilityConfig={viewabilityConfig} 914 + onViewableItemsChanged={onViewableItemsChanged} 915 + onEndReached={onLoadMoreFeedPreviews} 916 + onEndReachedThreshold={2} 917 + /> 918 + ) 919 + } 920 + 921 + const viewabilityConfig: ViewabilityConfig = { 922 + itemVisiblePercentThreshold: 100, 923 + }
+338
src/screens/Search/SearchResults.tsx
···
··· 1 + import {memo, useCallback, useMemo, useState} from 'react' 2 + import {ActivityIndicator, View} from 'react-native' 3 + import {type AppBskyFeedDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {augmentSearchQuery} from '#/lib/strings/helpers' 8 + import {useActorSearch} from '#/state/queries/actor-search' 9 + import {usePopularFeedsSearch} from '#/state/queries/feed' 10 + import {useSearchPostsQuery} from '#/state/queries/search-posts' 11 + import {useSession} from '#/state/session' 12 + import {Pager} from '#/view/com/pager/Pager' 13 + import {TabBar} from '#/view/com/pager/TabBar' 14 + import {Post} from '#/view/com/post/Post' 15 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 16 + import {List} from '#/view/com/util/List' 17 + import {atoms as a, useTheme, web} from '#/alf' 18 + import * as FeedCard from '#/components/FeedCard' 19 + import * as Layout from '#/components/Layout' 20 + import {Text} from '#/components/Typography' 21 + 22 + let SearchResults = ({ 23 + query, 24 + queryWithParams, 25 + activeTab, 26 + onPageSelected, 27 + headerHeight, 28 + }: { 29 + query: string 30 + queryWithParams: string 31 + activeTab: number 32 + onPageSelected: (page: number) => void 33 + headerHeight: number 34 + }): React.ReactNode => { 35 + const {_} = useLingui() 36 + 37 + const sections = useMemo(() => { 38 + if (!queryWithParams) return [] 39 + const noParams = queryWithParams === query 40 + return [ 41 + { 42 + title: _(msg`Top`), 43 + component: ( 44 + <SearchScreenPostResults 45 + query={queryWithParams} 46 + sort="top" 47 + active={activeTab === 0} 48 + /> 49 + ), 50 + }, 51 + { 52 + title: _(msg`Latest`), 53 + component: ( 54 + <SearchScreenPostResults 55 + query={queryWithParams} 56 + sort="latest" 57 + active={activeTab === 1} 58 + /> 59 + ), 60 + }, 61 + noParams && { 62 + title: _(msg`People`), 63 + component: ( 64 + <SearchScreenUserResults query={query} active={activeTab === 2} /> 65 + ), 66 + }, 67 + noParams && { 68 + title: _(msg`Feeds`), 69 + component: ( 70 + <SearchScreenFeedsResults query={query} active={activeTab === 3} /> 71 + ), 72 + }, 73 + ].filter(Boolean) as { 74 + title: string 75 + component: React.ReactNode 76 + }[] 77 + }, [_, query, queryWithParams, activeTab]) 78 + 79 + return ( 80 + <Pager 81 + onPageSelected={onPageSelected} 82 + renderTabBar={props => ( 83 + <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}> 84 + <TabBar items={sections.map(section => section.title)} {...props} /> 85 + </Layout.Center> 86 + )} 87 + initialPage={0}> 88 + {sections.map((section, i) => ( 89 + <View key={i}>{section.component}</View> 90 + ))} 91 + </Pager> 92 + ) 93 + } 94 + SearchResults = memo(SearchResults) 95 + export {SearchResults} 96 + 97 + function Loader() { 98 + return ( 99 + <Layout.Content> 100 + <View style={[a.py_xl]}> 101 + <ActivityIndicator /> 102 + </View> 103 + </Layout.Content> 104 + ) 105 + } 106 + 107 + function EmptyState({message, error}: {message: string; error?: string}) { 108 + const t = useTheme() 109 + 110 + return ( 111 + <Layout.Content> 112 + <View style={[a.p_xl]}> 113 + <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}> 114 + <Text style={[a.text_md]}>{message}</Text> 115 + 116 + {error && ( 117 + <> 118 + <View 119 + style={[ 120 + { 121 + marginVertical: 12, 122 + height: 1, 123 + width: '100%', 124 + backgroundColor: t.atoms.text.color, 125 + opacity: 0.2, 126 + }, 127 + ]} 128 + /> 129 + 130 + <Text style={[t.atoms.text_contrast_medium]}> 131 + <Trans>Error: {error}</Trans> 132 + </Text> 133 + </> 134 + )} 135 + </View> 136 + </View> 137 + </Layout.Content> 138 + ) 139 + } 140 + 141 + type SearchResultSlice = 142 + | { 143 + type: 'post' 144 + key: string 145 + post: AppBskyFeedDefs.PostView 146 + } 147 + | { 148 + type: 'loadingMore' 149 + key: string 150 + } 151 + 152 + let SearchScreenPostResults = ({ 153 + query, 154 + sort, 155 + active, 156 + }: { 157 + query: string 158 + sort?: 'top' | 'latest' 159 + active: boolean 160 + }): React.ReactNode => { 161 + const {_} = useLingui() 162 + const {currentAccount} = useSession() 163 + const [isPTR, setIsPTR] = useState(false) 164 + 165 + const augmentedQuery = useMemo(() => { 166 + return augmentSearchQuery(query || '', {did: currentAccount?.did}) 167 + }, [query, currentAccount]) 168 + 169 + const { 170 + isFetched, 171 + data: results, 172 + isFetching, 173 + error, 174 + refetch, 175 + fetchNextPage, 176 + isFetchingNextPage, 177 + hasNextPage, 178 + } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) 179 + 180 + const onPullToRefresh = useCallback(async () => { 181 + setIsPTR(true) 182 + await refetch() 183 + setIsPTR(false) 184 + }, [setIsPTR, refetch]) 185 + const onEndReached = useCallback(() => { 186 + if (isFetching || !hasNextPage || error) return 187 + fetchNextPage() 188 + }, [isFetching, error, hasNextPage, fetchNextPage]) 189 + 190 + const posts = useMemo(() => { 191 + return results?.pages.flatMap(page => page.posts) || [] 192 + }, [results]) 193 + const items = useMemo(() => { 194 + let temp: SearchResultSlice[] = [] 195 + 196 + const seenUris = new Set() 197 + for (const post of posts) { 198 + if (seenUris.has(post.uri)) { 199 + continue 200 + } 201 + temp.push({ 202 + type: 'post', 203 + key: post.uri, 204 + post, 205 + }) 206 + seenUris.add(post.uri) 207 + } 208 + 209 + if (isFetchingNextPage) { 210 + temp.push({ 211 + type: 'loadingMore', 212 + key: 'loadingMore', 213 + }) 214 + } 215 + 216 + return temp 217 + }, [posts, isFetchingNextPage]) 218 + 219 + return error ? ( 220 + <EmptyState 221 + message={_( 222 + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, 223 + )} 224 + error={error.toString()} 225 + /> 226 + ) : ( 227 + <> 228 + {isFetched ? ( 229 + <> 230 + {posts.length ? ( 231 + <List 232 + data={items} 233 + renderItem={({item}) => { 234 + if (item.type === 'post') { 235 + return <Post post={item.post} /> 236 + } else { 237 + return null 238 + } 239 + }} 240 + keyExtractor={item => item.key} 241 + refreshing={isPTR} 242 + onRefresh={onPullToRefresh} 243 + onEndReached={onEndReached} 244 + desktopFixedHeight 245 + contentContainerStyle={{paddingBottom: 100}} 246 + /> 247 + ) : ( 248 + <EmptyState message={_(msg`No results found for ${query}`)} /> 249 + )} 250 + </> 251 + ) : ( 252 + <Loader /> 253 + )} 254 + </> 255 + ) 256 + } 257 + SearchScreenPostResults = memo(SearchScreenPostResults) 258 + 259 + let SearchScreenUserResults = ({ 260 + query, 261 + active, 262 + }: { 263 + query: string 264 + active: boolean 265 + }): React.ReactNode => { 266 + const {_} = useLingui() 267 + 268 + const {data: results, isFetched} = useActorSearch({ 269 + query, 270 + enabled: active, 271 + }) 272 + 273 + return isFetched && results ? ( 274 + <> 275 + {results.length ? ( 276 + <List 277 + data={results} 278 + renderItem={({item}) => ( 279 + <ProfileCardWithFollowBtn profile={item} noBg /> 280 + )} 281 + keyExtractor={item => item.did} 282 + desktopFixedHeight 283 + contentContainerStyle={{paddingBottom: 100}} 284 + /> 285 + ) : ( 286 + <EmptyState message={_(msg`No results found for ${query}`)} /> 287 + )} 288 + </> 289 + ) : ( 290 + <Loader /> 291 + ) 292 + } 293 + SearchScreenUserResults = memo(SearchScreenUserResults) 294 + 295 + let SearchScreenFeedsResults = ({ 296 + query, 297 + active, 298 + }: { 299 + query: string 300 + active: boolean 301 + }): React.ReactNode => { 302 + const t = useTheme() 303 + const {_} = useLingui() 304 + 305 + const {data: results, isFetched} = usePopularFeedsSearch({ 306 + query, 307 + enabled: active, 308 + }) 309 + 310 + return isFetched && results ? ( 311 + <> 312 + {results.length ? ( 313 + <List 314 + data={results} 315 + renderItem={({item}) => ( 316 + <View 317 + style={[ 318 + a.border_b, 319 + t.atoms.border_contrast_low, 320 + a.px_lg, 321 + a.py_lg, 322 + ]}> 323 + <FeedCard.Default view={item} /> 324 + </View> 325 + )} 326 + keyExtractor={item => item.uri} 327 + desktopFixedHeight 328 + contentContainerStyle={{paddingBottom: 100}} 329 + /> 330 + ) : ( 331 + <EmptyState message={_(msg`No results found for ${query}`)} /> 332 + )} 333 + </> 334 + ) : ( 335 + <Loader /> 336 + ) 337 + } 338 + SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
+535
src/screens/Search/Shell.tsx
···
··· 1 + import { 2 + memo, 3 + useCallback, 4 + useLayoutEffect, 5 + useMemo, 6 + useRef, 7 + useState, 8 + } from 'react' 9 + import { 10 + type StyleProp, 11 + type TextInput, 12 + View, 13 + type ViewStyle, 14 + } from 'react-native' 15 + import {msg, Trans} from '@lingui/macro' 16 + import {useLingui} from '@lingui/react' 17 + import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' 18 + import {useQueryClient} from '@tanstack/react-query' 19 + 20 + import {HITSLOP_20} from '#/lib/constants' 21 + import {HITSLOP_10} from '#/lib/constants' 22 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 23 + import {MagnifyingGlassIcon} from '#/lib/icons' 24 + import {type NavigationProp} from '#/lib/routes/types' 25 + import {isWeb} from '#/platform/detection' 26 + import {listenSoftReset} from '#/state/events' 27 + import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 28 + import { 29 + unstableCacheProfileView, 30 + useProfilesQuery, 31 + } from '#/state/queries/profile' 32 + import {useSession} from '#/state/session' 33 + import {useSetMinimalShellMode} from '#/state/shell' 34 + import { 35 + makeSearchQuery, 36 + type Params, 37 + parseSearchQuery, 38 + } from '#/screens/Search/utils' 39 + import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' 40 + import {Button, ButtonText} from '#/components/Button' 41 + import {SearchInput} from '#/components/forms/SearchInput' 42 + import * as Layout from '#/components/Layout' 43 + import {Text} from '#/components/Typography' 44 + import {account, useStorage} from '#/storage' 45 + import type * as bsky from '#/types/bsky' 46 + import {AutocompleteResults} from './components/AutocompleteResults' 47 + import {SearchHistory} from './components/SearchHistory' 48 + import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' 49 + import {Explore} from './Explore' 50 + import {SearchResults} from './SearchResults' 51 + 52 + export function SearchScreenShell({ 53 + queryParam, 54 + testID, 55 + fixedParams, 56 + navButton = 'menu', 57 + inputPlaceholder, 58 + }: { 59 + queryParam: string 60 + testID: string 61 + fixedParams?: Params 62 + navButton?: 'back' | 'menu' 63 + inputPlaceholder?: string 64 + }) { 65 + const t = useTheme() 66 + const {gtMobile} = useBreakpoints() 67 + const navigation = useNavigation<NavigationProp>() 68 + const route = useRoute() 69 + const textInput = useRef<TextInput>(null) 70 + const {_} = useLingui() 71 + const setMinimalShellMode = useSetMinimalShellMode() 72 + const {currentAccount} = useSession() 73 + const queryClient = useQueryClient() 74 + 75 + // Query terms 76 + const [searchText, setSearchText] = useState<string>(queryParam) 77 + const {data: autocompleteData, isFetching: isAutocompleteFetching} = 78 + useActorAutocompleteQuery(searchText, true) 79 + 80 + const [showAutocomplete, setShowAutocomplete] = useState(false) 81 + 82 + const [termHistory = [], setTermHistory] = useStorage(account, [ 83 + currentAccount?.did ?? 'pwi', 84 + 'searchTermHistory', 85 + ] as const) 86 + const [accountHistory = [], setAccountHistory] = useStorage(account, [ 87 + currentAccount?.did ?? 'pwi', 88 + 'searchAccountHistory', 89 + ]) 90 + 91 + const {data: accountHistoryProfiles} = useProfilesQuery({ 92 + handles: accountHistory, 93 + maintainData: true, 94 + }) 95 + 96 + const updateSearchHistory = useCallback( 97 + async (item: string) => { 98 + if (!item) return 99 + const newSearchHistory = [ 100 + item, 101 + ...termHistory.filter(search => search !== item), 102 + ].slice(0, 6) 103 + setTermHistory(newSearchHistory) 104 + }, 105 + [termHistory, setTermHistory], 106 + ) 107 + 108 + const updateProfileHistory = useCallback( 109 + async (item: bsky.profile.AnyProfileView) => { 110 + const newAccountHistory = [ 111 + item.did, 112 + ...accountHistory.filter(p => p !== item.did), 113 + ].slice(0, 5) 114 + setAccountHistory(newAccountHistory) 115 + }, 116 + [accountHistory, setAccountHistory], 117 + ) 118 + 119 + const deleteSearchHistoryItem = useCallback( 120 + async (item: string) => { 121 + setTermHistory(termHistory.filter(search => search !== item)) 122 + }, 123 + [termHistory, setTermHistory], 124 + ) 125 + const deleteProfileHistoryItem = useCallback( 126 + async (item: bsky.profile.AnyProfileView) => { 127 + setAccountHistory(accountHistory.filter(p => p !== item.did)) 128 + }, 129 + [accountHistory, setAccountHistory], 130 + ) 131 + 132 + const {params, query, queryWithParams} = useQueryManager({ 133 + initialQuery: queryParam, 134 + fixedParams, 135 + }) 136 + const showFilters = Boolean(queryWithParams && !showAutocomplete) 137 + 138 + // web only - measure header height for sticky positioning 139 + const [headerHeight, setHeaderHeight] = useState(0) 140 + const headerRef = useRef(null) 141 + useLayoutEffect(() => { 142 + if (isWeb) { 143 + if (!headerRef.current) return 144 + const measurement = (headerRef.current as Element).getBoundingClientRect() 145 + setHeaderHeight(measurement.height) 146 + } 147 + }, []) 148 + 149 + useFocusEffect( 150 + useNonReactiveCallback(() => { 151 + if (isWeb) { 152 + setSearchText(queryParam) 153 + } 154 + }), 155 + ) 156 + 157 + const onPressClearQuery = useCallback(() => { 158 + scrollToTopWeb() 159 + setSearchText('') 160 + textInput.current?.focus() 161 + }, []) 162 + 163 + const onChangeText = useCallback(async (text: string) => { 164 + scrollToTopWeb() 165 + setSearchText(text) 166 + }, []) 167 + 168 + const navigateToItem = useCallback( 169 + (item: string) => { 170 + scrollToTopWeb() 171 + setShowAutocomplete(false) 172 + updateSearchHistory(item) 173 + 174 + if (isWeb) { 175 + // @ts-expect-error route is not typesafe 176 + navigation.push(route.name, {...route.params, q: item}) 177 + } else { 178 + textInput.current?.blur() 179 + navigation.setParams({q: item}) 180 + } 181 + }, 182 + [updateSearchHistory, navigation, route], 183 + ) 184 + 185 + const onPressCancelSearch = useCallback(() => { 186 + scrollToTopWeb() 187 + textInput.current?.blur() 188 + setShowAutocomplete(false) 189 + if (isWeb) { 190 + // Empty params resets the URL to be /search rather than /search?q= 191 + 192 + const {q: _q, ...parameters} = (route.params ?? {}) as { 193 + [key: string]: string 194 + } 195 + // @ts-expect-error route is not typesafe 196 + navigation.replace(route.name, parameters) 197 + } else { 198 + setSearchText('') 199 + navigation.setParams({q: ''}) 200 + } 201 + }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) 202 + 203 + const onSubmit = useCallback(() => { 204 + navigateToItem(searchText) 205 + }, [navigateToItem, searchText]) 206 + 207 + const onAutocompleteResultPress = useCallback(() => { 208 + if (isWeb) { 209 + setShowAutocomplete(false) 210 + } else { 211 + textInput.current?.blur() 212 + } 213 + }, []) 214 + 215 + const handleHistoryItemClick = useCallback( 216 + (item: string) => { 217 + setSearchText(item) 218 + navigateToItem(item) 219 + }, 220 + [navigateToItem], 221 + ) 222 + 223 + const handleProfileClick = useCallback( 224 + (profile: bsky.profile.AnyProfileView) => { 225 + unstableCacheProfileView(queryClient, profile) 226 + // Slight delay to avoid updating during push nav animation. 227 + setTimeout(() => { 228 + updateProfileHistory(profile) 229 + }, 400) 230 + }, 231 + [updateProfileHistory, queryClient], 232 + ) 233 + 234 + const onSoftReset = useCallback(() => { 235 + if (isWeb) { 236 + // Empty params resets the URL to be /search rather than /search?q= 237 + 238 + const {q: _q, ...parameters} = (route.params ?? {}) as { 239 + [key: string]: string 240 + } 241 + // @ts-expect-error route is not typesafe 242 + navigation.replace(route.name, parameters) 243 + } else { 244 + setSearchText('') 245 + navigation.setParams({q: ''}) 246 + textInput.current?.focus() 247 + } 248 + }, [navigation, route]) 249 + 250 + useFocusEffect( 251 + useCallback(() => { 252 + setMinimalShellMode(false) 253 + return listenSoftReset(onSoftReset) 254 + }, [onSoftReset, setMinimalShellMode]), 255 + ) 256 + 257 + const onSearchInputFocus = useCallback(() => { 258 + if (isWeb) { 259 + // Prevent a jump on iPad by ensuring that 260 + // the initial focused render has no result list. 261 + requestAnimationFrame(() => { 262 + setShowAutocomplete(true) 263 + }) 264 + } else { 265 + setShowAutocomplete(true) 266 + } 267 + }, [setShowAutocomplete]) 268 + 269 + const focusSearchInput = useCallback(() => { 270 + textInput.current?.focus() 271 + }, []) 272 + 273 + const showHeader = !gtMobile || navButton !== 'menu' 274 + 275 + return ( 276 + <Layout.Screen testID={testID}> 277 + <View 278 + ref={headerRef} 279 + onLayout={evt => { 280 + if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) 281 + }} 282 + style={[ 283 + a.relative, 284 + a.z_10, 285 + web({ 286 + position: 'sticky', 287 + top: 0, 288 + }), 289 + ]}> 290 + <Layout.Center style={t.atoms.bg}> 291 + {showHeader && ( 292 + <View 293 + // HACK: shift up search input. we can't remove the top padding 294 + // on the search input because it messes up the layout animation 295 + // if we add it only when the header is hidden 296 + style={{marginBottom: tokens.space.xs * -1}}> 297 + <Layout.Header.Outer noBottomBorder> 298 + {navButton === 'menu' ? ( 299 + <Layout.Header.MenuButton /> 300 + ) : ( 301 + <Layout.Header.BackButton /> 302 + )} 303 + <Layout.Header.Content align="left"> 304 + <Layout.Header.TitleText> 305 + <Trans>Search</Trans> 306 + </Layout.Header.TitleText> 307 + </Layout.Header.Content> 308 + {showFilters ? ( 309 + <SearchLanguageDropdown 310 + value={params.lang} 311 + onChange={params.setLang} 312 + /> 313 + ) : ( 314 + <Layout.Header.Slot /> 315 + )} 316 + </Layout.Header.Outer> 317 + </View> 318 + )} 319 + <View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}> 320 + <View style={[a.gap_sm]}> 321 + <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}> 322 + <View style={[a.flex_1]}> 323 + <SearchInput 324 + ref={textInput} 325 + value={searchText} 326 + onFocus={onSearchInputFocus} 327 + onChangeText={onChangeText} 328 + onClearText={onPressClearQuery} 329 + onSubmitEditing={onSubmit} 330 + placeholder={ 331 + inputPlaceholder ?? 332 + _(msg`Search for posts, users, or feeds`) 333 + } 334 + hitSlop={{...HITSLOP_20, top: 0}} 335 + /> 336 + </View> 337 + {showAutocomplete && ( 338 + <Button 339 + label={_(msg`Cancel search`)} 340 + size="large" 341 + variant="ghost" 342 + color="secondary" 343 + style={[a.px_sm]} 344 + onPress={onPressCancelSearch} 345 + hitSlop={HITSLOP_10}> 346 + <ButtonText> 347 + <Trans>Cancel</Trans> 348 + </ButtonText> 349 + </Button> 350 + )} 351 + </View> 352 + 353 + {showFilters && !showHeader && ( 354 + <View 355 + style={[ 356 + a.flex_row, 357 + a.align_center, 358 + a.justify_between, 359 + a.gap_sm, 360 + ]}> 361 + <SearchLanguageDropdown 362 + value={params.lang} 363 + onChange={params.setLang} 364 + /> 365 + </View> 366 + )} 367 + </View> 368 + </View> 369 + </Layout.Center> 370 + </View> 371 + 372 + <View 373 + style={{ 374 + display: showAutocomplete && !fixedParams ? 'flex' : 'none', 375 + flex: 1, 376 + }}> 377 + {searchText.length > 0 ? ( 378 + <AutocompleteResults 379 + isAutocompleteFetching={isAutocompleteFetching} 380 + autocompleteData={autocompleteData} 381 + searchText={searchText} 382 + onSubmit={onSubmit} 383 + onResultPress={onAutocompleteResultPress} 384 + onProfileClick={handleProfileClick} 385 + /> 386 + ) : ( 387 + <SearchHistory 388 + searchHistory={termHistory} 389 + selectedProfiles={accountHistoryProfiles?.profiles || []} 390 + onItemClick={handleHistoryItemClick} 391 + onProfileClick={handleProfileClick} 392 + onRemoveItemClick={deleteSearchHistoryItem} 393 + onRemoveProfileClick={deleteProfileHistoryItem} 394 + /> 395 + )} 396 + </View> 397 + <View 398 + style={{ 399 + display: showAutocomplete ? 'none' : 'flex', 400 + flex: 1, 401 + }}> 402 + <SearchScreenInner 403 + query={query} 404 + queryWithParams={queryWithParams} 405 + headerHeight={headerHeight} 406 + focusSearchInput={focusSearchInput} 407 + /> 408 + </View> 409 + </Layout.Screen> 410 + ) 411 + } 412 + 413 + let SearchScreenInner = ({ 414 + query, 415 + queryWithParams, 416 + headerHeight, 417 + focusSearchInput, 418 + }: { 419 + query: string 420 + queryWithParams: string 421 + headerHeight: number 422 + focusSearchInput: () => void 423 + }): React.ReactNode => { 424 + const t = useTheme() 425 + const setMinimalShellMode = useSetMinimalShellMode() 426 + const {hasSession} = useSession() 427 + const {gtTablet} = useBreakpoints() 428 + const [activeTab, setActiveTab] = useState(0) 429 + const {_} = useLingui() 430 + 431 + const onPageSelected = useCallback( 432 + (index: number) => { 433 + setMinimalShellMode(false) 434 + setActiveTab(index) 435 + }, 436 + [setMinimalShellMode], 437 + ) 438 + 439 + return queryWithParams ? ( 440 + <SearchResults 441 + query={query} 442 + queryWithParams={queryWithParams} 443 + activeTab={activeTab} 444 + headerHeight={headerHeight} 445 + onPageSelected={onPageSelected} 446 + /> 447 + ) : hasSession ? ( 448 + <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} /> 449 + ) : ( 450 + <Layout.Center> 451 + <View style={a.flex_1}> 452 + {gtTablet && ( 453 + <View 454 + style={[ 455 + a.border_b, 456 + t.atoms.border_contrast_low, 457 + a.px_lg, 458 + a.pt_sm, 459 + a.pb_lg, 460 + ]}> 461 + <Text style={[a.text_2xl, a.font_heavy]}> 462 + <Trans>Search</Trans> 463 + </Text> 464 + </View> 465 + )} 466 + 467 + <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}> 468 + <MagnifyingGlassIcon 469 + strokeWidth={3} 470 + size={60} 471 + style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>} 472 + /> 473 + <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 474 + <Trans>Find posts, users, and feeds on Bluesky</Trans> 475 + </Text> 476 + </View> 477 + </View> 478 + </Layout.Center> 479 + ) 480 + } 481 + SearchScreenInner = memo(SearchScreenInner) 482 + 483 + function useQueryManager({ 484 + initialQuery, 485 + fixedParams, 486 + }: { 487 + initialQuery: string 488 + fixedParams?: Params 489 + }) { 490 + const {query, params: initialParams} = useMemo(() => { 491 + return parseSearchQuery(initialQuery || '') 492 + }, [initialQuery]) 493 + const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery) 494 + const [lang, setLang] = useState(initialParams.lang || '') 495 + 496 + if (initialQuery !== prevInitialQuery) { 497 + // handle new queryParam change (from manual search entry) 498 + setPrevInitialQuery(initialQuery) 499 + setLang(initialParams.lang || '') 500 + } 501 + 502 + const params = useMemo( 503 + () => ({ 504 + // default stuff 505 + ...initialParams, 506 + // managed stuff 507 + lang, 508 + ...fixedParams, 509 + }), 510 + [lang, initialParams, fixedParams], 511 + ) 512 + const handlers = useMemo( 513 + () => ({ 514 + setLang, 515 + }), 516 + [setLang], 517 + ) 518 + 519 + return useMemo(() => { 520 + return { 521 + query, 522 + queryWithParams: makeSearchQuery(query, params), 523 + params: { 524 + ...params, 525 + ...handlers, 526 + }, 527 + } 528 + }, [query, params, handlers]) 529 + } 530 + 531 + function scrollToTopWeb() { 532 + if (isWeb) { 533 + window.scrollTo(0, 0) 534 + } 535 + }
+71
src/screens/Search/components/AutocompleteResults.tsx
···
··· 1 + import {memo} from 'react' 2 + import {ActivityIndicator, View} from 'react-native' 3 + import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {isNative} from '#/platform/detection' 8 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 9 + import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 10 + import {atoms as a, native} from '#/alf' 11 + import * as Layout from '#/components/Layout' 12 + 13 + let AutocompleteResults = ({ 14 + isAutocompleteFetching, 15 + autocompleteData, 16 + searchText, 17 + onSubmit, 18 + onResultPress, 19 + onProfileClick, 20 + }: { 21 + isAutocompleteFetching: boolean 22 + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined 23 + searchText: string 24 + onSubmit: () => void 25 + onResultPress: () => void 26 + onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void 27 + }): React.ReactNode => { 28 + const moderationOpts = useModerationOpts() 29 + const {_} = useLingui() 30 + return ( 31 + <> 32 + {(isAutocompleteFetching && !autocompleteData?.length) || 33 + !moderationOpts ? ( 34 + <Layout.Content> 35 + <View style={[a.py_xl]}> 36 + <ActivityIndicator /> 37 + </View> 38 + </Layout.Content> 39 + ) : ( 40 + <Layout.Content 41 + keyboardShouldPersistTaps="handled" 42 + keyboardDismissMode="on-drag"> 43 + <SearchLinkCard 44 + label={_(msg`Search for "${searchText}"`)} 45 + onPress={native(onSubmit)} 46 + to={ 47 + isNative 48 + ? undefined 49 + : `/search?q=${encodeURIComponent(searchText)}` 50 + } 51 + style={{borderBottomWidth: 1}} 52 + /> 53 + {autocompleteData?.map(item => ( 54 + <SearchProfileCard 55 + key={item.did} 56 + profile={item} 57 + moderation={moderateProfile(item, moderationOpts)} 58 + onPress={() => { 59 + onProfileClick(item) 60 + onResultPress() 61 + }} 62 + /> 63 + ))} 64 + <View style={{height: 200}} /> 65 + </Layout.Content> 66 + )} 67 + </> 68 + ) 69 + } 70 + AutocompleteResults = memo(AutocompleteResults) 71 + export {AutocompleteResults}
+9 -3
src/screens/Search/components/ExploreRecommendations.tsx src/screens/Search/modules/ExploreRecommendations.tsx
··· 1 import {View} from 'react-native' 2 - import {AppBskyUnspeccedDefs} from '@atproto/api' 3 import {Trans} from '@lingui/macro' 4 5 - import {logEvent} from '#/lib/statsig/statsig' 6 import {isWeb} from '#/platform/detection' 7 import { 8 DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, ··· 17 TrendingTopicSkeleton, 18 } from '#/components/TrendingTopics' 19 import {Text} from '#/components/Typography' 20 21 export function ExploreRecommendations() { 22 const {enabled} = useTrendingConfig() ··· 86 key={topic.link} 87 topic={topic} 88 onPress={() => { 89 - logEvent('recommendedTopic:click', {context: 'explore'}) 90 }}> 91 {({hovered}) => ( 92 <TrendingTopic
··· 1 import {View} from 'react-native' 2 + import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 import {Trans} from '@lingui/macro' 4 5 + import {logger} from '#/logger' 6 import {isWeb} from '#/platform/detection' 7 import { 8 DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, ··· 17 TrendingTopicSkeleton, 18 } from '#/components/TrendingTopics' 19 import {Text} from '#/components/Typography' 20 + 21 + // Note: This module is not currently used and may be removed in the future. 22 23 export function ExploreRecommendations() { 24 const {enabled} = useTrendingConfig() ··· 88 key={topic.link} 89 topic={topic} 90 onPress={() => { 91 + logger.metric( 92 + 'recommendedTopic:click', 93 + {context: 'explore'}, 94 + {statsig: true}, 95 + ) 96 }}> 97 {({hovered}) => ( 98 <TrendingTopic
-142
src/screens/Search/components/ExploreTrendingTopics.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {msg, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - 6 - import {logEvent} from '#/lib/statsig/statsig' 7 - import {isWeb} from '#/platform/detection' 8 - import { 9 - useTrendingSettings, 10 - useTrendingSettingsApi, 11 - } from '#/state/preferences/trending' 12 - import { 13 - DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, 14 - useTrendingTopics, 15 - } from '#/state/queries/trending/useTrendingTopics' 16 - import {useTrendingConfig} from '#/state/trending-config' 17 - import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 18 - import {Button, ButtonIcon} from '#/components/Button' 19 - import {GradientFill} from '#/components/GradientFill' 20 - import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 21 - import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' 22 - import * as Prompt from '#/components/Prompt' 23 - import { 24 - TrendingTopic, 25 - TrendingTopicLink, 26 - TrendingTopicSkeleton, 27 - } from '#/components/TrendingTopics' 28 - import {Text} from '#/components/Typography' 29 - 30 - export function ExploreTrendingTopics() { 31 - const {enabled} = useTrendingConfig() 32 - const {trendingDisabled} = useTrendingSettings() 33 - return enabled && !trendingDisabled ? <Inner /> : null 34 - } 35 - 36 - function Inner() { 37 - const t = useTheme() 38 - const {_} = useLingui() 39 - const gutters = useGutters([0, 'compact']) 40 - const {data: trending, error, isLoading} = useTrendingTopics() 41 - const noTopics = !isLoading && !error && !trending?.topics?.length 42 - const {setTrendingDisabled} = useTrendingSettingsApi() 43 - const trendingPrompt = Prompt.usePromptControl() 44 - 45 - const onConfirmHide = React.useCallback(() => { 46 - logEvent('trendingTopics:hide', {context: 'explore:trending'}) 47 - setTrendingDisabled(true) 48 - }, [setTrendingDisabled]) 49 - 50 - return error || noTopics ? null : ( 51 - <> 52 - <View 53 - style={[ 54 - a.flex_row, 55 - isWeb 56 - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 57 - : [a.p_lg, a.pt_2xl, a.gap_md], 58 - a.border_b, 59 - t.atoms.border_contrast_low, 60 - ]}> 61 - <View style={[a.flex_1, a.gap_sm]}> 62 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 63 - <Trending 64 - size="lg" 65 - fill={t.palette.primary_500} 66 - style={{marginLeft: -2}} 67 - /> 68 - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> 69 - <Trans>Trending</Trans> 70 - </Text> 71 - <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> 72 - <GradientFill gradient={tokens.gradients.primary} /> 73 - <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> 74 - <Trans>BETA</Trans> 75 - </Text> 76 - </View> 77 - </View> 78 - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 79 - <Trans>What people are posting about.</Trans> 80 - </Text> 81 - </View> 82 - <Button 83 - label={_(msg`Hide trending topics`)} 84 - size="small" 85 - variant="ghost" 86 - color="secondary" 87 - shape="round" 88 - onPress={() => trendingPrompt.open()}> 89 - <ButtonIcon icon={X} /> 90 - </Button> 91 - </View> 92 - 93 - <View style={[a.pt_md, a.pb_lg]}> 94 - <View 95 - style={[ 96 - a.flex_row, 97 - a.justify_start, 98 - a.flex_wrap, 99 - {rowGap: 8, columnGap: 6}, 100 - gutters, 101 - ]}> 102 - {isLoading ? ( 103 - Array(TRENDING_TOPICS_COUNT) 104 - .fill(0) 105 - .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) 106 - ) : !trending?.topics ? null : ( 107 - <> 108 - {trending.topics.map(topic => ( 109 - <TrendingTopicLink 110 - key={topic.link} 111 - topic={topic} 112 - onPress={() => { 113 - logEvent('trendingTopic:click', {context: 'explore'}) 114 - }}> 115 - {({hovered}) => ( 116 - <TrendingTopic 117 - topic={topic} 118 - style={[ 119 - hovered && [ 120 - t.atoms.border_contrast_high, 121 - t.atoms.bg_contrast_25, 122 - ], 123 - ]} 124 - /> 125 - )} 126 - </TrendingTopicLink> 127 - ))} 128 - </> 129 - )} 130 - </View> 131 - </View> 132 - 133 - <Prompt.Basic 134 - control={trendingPrompt} 135 - title={_(msg`Hide trending topics?`)} 136 - description={_(msg`You can update this later from your settings.`)} 137 - confirmButtonCta={_(msg`Hide`)} 138 - onConfirm={onConfirmHide} 139 - /> 140 - </> 141 - ) 142 - }
···
+33 -70
src/screens/Search/components/ExploreTrendingVideos.tsx src/screens/Search/modules/ExploreTrendingVideos.tsx
··· 1 - import React from 'react' 2 import {ScrollView, View} from 'react-native' 3 import {AppBskyEmbedVideo, AtUri} from '@atproto/api' 4 import {msg, Trans} from '@lingui/macro' ··· 8 9 import {VIDEO_FEED_URI} from '#/lib/constants' 10 import {makeCustomFeedLink} from '#/lib/routes/links' 11 - import {logEvent} from '#/lib/statsig/statsig' 12 - import {isWeb} from '#/platform/detection' 13 - import {useSavedFeeds} from '#/state/queries/feed' 14 import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 15 - import {useAddSavedFeedsMutation} from '#/state/queries/preferences' 16 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 17 import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 18 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 - import {GradientFill} from '#/components/GradientFill' 20 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 21 - import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin' 22 - import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 23 import {Link} from '#/components/Link' 24 import {Text} from '#/components/Typography' 25 import { ··· 37 } 38 39 export function ExploreTrendingVideos() { 40 - const t = useTheme() 41 const {_} = useLingui() 42 const gutters = useGutters([0, 'base']) 43 const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) ··· 55 } 56 }) 57 58 - const {data: saved} = useSavedFeeds() 59 - const isSavedAlready = React.useMemo(() => { 60 - return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) 61 - }, [saved]) 62 63 - const {mutateAsync: addSavedFeeds, isPending: isPinPending} = 64 - useAddSavedFeedsMutation() 65 - const pinFeed = React.useCallback( 66 - (e: any) => { 67 - e.preventDefault() 68 69 - addSavedFeeds([ 70 - { 71 - type: 'feed', 72 - value: VIDEO_FEED_URI, 73 - pinned: true, 74 - }, 75 - ]) 76 77 - // prevent navigation 78 - return false 79 - }, 80 - [addSavedFeeds], 81 - ) 82 83 if (error) { 84 return null ··· 86 87 return ( 88 <View style={[a.pb_xl]}> 89 - <View 90 - style={[ 91 - a.flex_row, 92 - isWeb 93 - ? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 94 - : [a.p_lg, a.pt_xl, a.gap_md], 95 - a.border_b, 96 - t.atoms.border_contrast_low, 97 - ]}> 98 - <View style={[a.flex_1, a.gap_sm]}> 99 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 100 - <Graph 101 - size="lg" 102 - fill={t.palette.primary_500} 103 - style={{marginLeft: -2}} 104 - /> 105 - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> 106 - <Trans>Trending Videos</Trans> 107 - </Text> 108 - <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> 109 - <GradientFill gradient={tokens.gradients.primary} /> 110 - <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> 111 - <Trans>BETA</Trans> 112 - </Text> 113 - </View> 114 - </View> 115 - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 116 - <Trans>Popular videos in your network.</Trans> 117 - </Text> 118 - </View> 119 - </View> 120 - 121 <BlockDrawerGesture> 122 <ScrollView 123 horizontal ··· 153 </ScrollView> 154 </BlockDrawerGesture> 155 156 - {!isSavedAlready && ( 157 <View 158 style={[ 159 gutters, ··· 179 <ButtonIcon icon={Pin} position="right" /> 180 </Button> 181 </View> 182 - )} 183 </View> 184 ) 185 } ··· 191 }) { 192 const t = useTheme() 193 const {_} = useLingui() 194 - const items = React.useMemo(() => { 195 return data.pages 196 .flatMap(page => page.slices) 197 .map(slice => slice.items[0]) ··· 199 .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) 200 .slice(0, 8) 201 }, [data]) 202 - const href = React.useMemo(() => { 203 const urip = new AtUri(VIDEO_FEED_URI) 204 return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') 205 }, []) ··· 217 sourceInterstitial: 'explore', 218 }} 219 onInteract={() => { 220 - logEvent('videoCard:click', { 221 - context: 'interstitial:explore', 222 - }) 223 }} 224 /> 225 </View>
··· 1 + import {useMemo} from 'react' 2 import {ScrollView, View} from 'react-native' 3 import {AppBskyEmbedVideo, AtUri} from '@atproto/api' 4 import {msg, Trans} from '@lingui/macro' ··· 8 9 import {VIDEO_FEED_URI} from '#/lib/constants' 10 import {makeCustomFeedLink} from '#/lib/routes/links' 11 + import {logger} from '#/logger' 12 import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed' 13 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 14 import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 15 + import {ButtonIcon} from '#/components/Button' 16 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 17 import {Link} from '#/components/Link' 18 import {Text} from '#/components/Typography' 19 import { ··· 31 } 32 33 export function ExploreTrendingVideos() { 34 const {_} = useLingui() 35 const gutters = useGutters([0, 'base']) 36 const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) ··· 48 } 49 }) 50 51 + // const {data: saved} = useSavedFeeds() 52 + // const isSavedAlready = useMemo(() => { 53 + // return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI) 54 + // }, [saved]) 55 56 + // const {mutateAsync: addSavedFeeds, isPending: isPinPending} = 57 + // useAddSavedFeedsMutation() 58 + // const pinFeed = useCallback( 59 + // (e: any) => { 60 + // e.preventDefault() 61 62 + // addSavedFeeds([ 63 + // { 64 + // type: 'feed', 65 + // value: VIDEO_FEED_URI, 66 + // pinned: true, 67 + // }, 68 + // ]) 69 70 + // // prevent navigation 71 + // return false 72 + // }, 73 + // [addSavedFeeds], 74 + // ) 75 76 if (error) { 77 return null ··· 79 80 return ( 81 <View style={[a.pb_xl]}> 82 <BlockDrawerGesture> 83 <ScrollView 84 horizontal ··· 114 </ScrollView> 115 </BlockDrawerGesture> 116 117 + {/* {!isSavedAlready && ( 118 <View 119 style={[ 120 gutters, ··· 140 <ButtonIcon icon={Pin} position="right" /> 141 </Button> 142 </View> 143 + )} */} 144 </View> 145 ) 146 } ··· 152 }) { 153 const t = useTheme() 154 const {_} = useLingui() 155 + const items = useMemo(() => { 156 return data.pages 157 .flatMap(page => page.slices) 158 .map(slice => slice.items[0]) ··· 160 .filter(item => AppBskyEmbedVideo.isView(item.post.embed)) 161 .slice(0, 8) 162 }, [data]) 163 + const href = useMemo(() => { 164 const urip = new AtUri(VIDEO_FEED_URI) 165 return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore') 166 }, []) ··· 178 sourceInterstitial: 'explore', 179 }} 180 onInteract={() => { 181 + logger.metric( 182 + 'videoCard:click', 183 + {context: 'interstitial:explore'}, 184 + {statsig: true}, 185 + ) 186 }} 187 /> 188 </View>
+170
src/screens/Search/components/ModuleHeader.tsx
···
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 4 + 5 + import {PressableScale} from '#/lib/custom-animations/PressableScale' 6 + import {makeCustomFeedLink} from '#/lib/routes/links' 7 + import {logger} from '#/logger' 8 + import {UserAvatar} from '#/view/com/util/UserAvatar' 9 + import { 10 + atoms as a, 11 + native, 12 + useGutters, 13 + useTheme, 14 + type ViewStyleProp, 15 + web, 16 + } from '#/alf' 17 + import {Button, ButtonIcon} from '#/components/Button' 18 + import * as FeedCard from '#/components/FeedCard' 19 + import {sizes as iconSizes} from '#/components/icons/common' 20 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 21 + import {Link} from '#/components/Link' 22 + import {Text, type TextProps} from '#/components/Typography' 23 + 24 + export function Container({ 25 + style, 26 + children, 27 + headerHeight, 28 + }: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) { 29 + const t = useTheme() 30 + const gutters = useGutters([0, 'base']) 31 + return ( 32 + <View 33 + style={[ 34 + gutters, 35 + a.flex_row, 36 + a.align_center, 37 + a.pt_2xl, 38 + a.pb_md, 39 + a.gap_sm, 40 + t.atoms.bg, 41 + headerHeight && web({position: 'sticky', top: headerHeight}), 42 + style, 43 + ]}> 44 + {children} 45 + </View> 46 + ) 47 + } 48 + 49 + export function FeedLink({ 50 + feed, 51 + children, 52 + }: { 53 + feed: AppBskyFeedDefs.GeneratorView 54 + children?: React.ReactNode 55 + }) { 56 + const t = useTheme() 57 + const {host: did, rkey} = useMemo(() => new AtUri(feed.uri), [feed.uri]) 58 + return ( 59 + <Link 60 + to={makeCustomFeedLink(did, rkey)} 61 + label={feed.displayName} 62 + style={[a.flex_1]}> 63 + {({focused, hovered, pressed}) => ( 64 + <View 65 + style={[ 66 + a.flex_1, 67 + a.flex_row, 68 + a.align_center, 69 + {gap: 10}, 70 + a.rounded_md, 71 + a.p_xs, 72 + {marginLeft: -6}, 73 + (focused || hovered || pressed) && t.atoms.bg_contrast_25, 74 + ]}> 75 + {children} 76 + </View> 77 + )} 78 + </Link> 79 + ) 80 + } 81 + 82 + export function FeedAvatar({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { 83 + return <UserAvatar type="algo" size={38} avatar={feed.avatar} /> 84 + } 85 + 86 + export function Icon({ 87 + icon: Comp, 88 + size = 'lg', 89 + }: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) { 90 + const iconSize = iconSizes[size] 91 + 92 + return ( 93 + <View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}> 94 + <Comp width={iconSize} /> 95 + </View> 96 + ) 97 + } 98 + 99 + export function TitleText({style, ...props}: TextProps) { 100 + return ( 101 + <Text style={[a.font_bold, a.flex_1, a.text_xl, style]} emoji {...props} /> 102 + ) 103 + } 104 + 105 + export function SubtitleText({style, ...props}: TextProps) { 106 + const t = useTheme() 107 + return ( 108 + <Text 109 + style={[ 110 + t.atoms.text_contrast_medium, 111 + a.leading_tight, 112 + a.flex_1, 113 + a.text_sm, 114 + style, 115 + ]} 116 + {...props} 117 + /> 118 + ) 119 + } 120 + 121 + export function SearchButton({ 122 + label, 123 + metricsTag, 124 + onPress, 125 + }: { 126 + label: string 127 + metricsTag: 'suggestedAccounts' | 'suggestedFeeds' 128 + onPress?: () => void 129 + }) { 130 + return ( 131 + <Button 132 + label={label} 133 + size="small" 134 + variant="ghost" 135 + color="secondary" 136 + shape="round" 137 + PressableComponent={native(PressableScale)} 138 + onPress={() => { 139 + logger.metric( 140 + 'explore:module:searchButtonPress', 141 + {module: metricsTag}, 142 + {statsig: true}, 143 + ) 144 + onPress?.() 145 + }} 146 + style={[ 147 + { 148 + right: -4, 149 + }, 150 + ]}> 151 + <ButtonIcon icon={SearchIcon} size="lg" /> 152 + </Button> 153 + ) 154 + } 155 + 156 + export function PinButton({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { 157 + return ( 158 + <View style={[a.z_20, {marginRight: -6}]}> 159 + <FeedCard.SaveButton 160 + pin 161 + view={feed} 162 + size="large" 163 + color="secondary" 164 + variant="ghost" 165 + shape="square" 166 + text={false} 167 + /> 168 + </View> 169 + ) 170 + }
+169
src/screens/Search/components/SearchHistory.tsx
···
··· 1 + import {Pressable, ScrollView, StyleSheet, View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {createHitslop, HITSLOP_10} from '#/lib/constants' 6 + import {makeProfileLink} from '#/lib/routes/links' 7 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 8 + import {Link} from '#/view/com/util/Link' 9 + import {UserAvatar} from '#/view/com/util/UserAvatar' 10 + import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 11 + import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 12 + import {Button, ButtonIcon} from '#/components/Button' 13 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 + import * as Layout from '#/components/Layout' 15 + import {Text} from '#/components/Typography' 16 + import type * as bsky from '#/types/bsky' 17 + 18 + export function SearchHistory({ 19 + searchHistory, 20 + selectedProfiles, 21 + onItemClick, 22 + onProfileClick, 23 + onRemoveItemClick, 24 + onRemoveProfileClick, 25 + }: { 26 + searchHistory: string[] 27 + selectedProfiles: bsky.profile.AnyProfileView[] 28 + onItemClick: (item: string) => void 29 + onProfileClick: (profile: bsky.profile.AnyProfileView) => void 30 + onRemoveItemClick: (item: string) => void 31 + onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void 32 + }) { 33 + const {gtMobile} = useBreakpoints() 34 + const t = useTheme() 35 + const {_} = useLingui() 36 + 37 + return ( 38 + <Layout.Content 39 + keyboardDismissMode="interactive" 40 + keyboardShouldPersistTaps="handled"> 41 + <View style={[a.w_full, a.px_md]}> 42 + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( 43 + <Text style={[a.text_md, a.font_bold, a.p_md]}> 44 + <Trans>Recent Searches</Trans> 45 + </Text> 46 + )} 47 + {selectedProfiles.length > 0 && ( 48 + <View 49 + style={[ 50 + styles.selectedProfilesContainer, 51 + !gtMobile && styles.selectedProfilesContainerMobile, 52 + ]}> 53 + <BlockDrawerGesture> 54 + <ScrollView 55 + horizontal 56 + keyboardShouldPersistTaps="handled" 57 + style={[ 58 + a.flex_row, 59 + a.flex_nowrap, 60 + {marginHorizontal: tokens.space._2xl * -1}, 61 + ]} 62 + contentContainerStyle={[a.px_2xl, a.border_0]}> 63 + {selectedProfiles.slice(0, 5).map((profile, index) => ( 64 + <View 65 + key={index} 66 + style={[ 67 + styles.profileItem, 68 + !gtMobile && styles.profileItemMobile, 69 + ]}> 70 + <Link 71 + href={makeProfileLink(profile)} 72 + title={profile.handle} 73 + asAnchor 74 + anchorNoUnderline 75 + onBeforePress={() => onProfileClick(profile)} 76 + style={[a.align_center, a.w_full]}> 77 + <UserAvatar 78 + avatar={profile.avatar} 79 + type={profile.associated?.labeler ? 'labeler' : 'user'} 80 + size={60} 81 + /> 82 + <Text 83 + emoji 84 + style={[a.text_xs, a.text_center, styles.profileName]} 85 + numberOfLines={1}> 86 + {sanitizeDisplayName( 87 + profile.displayName || profile.handle, 88 + )} 89 + </Text> 90 + </Link> 91 + <Pressable 92 + accessibilityRole="button" 93 + accessibilityLabel={_(msg`Remove profile`)} 94 + accessibilityHint={_( 95 + msg`Removes profile from search history`, 96 + )} 97 + onPress={() => onRemoveProfileClick(profile)} 98 + hitSlop={createHitslop(6)} 99 + style={styles.profileRemoveBtn}> 100 + <XIcon size="xs" style={t.atoms.text_contrast_low} /> 101 + </Pressable> 102 + </View> 103 + ))} 104 + </ScrollView> 105 + </BlockDrawerGesture> 106 + </View> 107 + )} 108 + {searchHistory.length > 0 && ( 109 + <View style={[a.pl_md, a.pr_xs, a.mt_md]}> 110 + {searchHistory.slice(0, 5).map((historyItem, index) => ( 111 + <View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}> 112 + <Pressable 113 + accessibilityRole="button" 114 + onPress={() => onItemClick(historyItem)} 115 + hitSlop={HITSLOP_10} 116 + style={[a.flex_1, a.py_md]}> 117 + <Text style={[a.text_md]}>{historyItem}</Text> 118 + </Pressable> 119 + <Button 120 + label={_(msg`Remove ${historyItem}`)} 121 + onPress={() => onRemoveItemClick(historyItem)} 122 + size="small" 123 + variant="ghost" 124 + color="secondary" 125 + shape="round"> 126 + <ButtonIcon icon={XIcon} /> 127 + </Button> 128 + </View> 129 + ))} 130 + </View> 131 + )} 132 + </View> 133 + </Layout.Content> 134 + ) 135 + } 136 + 137 + const styles = StyleSheet.create({ 138 + selectedProfilesContainer: { 139 + marginTop: 10, 140 + paddingHorizontal: 12, 141 + height: 80, 142 + }, 143 + selectedProfilesContainerMobile: { 144 + height: 100, 145 + }, 146 + profileItem: { 147 + alignItems: 'center', 148 + marginRight: 15, 149 + width: 78, 150 + }, 151 + profileItemMobile: { 152 + width: 70, 153 + }, 154 + profileName: { 155 + width: 78, 156 + marginTop: 6, 157 + }, 158 + profileRemoveBtn: { 159 + position: 'absolute', 160 + top: 0, 161 + right: 5, 162 + backgroundColor: 'white', 163 + borderRadius: 10, 164 + width: 18, 165 + height: 18, 166 + alignItems: 'center', 167 + justifyContent: 'center', 168 + }, 169 + })
+120
src/screens/Search/components/SearchLanguageDropdown.tsx
···
··· 1 + import {useMemo} from 'react' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {languageName} from '#/locale/helpers' 6 + import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' 7 + import {useLanguagePrefs} from '#/state/preferences' 8 + import {atoms as a, native, platform, tokens} from '#/alf' 9 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 + import { 11 + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 12 + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, 13 + } from '#/components/icons/Chevron' 14 + import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 15 + import * as Menu from '#/components/Menu' 16 + 17 + export function SearchLanguageDropdown({ 18 + value, 19 + onChange, 20 + }: { 21 + value: string 22 + onChange(value: string): void 23 + }) { 24 + const {_} = useLingui() 25 + const {appLanguage, contentLanguages} = useLanguagePrefs() 26 + 27 + const languages = useMemo(() => { 28 + return LANGUAGES.filter( 29 + (lang, index, self) => 30 + Boolean(lang.code2) && // reduce to the code2 varieties 31 + index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen) 32 + ) 33 + .map(l => ({ 34 + label: languageName(l, appLanguage), 35 + value: l.code2, 36 + key: l.code2 + l.code3, 37 + })) 38 + .sort((a, b) => { 39 + // prioritize user's languages 40 + const aIsUser = contentLanguages.includes(a.value) 41 + const bIsUser = contentLanguages.includes(b.value) 42 + if (aIsUser && !bIsUser) return -1 43 + if (bIsUser && !aIsUser) return 1 44 + // prioritize "common" langs in the network 45 + const aIsCommon = !!APP_LANGUAGES.find( 46 + al => 47 + // skip `ast`, because it uses a 3-letter code which conflicts with `as` 48 + // it begins with `a` anyway so still is top of the list 49 + al.code2 !== 'ast' && al.code2.startsWith(a.value), 50 + ) 51 + const bIsCommon = !!APP_LANGUAGES.find( 52 + al => 53 + // ditto 54 + al.code2 !== 'ast' && al.code2.startsWith(b.value), 55 + ) 56 + if (aIsCommon && !bIsCommon) return -1 57 + if (bIsCommon && !aIsCommon) return 1 58 + // fall back to alphabetical 59 + return a.label.localeCompare(b.label) 60 + }) 61 + }, [appLanguage, contentLanguages]) 62 + 63 + const currentLanguageLabel = 64 + languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) 65 + 66 + return ( 67 + <Menu.Root> 68 + <Menu.Trigger 69 + label={_( 70 + msg`Filter search by language (currently: ${currentLanguageLabel})`, 71 + )}> 72 + {({props}) => ( 73 + <Button 74 + {...props} 75 + label={props.accessibilityLabel} 76 + size="small" 77 + color={platform({native: 'primary', default: 'secondary'})} 78 + variant={platform({native: 'ghost', default: 'solid'})} 79 + style={native([ 80 + a.py_sm, 81 + a.px_sm, 82 + {marginRight: tokens.space.sm * -1}, 83 + ])}> 84 + <ButtonIcon icon={EarthIcon} /> 85 + <ButtonText>{currentLanguageLabel}</ButtonText> 86 + <ButtonIcon 87 + icon={platform({ 88 + native: ChevronUpDownIcon, 89 + default: ChevronDownIcon, 90 + })} 91 + /> 92 + </Button> 93 + )} 94 + </Menu.Trigger> 95 + <Menu.Outer> 96 + <Menu.LabelText> 97 + <Trans>Filter search by language</Trans> 98 + </Menu.LabelText> 99 + <Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}> 100 + <Menu.ItemText> 101 + <Trans>All languages</Trans> 102 + </Menu.ItemText> 103 + <Menu.ItemRadio selected={value === ''} /> 104 + </Menu.Item> 105 + <Menu.Divider /> 106 + <Menu.Group> 107 + {languages.map(lang => ( 108 + <Menu.Item 109 + key={lang.key} 110 + label={lang.label} 111 + onPress={() => onChange(lang.value)}> 112 + <Menu.ItemText>{lang.label}</Menu.ItemText> 113 + <Menu.ItemRadio selected={value === lang.value} /> 114 + </Menu.Item> 115 + ))} 116 + </Menu.Group> 117 + </Menu.Outer> 118 + </Menu.Root> 119 + ) 120 + }
+296
src/screens/Search/components/StarterPackCard.tsx
···
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + type AppBskyGraphDefs, 5 + AppBskyGraphStarterpack, 6 + moderateProfile, 7 + } from '@atproto/api' 8 + import {msg, Trans} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {sanitizeHandle} from '#/lib/strings/handles' 12 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 + import {useSession} from '#/state/session' 14 + import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 15 + import {UserAvatar} from '#/view/com/util/UserAvatar' 16 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 17 + import {ButtonText} from '#/components/Button' 18 + import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 19 + import {Link} from '#/components/Link' 20 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 21 + import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard' 22 + import {Text} from '#/components/Typography' 23 + import * as bsky from '#/types/bsky' 24 + 25 + export function StarterPackCard({ 26 + view, 27 + }: { 28 + view: AppBskyGraphDefs.StarterPackView 29 + }) { 30 + const t = useTheme() 31 + const {_} = useLingui() 32 + const {currentAccount} = useSession() 33 + const {gtPhone} = useBreakpoints() 34 + const link = useStarterPackLink({view}) 35 + 36 + if ( 37 + !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 38 + view.record, 39 + AppBskyGraphStarterpack.isRecord, 40 + ) 41 + ) { 42 + return null 43 + } 44 + 45 + const profileCount = gtPhone ? 11 : 8 46 + const profiles = view.listItemsSample 47 + ?.slice(0, profileCount) 48 + .map(item => item.subject) 49 + 50 + return ( 51 + <View 52 + style={[ 53 + a.w_full, 54 + a.p_lg, 55 + a.gap_md, 56 + a.border, 57 + a.rounded_sm, 58 + a.overflow_hidden, 59 + t.atoms.border_contrast_low, 60 + ]}> 61 + <View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}> 62 + <Link 63 + to={link.to} 64 + label={link.label} 65 + style={[a.absolute, a.inset_0]} 66 + onHoverIn={link.precache} 67 + onPress={link.precache}> 68 + <View /> 69 + </Link> 70 + </View> 71 + 72 + <AvatarStack 73 + profiles={profiles ?? []} 74 + numPending={profileCount} 75 + total={view.list?.listItemCount} 76 + /> 77 + 78 + <View 79 + style={[ 80 + a.w_full, 81 + a.flex_row, 82 + a.align_start, 83 + a.gap_lg, 84 + web({ 85 + position: 'static', 86 + zIndex: 'unset', 87 + }), 88 + ]}> 89 + <View style={[a.flex_1]}> 90 + <Text 91 + emoji 92 + style={[a.text_md, a.font_bold, a.leading_snug]} 93 + numberOfLines={1}> 94 + {view.record.name} 95 + </Text> 96 + <Text 97 + emoji 98 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]} 99 + numberOfLines={1}> 100 + {view.creator?.did === currentAccount?.did 101 + ? _(msg`By you`) 102 + : _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)} 103 + </Text> 104 + </View> 105 + <Link 106 + to={link.to} 107 + label={link.label} 108 + onHoverIn={link.precache} 109 + onPress={link.precache} 110 + variant="solid" 111 + color="secondary" 112 + size="small" 113 + style={[a.z_50]}> 114 + <ButtonText> 115 + <Trans>Open pack</Trans> 116 + </ButtonText> 117 + </Link> 118 + </View> 119 + </View> 120 + ) 121 + } 122 + 123 + export function AvatarStack({ 124 + profiles, 125 + numPending, 126 + total, 127 + }: { 128 + profiles: bsky.profile.AnyProfileView[] 129 + numPending: number 130 + total?: number 131 + }) { 132 + const t = useTheme() 133 + const {gtPhone} = useBreakpoints() 134 + const moderationOpts = useModerationOpts() 135 + const computedTotal = (total ?? numPending) - numPending 136 + const circlesCount = numPending + 1 // add total at end 137 + const widthPerc = 100 / circlesCount 138 + const [size, setSize] = React.useState<number | null>(null) 139 + 140 + const isPending = (numPending && profiles.length === 0) || !moderationOpts 141 + 142 + const items = isPending 143 + ? Array.from({length: numPending ?? circlesCount}).map((_, i) => ({ 144 + key: i, 145 + profile: null, 146 + moderation: null, 147 + })) 148 + : profiles.map(item => ({ 149 + key: item.did, 150 + profile: item, 151 + moderation: moderateProfile(item, moderationOpts), 152 + })) 153 + 154 + return ( 155 + <View 156 + style={[ 157 + a.w_full, 158 + a.flex_row, 159 + a.align_center, 160 + a.relative, 161 + {width: `${100 - widthPerc * 0.2}%`}, 162 + ]}> 163 + {items.map((item, i) => ( 164 + <View 165 + key={item.key} 166 + style={[ 167 + { 168 + width: `${widthPerc}%`, 169 + zIndex: 100 - i, 170 + }, 171 + ]}> 172 + <View 173 + style={[ 174 + a.relative, 175 + { 176 + width: '120%', 177 + }, 178 + ]}> 179 + <View 180 + onLayout={e => setSize(e.nativeEvent.layout.width)} 181 + style={[ 182 + a.rounded_full, 183 + t.atoms.bg_contrast_25, 184 + { 185 + paddingTop: '100%', 186 + }, 187 + ]}> 188 + {size && item.profile ? ( 189 + <UserAvatar 190 + size={size} 191 + avatar={item.profile.avatar} 192 + type={item.profile.associated?.labeler ? 'labeler' : 'user'} 193 + moderation={item.moderation.ui('avatar')} 194 + style={[a.absolute, a.inset_0]} 195 + /> 196 + ) : ( 197 + <MediaInsetBorder style={[a.rounded_full]} /> 198 + )} 199 + </View> 200 + </View> 201 + </View> 202 + ))} 203 + <View 204 + style={[ 205 + { 206 + width: `${widthPerc}%`, 207 + zIndex: 1, 208 + }, 209 + ]}> 210 + <View 211 + style={[ 212 + a.relative, 213 + { 214 + width: '120%', 215 + }, 216 + ]}> 217 + <View 218 + style={[ 219 + { 220 + paddingTop: '100%', 221 + }, 222 + ]}> 223 + <View 224 + style={[ 225 + a.absolute, 226 + a.inset_0, 227 + a.rounded_full, 228 + a.align_center, 229 + a.justify_center, 230 + { 231 + backgroundColor: t.atoms.text_contrast_low.color, 232 + }, 233 + ]}> 234 + {computedTotal > 0 ? ( 235 + <Text 236 + style={[ 237 + gtPhone ? a.text_md : a.text_sm, 238 + a.font_bold, 239 + a.leading_snug, 240 + {color: 'white'}, 241 + ]}> 242 + <Trans comment="Indicates the number of additional profiles are in the Starter Pack e.g. +12"> 243 + +{computedTotal} 244 + </Trans> 245 + </Text> 246 + ) : ( 247 + <Plus fill="white" /> 248 + )} 249 + </View> 250 + </View> 251 + </View> 252 + </View> 253 + </View> 254 + ) 255 + } 256 + 257 + export function StarterPackCardSkeleton() { 258 + const t = useTheme() 259 + const {gtPhone} = useBreakpoints() 260 + 261 + const profileCount = gtPhone ? 11 : 8 262 + 263 + return ( 264 + <View 265 + style={[ 266 + a.w_full, 267 + a.p_lg, 268 + a.gap_md, 269 + a.border, 270 + a.rounded_sm, 271 + a.overflow_hidden, 272 + t.atoms.border_contrast_low, 273 + ]}> 274 + <AvatarStack profiles={[]} numPending={profileCount} /> 275 + 276 + <View 277 + style={[ 278 + a.w_full, 279 + a.flex_row, 280 + a.align_start, 281 + a.gap_lg, 282 + web({ 283 + position: 'static', 284 + zIndex: 'unset', 285 + }), 286 + ]}> 287 + <View style={[a.flex_1, a.gap_xs]}> 288 + <LoadingPlaceholder width={180} height={18} /> 289 + <LoadingPlaceholder width={120} height={14} /> 290 + </View> 291 + 292 + <LoadingPlaceholder width={100} height={33} /> 293 + </View> 294 + </View> 295 + ) 296 + }
+13
src/screens/Search/index.tsx
···
··· 1 + import { 2 + type NativeStackScreenProps, 3 + type SearchTabNavigatorParams, 4 + } from '#/lib/routes/types' 5 + import {SearchScreenShell} from './Shell' 6 + 7 + export function SearchScreen( 8 + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 9 + ) { 10 + const queryParam = props.route?.params?.q ?? '' 11 + 12 + return <SearchScreenShell queryParam={queryParam} testID="searchScreen" /> 13 + }
+264
src/screens/Search/modules/ExploreFeedPreviews.tsx
···
··· 1 + import {useMemo} from 'react' 2 + import {type AppBskyFeedDefs, moderatePost} from '@atproto/api' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useInfiniteQuery} from '@tanstack/react-query' 6 + 7 + import {CustomFeedAPI} from '#/lib/api/feed/custom' 8 + import {aggregateUserInterests} from '#/lib/api/feed/utils' 9 + import {FeedTuner} from '#/lib/api/feed-manip' 10 + import {cleanError} from '#/lib/strings/errors' 11 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 12 + import { 13 + type FeedPostSlice, 14 + type FeedPostSliceItem, 15 + } from '#/state/queries/post-feed' 16 + import {usePreferencesQuery} from '#/state/queries/preferences' 17 + import {useAgent} from '#/state/session' 18 + 19 + const RQKEY_ROOT = 'feed-previews' 20 + const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds] 21 + 22 + const LIMIT = 8 // sliced to 6, overfetch to account for moderation 23 + 24 + export type FeedPreviewItem = 25 + | { 26 + type: 'topBorder' 27 + key: string 28 + } 29 + | { 30 + type: 'preview:loading' 31 + key: string 32 + } 33 + | { 34 + type: 'preview:error' 35 + key: string 36 + message: string 37 + error: string 38 + } 39 + | { 40 + type: 'preview:loadMoreError' 41 + key: string 42 + } 43 + | { 44 + type: 'preview:empty' 45 + key: string 46 + } 47 + | { 48 + type: 'preview:header' 49 + key: string 50 + feed: AppBskyFeedDefs.GeneratorView 51 + } 52 + | { 53 + type: 'preview:footer' 54 + key: string 55 + } 56 + // copied from PostFeed.tsx 57 + | { 58 + type: 'preview:sliceItem' 59 + key: string 60 + slice: FeedPostSlice 61 + indexInSlice: number 62 + showReplyTo: boolean 63 + hideTopBorder: boolean 64 + } 65 + | { 66 + type: 'preview:sliceViewFullThread' 67 + key: string 68 + uri: string 69 + } 70 + 71 + export function useFeedPreviews(feeds: AppBskyFeedDefs.GeneratorView[]) { 72 + const uris = feeds.map(feed => feed.uri) 73 + const {_} = useLingui() 74 + const agent = useAgent() 75 + const {data: preferences} = usePreferencesQuery() 76 + const userInterests = aggregateUserInterests(preferences) 77 + const moderationOpts = useModerationOpts() 78 + const enabled = feeds.length > 0 79 + 80 + const query = useInfiniteQuery({ 81 + enabled, 82 + queryKey: RQKEY(uris), 83 + queryFn: async ({pageParam}) => { 84 + const feed = feeds[pageParam] 85 + const api = new CustomFeedAPI({ 86 + agent, 87 + feedParams: {feed: feed.uri}, 88 + userInterests, 89 + }) 90 + const data = await api.fetch({cursor: undefined, limit: LIMIT}) 91 + return { 92 + feed, 93 + posts: data.feed, 94 + } 95 + }, 96 + initialPageParam: 0, 97 + getNextPageParam: (_p, _a, count) => 98 + count < feeds.length ? count + 1 : undefined, 99 + }) 100 + 101 + const {data, isFetched, isError, isPending, error} = query 102 + 103 + return { 104 + query, 105 + data: useMemo<FeedPreviewItem[]>(() => { 106 + const items: FeedPreviewItem[] = [] 107 + 108 + if (!enabled) return items 109 + 110 + const isEmpty = 111 + !isPending && !data?.pages?.some(page => page.posts.length) 112 + 113 + if (isFetched) { 114 + if (isError && isEmpty) { 115 + items.push({ 116 + type: 'preview:error', 117 + key: 'error', 118 + message: _(msg`An error occurred while fetching the feed.`), 119 + error: cleanError(error), 120 + }) 121 + } else if (isEmpty) { 122 + items.push({ 123 + type: 'preview:empty', 124 + key: 'empty', 125 + }) 126 + } else if (data) { 127 + for (let pageIndex = 0; pageIndex < data.pages.length; pageIndex++) { 128 + const page = data.pages[pageIndex] 129 + // default feed tuner - we just want it to slice up the feed 130 + const tuner = new FeedTuner([]) 131 + const slices: FeedPreviewItem[] = [] 132 + 133 + let rowIndex = 0 134 + for (const item of tuner.tune(page.posts)) { 135 + if (item.isFallbackMarker) continue 136 + 137 + const moderations = item.items.map(item => 138 + moderatePost(item.post, moderationOpts!), 139 + ) 140 + 141 + // apply moderation filters 142 + item.items = item.items.filter((_, i) => { 143 + return !moderations[i]?.ui('contentList').filter 144 + }) 145 + 146 + const slice = { 147 + _reactKey: item._reactKey, 148 + _isFeedPostSlice: true, 149 + isFallbackMarker: false, 150 + isIncompleteThread: item.isIncompleteThread, 151 + feedContext: item.feedContext, 152 + reason: item.reason, 153 + feedPostUri: item.feedPostUri, 154 + items: item.items.slice(0, 6).map((subItem, i) => { 155 + const feedPostSliceItem: FeedPostSliceItem = { 156 + _reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`, 157 + uri: subItem.post.uri, 158 + post: subItem.post, 159 + record: subItem.record, 160 + moderation: moderations[i], 161 + parentAuthor: subItem.parentAuthor, 162 + isParentBlocked: subItem.isParentBlocked, 163 + isParentNotFound: subItem.isParentNotFound, 164 + } 165 + return feedPostSliceItem 166 + }), 167 + } 168 + if (slice.isIncompleteThread && slice.items.length >= 3) { 169 + const beforeLast = slice.items.length - 2 170 + const last = slice.items.length - 1 171 + slices.push({ 172 + type: 'preview:sliceItem', 173 + key: slice.items[0]._reactKey, 174 + slice: slice, 175 + indexInSlice: 0, 176 + showReplyTo: false, 177 + hideTopBorder: rowIndex === 0, 178 + }) 179 + slices.push({ 180 + type: 'preview:sliceViewFullThread', 181 + key: slice._reactKey + '-viewFullThread', 182 + uri: slice.items[0].uri, 183 + }) 184 + slices.push({ 185 + type: 'preview:sliceItem', 186 + key: slice.items[beforeLast]._reactKey, 187 + slice: slice, 188 + indexInSlice: beforeLast, 189 + showReplyTo: 190 + slice.items[beforeLast].parentAuthor?.did !== 191 + slice.items[beforeLast].post.author.did, 192 + hideTopBorder: false, 193 + }) 194 + slices.push({ 195 + type: 'preview:sliceItem', 196 + key: slice.items[last]._reactKey, 197 + slice: slice, 198 + indexInSlice: last, 199 + showReplyTo: false, 200 + hideTopBorder: false, 201 + }) 202 + } else { 203 + for (let i = 0; i < slice.items.length; i++) { 204 + slices.push({ 205 + type: 'preview:sliceItem', 206 + key: slice.items[i]._reactKey, 207 + slice: slice, 208 + indexInSlice: i, 209 + showReplyTo: i === 0, 210 + hideTopBorder: i === 0 && rowIndex === 0, 211 + }) 212 + } 213 + } 214 + 215 + rowIndex++ 216 + } 217 + 218 + if (slices.length > 0) { 219 + if (pageIndex > 0) { 220 + items.push({ 221 + type: 'topBorder', 222 + key: `topBorder-${page.feed.uri}`, 223 + }) 224 + } 225 + items.push( 226 + { 227 + type: 'preview:footer', 228 + key: `footer-${page.feed.uri}`, 229 + }, 230 + { 231 + type: 'preview:header', 232 + key: `header-${page.feed.uri}`, 233 + feed: page.feed, 234 + }, 235 + ...slices, 236 + ) 237 + } 238 + } 239 + } else if (isError && !isEmpty) { 240 + items.push({ 241 + type: 'preview:loadMoreError', 242 + key: 'loadMoreError', 243 + }) 244 + } 245 + } else { 246 + items.push({ 247 + type: 'preview:loading', 248 + key: 'loading', 249 + }) 250 + } 251 + 252 + return items 253 + }, [ 254 + enabled, 255 + data, 256 + isFetched, 257 + isError, 258 + isPending, 259 + moderationOpts, 260 + _, 261 + error, 262 + ]), 263 + } 264 + }
+228
src/screens/Search/modules/ExploreSuggestedAccounts.tsx
···
··· 1 + import {memo, useEffect} from 'react' 2 + import {View} from 'react-native' 3 + import {type AppBskyActorSearchActors, type ModerationOpts} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {type InfiniteData} from '@tanstack/react-query' 7 + 8 + import {logger} from '#/logger' 9 + import {usePreferencesQuery} from '#/state/queries/preferences' 10 + import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 11 + import { 12 + popularInterests, 13 + useInterestsDisplayNames, 14 + } from '#/screens/Onboarding/state' 15 + import {useTheme} from '#/alf' 16 + import {atoms as a} from '#/alf' 17 + import {Button} from '#/components/Button' 18 + import * as ProfileCard from '#/components/ProfileCard' 19 + import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' 20 + import {Text} from '#/components/Typography' 21 + import type * as bsky from '#/types/bsky' 22 + 23 + export function useLoadEnoughProfiles({ 24 + interest, 25 + data, 26 + isLoading, 27 + isFetchingNextPage, 28 + hasNextPage, 29 + fetchNextPage, 30 + }: { 31 + interest: string | null 32 + data?: InfiniteData<AppBskyActorSearchActors.OutputSchema> 33 + isLoading: boolean 34 + isFetchingNextPage: boolean 35 + hasNextPage: boolean 36 + fetchNextPage: () => Promise<unknown> 37 + }) { 38 + const profileCount = 39 + data?.pages.flatMap(page => 40 + page.actors.filter(actor => !actor.viewer?.following), 41 + ).length || 0 42 + const isAnyLoading = isLoading || isFetchingNextPage 43 + const isEnoughProfiles = profileCount > 3 44 + const shouldFetchMore = !isEnoughProfiles && hasNextPage && !!interest 45 + useEffect(() => { 46 + if (shouldFetchMore && !isAnyLoading) { 47 + logger.info('Not enough suggested accounts - fetching more') 48 + fetchNextPage() 49 + } 50 + }, [shouldFetchMore, fetchNextPage, isAnyLoading, interest]) 51 + 52 + return { 53 + isReady: !shouldFetchMore, 54 + } 55 + } 56 + 57 + export function SuggestedAccountsTabBar({ 58 + selectedInterest, 59 + onSelectInterest, 60 + }: { 61 + selectedInterest: string | null 62 + onSelectInterest: (interest: string | null) => void 63 + }) { 64 + const {_} = useLingui() 65 + const interestsDisplayNames = useInterestsDisplayNames() 66 + const {data: preferences} = usePreferencesQuery() 67 + const personalizedInterests = preferences?.interests?.tags 68 + const interests = Object.keys(interestsDisplayNames) 69 + .sort(boostInterests(popularInterests)) 70 + .sort(boostInterests(personalizedInterests)) 71 + return ( 72 + <BlockDrawerGesture> 73 + <Tabs 74 + interests={['all', ...interests]} 75 + selectedInterest={selectedInterest || 'all'} 76 + onSelectTab={tab => { 77 + logger.metric( 78 + 'explore:suggestedAccounts:tabPressed', 79 + {tab: tab}, 80 + {statsig: true}, 81 + ) 82 + onSelectInterest(tab === 'all' ? null : tab) 83 + }} 84 + hasSearchText={false} 85 + interestsDisplayNames={{ 86 + all: _(msg`All`), 87 + ...interestsDisplayNames, 88 + }} 89 + TabComponent={Tab} 90 + /> 91 + </BlockDrawerGesture> 92 + ) 93 + } 94 + 95 + let Tab = ({ 96 + onSelectTab, 97 + interest, 98 + active, 99 + index, 100 + interestsDisplayName, 101 + onLayout, 102 + }: { 103 + onSelectTab: (index: number) => void 104 + interest: string 105 + active: boolean 106 + index: number 107 + interestsDisplayName: string 108 + onLayout: (index: number, x: number, width: number) => void 109 + }): React.ReactNode => { 110 + const t = useTheme() 111 + const {_} = useLingui() 112 + const activeText = active ? _(msg` (active)`) : '' 113 + return ( 114 + <View 115 + key={interest} 116 + onLayout={e => 117 + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 118 + }> 119 + <Button 120 + label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 121 + onPress={() => onSelectTab(index)}> 122 + {({hovered, pressed, focused}) => ( 123 + <View 124 + style={[ 125 + a.rounded_full, 126 + a.px_lg, 127 + a.py_sm, 128 + a.border, 129 + active || hovered || pressed || focused 130 + ? [ 131 + t.atoms.bg_contrast_25, 132 + {borderColor: t.atoms.bg_contrast_25.backgroundColor}, 133 + ] 134 + : [t.atoms.bg, t.atoms.border_contrast_low], 135 + ]}> 136 + <Text 137 + style={[ 138 + /* TODO: medium weight */ 139 + active || hovered || pressed || focused 140 + ? t.atoms.text 141 + : t.atoms.text_contrast_medium, 142 + ]}> 143 + {interestsDisplayName} 144 + </Text> 145 + </View> 146 + )} 147 + </Button> 148 + </View> 149 + ) 150 + } 151 + Tab = memo(Tab) 152 + 153 + /** 154 + * Profile card for suggested accounts. Note: border is on the bottom edge 155 + */ 156 + let SuggestedProfileCard = ({ 157 + profile, 158 + moderationOpts, 159 + recId, 160 + position, 161 + }: { 162 + profile: bsky.profile.AnyProfileView 163 + moderationOpts: ModerationOpts 164 + recId?: number 165 + position: number 166 + }): React.ReactNode => { 167 + const t = useTheme() 168 + return ( 169 + <ProfileCard.Link 170 + profile={profile} 171 + style={[a.flex_1]} 172 + onPress={() => { 173 + logger.metric( 174 + 'suggestedUser:press', 175 + { 176 + logContext: 'Explore', 177 + recId, 178 + position, 179 + }, 180 + {statsig: true}, 181 + ) 182 + }}> 183 + <View 184 + style={[ 185 + a.w_full, 186 + a.py_lg, 187 + a.px_lg, 188 + a.border_t, 189 + t.atoms.border_contrast_low, 190 + a.flex_1, 191 + ]}> 192 + <ProfileCard.Outer> 193 + <ProfileCard.Header> 194 + <ProfileCard.Avatar 195 + profile={profile} 196 + moderationOpts={moderationOpts} 197 + /> 198 + <ProfileCard.NameAndHandle 199 + profile={profile} 200 + moderationOpts={moderationOpts} 201 + /> 202 + <ProfileCard.FollowButton 203 + profile={profile} 204 + moderationOpts={moderationOpts} 205 + withIcon={false} 206 + logContext="ExploreSuggestedAccounts" 207 + onFollow={() => { 208 + logger.metric( 209 + 'suggestedUser:follow', 210 + { 211 + logContext: 'Explore', 212 + location: 'Card', 213 + recId, 214 + position, 215 + }, 216 + {statsig: true}, 217 + ) 218 + }} 219 + /> 220 + </ProfileCard.Header> 221 + <ProfileCard.Description profile={profile} numberOfLines={2} /> 222 + </ProfileCard.Outer> 223 + </View> 224 + </ProfileCard.Link> 225 + ) 226 + } 227 + SuggestedProfileCard = memo(SuggestedProfileCard) 228 + export {SuggestedProfileCard}
+278
src/screens/Search/modules/ExploreTrendingTopics.tsx
···
··· 1 + import {Pressable, View} from 'react-native' 2 + import {type AppBskyUnspeccedDefs} from '@atproto/api' 3 + import {msg, plural, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {logger} from '#/logger' 7 + import {useTrendingSettings} from '#/state/preferences/trending' 8 + import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' 9 + import {useTrendingConfig} from '#/state/trending-config' 10 + import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 11 + import {formatCount} from '#/view/com/util/numeric/format' 12 + import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf' 13 + import {AvatarStack} from '#/components/AvatarStack' 14 + import {type Props as SVGIconProps} from '#/components/icons/common' 15 + import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame' 16 + import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' 17 + import {Link} from '#/components/Link' 18 + import {Text} from '#/components/Typography' 19 + 20 + const TOPIC_COUNT = 5 21 + 22 + export function ExploreTrendingTopics() { 23 + const {enabled} = useTrendingConfig() 24 + const {trendingDisabled} = useTrendingSettings() 25 + return enabled && !trendingDisabled ? <Inner /> : null 26 + } 27 + 28 + function Inner() { 29 + const {data: trending, error, isLoading} = useGetTrendsQuery() 30 + const noTopics = !isLoading && !error && !trending?.trends?.length 31 + 32 + return isLoading ? ( 33 + Array.from({length: TOPIC_COUNT}).map((__, i) => ( 34 + <TrendingTopicRowSkeleton key={i} withPosts={i === 0} /> 35 + )) 36 + ) : error || !trending?.trends || noTopics ? null : ( 37 + <> 38 + {trending.trends.map((trend, index) => ( 39 + <TrendRow 40 + key={trend.link} 41 + trend={trend} 42 + rank={index + 1} 43 + onPress={() => { 44 + logger.metric('trendingTopic:click', {context: 'explore'}) 45 + }} 46 + /> 47 + ))} 48 + </> 49 + ) 50 + } 51 + 52 + export function TrendRow({ 53 + trend, 54 + rank, 55 + children, 56 + onPress, 57 + }: ViewStyleProp & { 58 + trend: AppBskyUnspeccedDefs.TrendView 59 + rank: number 60 + children?: React.ReactNode 61 + onPress?: () => void 62 + }) { 63 + const t = useTheme() 64 + const {_, i18n} = useLingui() 65 + const gutters = useGutters([0, 'base']) 66 + 67 + const category = useCategoryDisplayName(trend?.category || 'other') 68 + const age = Math.floor( 69 + (Date.now() - new Date(trend.startedAt || Date.now()).getTime()) / 70 + (1000 * 60 * 60), 71 + ) 72 + const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age 73 + const postCount = trend.postCount 74 + ? _( 75 + plural(trend.postCount, { 76 + other: `${formatCount(i18n, trend.postCount)} posts`, 77 + }), 78 + ) 79 + : null 80 + 81 + return ( 82 + <Link 83 + testID={trend.link} 84 + label={_(msg`Browse topic ${trend.displayName}`)} 85 + to={trend.link} 86 + onPress={onPress} 87 + style={[a.border_b, t.atoms.border_contrast_low]} 88 + PressableComponent={Pressable}> 89 + {({hovered, pressed}) => ( 90 + <> 91 + <View 92 + style={[ 93 + gutters, 94 + a.w_full, 95 + a.py_lg, 96 + a.flex_row, 97 + a.gap_2xs, 98 + (hovered || pressed) && t.atoms.bg_contrast_25, 99 + ]}> 100 + <View style={[a.flex_1, a.gap_xs]}> 101 + <View style={[a.flex_row]}> 102 + <Text 103 + style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}> 104 + <Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'> 105 + {rank}. 106 + </Trans> 107 + </Text> 108 + <Text 109 + style={[a.text_md, a.font_bold, a.leading_snug]} 110 + numberOfLines={1}> 111 + {trend.displayName} 112 + </Text> 113 + </View> 114 + <View 115 + style={[ 116 + a.flex_row, 117 + a.gap_sm, 118 + a.align_center, 119 + {paddingLeft: 20}, 120 + ]}> 121 + {trend.actors.length > 0 && ( 122 + <AvatarStack size={20} profiles={trend.actors} /> 123 + )} 124 + <Text 125 + style={[ 126 + a.text_sm, 127 + t.atoms.text_contrast_medium, 128 + web(a.leading_snug), 129 + ]} 130 + numberOfLines={1}> 131 + {postCount} 132 + {postCount && category && <> &middot; </>} 133 + {category} 134 + </Text> 135 + </View> 136 + </View> 137 + <View style={[a.flex_shrink_0]}> 138 + <TrendingIndicator type={badgeType} /> 139 + </View> 140 + </View> 141 + 142 + {children} 143 + </> 144 + )} 145 + </Link> 146 + ) 147 + } 148 + 149 + type TrendingIndicatorType = 'hot' | 'new' | number 150 + 151 + function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) { 152 + const t = useTheme() 153 + const {_} = useLingui() 154 + const pillStyles = [ 155 + a.flex_row, 156 + a.align_center, 157 + a.gap_xs, 158 + a.rounded_full, 159 + a.px_sm, 160 + { 161 + height: 28, 162 + }, 163 + ] 164 + 165 + let Icon: React.ComponentType<SVGIconProps> | null = null 166 + let text: string | null = null 167 + let color: string | null = null 168 + let backgroundColor: string | null = null 169 + 170 + switch (type) { 171 + case 'skeleton': { 172 + return ( 173 + <View 174 + style={[ 175 + pillStyles, 176 + {backgroundColor: t.palette.contrast_25, width: 65, height: 28}, 177 + ]} 178 + /> 179 + ) 180 + } 181 + case 'hot': { 182 + Icon = FlameIcon 183 + color = 184 + t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950 185 + backgroundColor = 186 + t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200 187 + text = _(msg`Hot`) 188 + break 189 + } 190 + case 'new': { 191 + Icon = TrendingIcon 192 + text = _(msg`New`) 193 + color = t.palette.positive_700 194 + backgroundColor = t.palette.positive_50 195 + break 196 + } 197 + default: { 198 + text = _( 199 + msg({ 200 + message: `${type}h ago`, 201 + comment: 202 + 'trending topic time spent trending. should be as short as possible to fit in a pill', 203 + }), 204 + ) 205 + color = t.atoms.text_contrast_medium.color 206 + backgroundColor = t.atoms.bg_contrast_25.backgroundColor 207 + break 208 + } 209 + } 210 + 211 + return ( 212 + <View style={[pillStyles, {backgroundColor}]}> 213 + {Icon && <Icon size="sm" style={{color}} />} 214 + <Text style={[a.text_sm, {color}]}>{text}</Text> 215 + </View> 216 + ) 217 + } 218 + 219 + function useCategoryDisplayName( 220 + category: AppBskyUnspeccedDefs.TrendView['category'], 221 + ) { 222 + const {_} = useLingui() 223 + 224 + switch (category) { 225 + case 'sports': 226 + return _(msg`Sports`) 227 + case 'politics': 228 + return _(msg`Politics`) 229 + case 'video-games': 230 + return _(msg`Video Games`) 231 + case 'pop-culture': 232 + return _(msg`Entertainment`) 233 + case 'news': 234 + return _(msg`News`) 235 + case 'other': 236 + default: 237 + return null 238 + } 239 + } 240 + 241 + export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) { 242 + const t = useTheme() 243 + const gutters = useGutters([0, 'base']) 244 + 245 + return ( 246 + <View 247 + style={[ 248 + gutters, 249 + a.w_full, 250 + a.py_lg, 251 + a.flex_row, 252 + a.gap_2xs, 253 + a.border_b, 254 + t.atoms.border_contrast_low, 255 + ]}> 256 + <View style={[a.flex_1, a.gap_sm]}> 257 + <View style={[a.flex_row, a.align_center]}> 258 + <View style={[{width: 20}]}> 259 + <LoadingPlaceholder 260 + width={12} 261 + height={12} 262 + style={[a.rounded_full]} 263 + /> 264 + </View> 265 + <LoadingPlaceholder width={90} height={18} /> 266 + </View> 267 + <View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}> 268 + <LoadingPlaceholder width={70} height={18} /> 269 + <LoadingPlaceholder width={40} height={18} /> 270 + <LoadingPlaceholder width={60} height={18} /> 271 + </View> 272 + </View> 273 + <View style={[a.flex_shrink_0]}> 274 + <TrendingIndicator type="skeleton" /> 275 + </View> 276 + </View> 277 + ) 278 + }
+3 -3
src/screens/Settings/ContentAndMediaSettings.tsx
··· 1 import {msg, Trans} from '@lingui/macro' 2 import {useLingui} from '@lingui/react' 3 - import {NativeStackScreenProps} from '@react-navigation/native-stack' 4 5 - import {CommonNavigatorParams} from '#/lib/routes/types' 6 import {logEvent} from '#/lib/statsig/statsig' 7 import {isNative} from '#/platform/detection' 8 import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' ··· 22 import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' 23 import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' 24 import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' 25 - import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 26 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 27 import * as Layout from '#/components/Layout' 28
··· 1 import {msg, Trans} from '@lingui/macro' 2 import {useLingui} from '@lingui/react' 3 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 4 5 + import {type CommonNavigatorParams} from '#/lib/routes/types' 6 import {logEvent} from '#/lib/statsig/statsig' 7 import {isNative} from '#/platform/detection' 8 import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' ··· 22 import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' 23 import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' 24 import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' 25 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 26 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 27 import * as Layout from '#/components/Layout' 28
+16 -7
src/state/queries/actor-search.ts
··· 1 - import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' 2 import { 3 - InfiniteData, 4 keepPreviousData, 5 - QueryClient, 6 - QueryKey, 7 useInfiniteQuery, 8 useQuery, 9 } from '@tanstack/react-query' ··· 15 export const RQKEY = (query: string) => [RQKEY_ROOT, query] 16 17 const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` 18 - export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query] 19 20 export function useActorSearch({ 21 query, ··· 42 query, 43 enabled, 44 maintainData, 45 }: { 46 query: string 47 enabled?: boolean 48 maintainData?: boolean 49 }) { 50 const agent = useAgent() 51 return useInfiniteQuery< ··· 56 string | undefined 57 >({ 58 staleTime: STALE.MINUTES.FIVE, 59 - queryKey: RQKEY_PAGINATED(query), 60 queryFn: async ({pageParam}) => { 61 const res = await agent.searchActors({ 62 q: query, 63 - limit: 25, 64 cursor: pageParam, 65 }) 66 return res.data
··· 1 import { 2 + type AppBskyActorDefs, 3 + type AppBskyActorSearchActors, 4 + } from '@atproto/api' 5 + import { 6 + type InfiniteData, 7 keepPreviousData, 8 + type QueryClient, 9 + type QueryKey, 10 useInfiniteQuery, 11 useQuery, 12 } from '@tanstack/react-query' ··· 18 export const RQKEY = (query: string) => [RQKEY_ROOT, query] 19 20 const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` 21 + export const RQKEY_PAGINATED = (query: string, limit?: number) => [ 22 + RQKEY_ROOT_PAGINATED, 23 + query, 24 + limit, 25 + ] 26 27 export function useActorSearch({ 28 query, ··· 49 query, 50 enabled, 51 maintainData, 52 + limit = 25, 53 }: { 54 query: string 55 enabled?: boolean 56 maintainData?: boolean 57 + limit?: number 58 }) { 59 const agent = useAgent() 60 return useInfiniteQuery< ··· 65 string | undefined 66 >({ 67 staleTime: STALE.MINUTES.FIVE, 68 + queryKey: RQKEY_PAGINATED(query, limit), 69 queryFn: async ({pageParam}) => { 70 const res = await agent.searchActors({ 71 q: query, 72 + limit, 73 cursor: pageParam, 74 }) 75 return res.data
+48
src/state/queries/trending/useGetSuggestedFeedsQuery.ts
···
··· 1 + import {useQuery} from '@tanstack/react-query' 2 + 3 + import { 4 + aggregateUserInterests, 5 + createBskyTopicsHeader, 6 + } from '#/lib/api/feed/utils' 7 + import {getContentLanguages} from '#/state/preferences/languages' 8 + import {STALE} from '#/state/queries' 9 + import {usePreferencesQuery} from '#/state/queries/preferences' 10 + import {useAgent} from '#/state/session' 11 + 12 + export const DEFAULT_LIMIT = 5 13 + 14 + export const createGetTrendsQueryKey = () => ['suggested-feeds'] 15 + 16 + export function useGetSuggestedFeedsQuery() { 17 + const agent = useAgent() 18 + const {data: preferences} = usePreferencesQuery() 19 + const savedFeeds = preferences?.savedFeeds 20 + 21 + return useQuery({ 22 + enabled: !!savedFeeds, 23 + refetchOnWindowFocus: true, 24 + staleTime: STALE.MINUTES.ONE, 25 + queryKey: createGetTrendsQueryKey(), 26 + queryFn: async () => { 27 + const contentLangs = getContentLanguages().join(',') 28 + const {data} = await agent.app.bsky.unspecced.getSuggestedFeeds( 29 + { 30 + limit: DEFAULT_LIMIT, 31 + }, 32 + { 33 + headers: { 34 + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), 35 + 'Accept-Language': contentLangs, 36 + }, 37 + }, 38 + ) 39 + 40 + return { 41 + feeds: data.feeds.filter(feed => { 42 + const isSaved = !!savedFeeds?.find(s => s.value === feed.uri) 43 + return !isSaved 44 + }), 45 + } 46 + }, 47 + }) 48 + }
+59
src/state/queries/trending/useGetTrendsQuery.ts
···
··· 1 + import React from 'react' 2 + import {type AppBskyUnspeccedGetTrends} from '@atproto/api' 3 + import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords' 4 + import {useQuery} from '@tanstack/react-query' 5 + 6 + import { 7 + aggregateUserInterests, 8 + createBskyTopicsHeader, 9 + } from '#/lib/api/feed/utils' 10 + import {getContentLanguages} from '#/state/preferences/languages' 11 + import {STALE} from '#/state/queries' 12 + import {usePreferencesQuery} from '#/state/queries/preferences' 13 + import {useAgent} from '#/state/session' 14 + 15 + export const DEFAULT_LIMIT = 5 16 + 17 + export const createGetTrendsQueryKey = () => ['trends'] 18 + 19 + export function useGetTrendsQuery() { 20 + const agent = useAgent() 21 + const {data: preferences} = usePreferencesQuery() 22 + const mutedWords = React.useMemo(() => { 23 + return preferences?.moderationPrefs?.mutedWords || [] 24 + }, [preferences?.moderationPrefs]) 25 + 26 + return useQuery({ 27 + refetchOnWindowFocus: true, 28 + staleTime: STALE.MINUTES.THREE, 29 + queryKey: createGetTrendsQueryKey(), 30 + queryFn: async () => { 31 + const contentLangs = getContentLanguages().join(',') 32 + const {data} = await agent.app.bsky.unspecced.getTrends( 33 + { 34 + limit: DEFAULT_LIMIT, 35 + }, 36 + { 37 + headers: { 38 + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), 39 + 'Accept-Language': contentLangs, 40 + }, 41 + }, 42 + ) 43 + return data 44 + }, 45 + select: React.useCallback( 46 + (data: AppBskyUnspeccedGetTrends.OutputSchema) => { 47 + return { 48 + trends: (data.trends ?? []).filter(t => { 49 + return !hasMutedWord({ 50 + mutedWords, 51 + text: t.topic + ' ' + t.displayName + ' ' + t.category, 52 + }) 53 + }), 54 + } 55 + }, 56 + [mutedWords], 57 + ), 58 + }) 59 + }
+38
src/state/queries/useSuggestedStarterPacksQuery.ts
···
··· 1 + import {useQuery} from '@tanstack/react-query' 2 + 3 + import { 4 + aggregateUserInterests, 5 + createBskyTopicsHeader, 6 + } from '#/lib/api/feed/utils' 7 + import {getContentLanguages} from '#/state/preferences/languages' 8 + import {STALE} from '#/state/queries' 9 + import {usePreferencesQuery} from '#/state/queries/preferences' 10 + import {useAgent} from '#/state/session' 11 + 12 + export const createSuggestedStarterPacksQueryKey = () => [ 13 + 'suggested-starter-packs', 14 + ] 15 + 16 + export function useSuggestedStarterPacksQuery() { 17 + const agent = useAgent() 18 + const {data: preferences} = usePreferencesQuery() 19 + const contentLangs = getContentLanguages().join(',') 20 + 21 + return useQuery({ 22 + refetchOnWindowFocus: true, 23 + staleTime: STALE.MINUTES.ONE, 24 + queryKey: createSuggestedStarterPacksQueryKey(), 25 + async queryFn() { 26 + const {data} = await agent.app.bsky.unspecced.getSuggestedStarterPacks( 27 + undefined, 28 + { 29 + headers: { 30 + ...createBskyTopicsHeader(aggregateUserInterests(preferences)), 31 + 'Accept-Language': contentLangs, 32 + }, 33 + }, 34 + ) 35 + return data 36 + }, 37 + }) 38 + }
+13 -13
src/view/com/posts/PostFeed.tsx
··· 3 ActivityIndicator, 4 AppState, 5 Dimensions, 6 - ListRenderItemInfo, 7 - StyleProp, 8 StyleSheet, 9 View, 10 - ViewStyle, 11 } from 'react-native' 12 - import {AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' 13 import {msg} from '@lingui/macro' 14 import {useLingui} from '@lingui/react' 15 import {useQueryClient} from '@tanstack/react-query' ··· 24 import {useTrendingSettings} from '#/state/preferences/trending' 25 import {STALE} from '#/state/queries' 26 import { 27 - AuthorFilter, 28 - FeedDescriptor, 29 - FeedParams, 30 - FeedPostSlice, 31 - FeedPostSliceItem, 32 pollLatest, 33 RQKEY, 34 usePostFeedQuery, 35 } from '#/state/queries/post-feed' 36 import {useSession} from '#/state/session' 37 import {useProgressGuide} from '#/state/shell/progress-guide' 38 - import {List, ListRef} from '#/view/com/util/List' 39 import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 40 import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 41 - import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' 42 import {useBreakpoints, useLayoutBreakpoints} from '#/alf' 43 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' 44 import { ··· 767 feedFooter: {paddingTop: 20}, 768 }) 769 770 - function isThreadParentAt<T>(arr: Array<T>, i: number) { 771 if (arr.length === 1) { 772 return false 773 } 774 return i < arr.length - 1 775 } 776 777 - function isThreadChildAt<T>(arr: Array<T>, i: number) { 778 if (arr.length === 1) { 779 return false 780 }
··· 3 ActivityIndicator, 4 AppState, 5 Dimensions, 6 + type ListRenderItemInfo, 7 + type StyleProp, 8 StyleSheet, 9 View, 10 + type ViewStyle, 11 } from 'react-native' 12 + import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api' 13 import {msg} from '@lingui/macro' 14 import {useLingui} from '@lingui/react' 15 import {useQueryClient} from '@tanstack/react-query' ··· 24 import {useTrendingSettings} from '#/state/preferences/trending' 25 import {STALE} from '#/state/queries' 26 import { 27 + type AuthorFilter, 28 + type FeedDescriptor, 29 + type FeedParams, 30 + type FeedPostSlice, 31 + type FeedPostSliceItem, 32 pollLatest, 33 RQKEY, 34 usePostFeedQuery, 35 } from '#/state/queries/post-feed' 36 import {useSession} from '#/state/session' 37 import {useProgressGuide} from '#/state/shell/progress-guide' 38 + import {List, type ListRef} from '#/view/com/util/List' 39 import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 40 import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 41 + import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 42 import {useBreakpoints, useLayoutBreakpoints} from '#/alf' 43 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' 44 import { ··· 767 feedFooter: {paddingTop: 20}, 768 }) 769 770 + export function isThreadParentAt<T>(arr: Array<T>, i: number) { 771 if (arr.length === 1) { 772 return false 773 } 774 return i < arr.length - 1 775 } 776 777 + export function isThreadChildAt<T>(arr: Array<T>, i: number) { 778 if (arr.length === 1) { 779 return false 780 }
+1 -1
src/view/com/util/numeric/format.ts
··· 1 - import {I18n} from '@lingui/core' 2 3 export const formatCount = (i18n: I18n, num: number) => { 4 return i18n.number(num, {
··· 1 + import {type I18n} from '@lingui/core' 2 3 export const formatCount = (i18n: I18n, num: number) => { 4 return i18n.number(num, {
-641
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 {logEvent} from '#/lib/statsig/statsig' 14 - import {cleanError} from '#/lib/strings/errors' 15 - import {logger} from '#/logger' 16 - import {isNative, isWeb} from '#/platform/detection' 17 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 - import {useGetPopularFeedsQuery} from '#/state/queries/feed' 19 - import {usePreferencesQuery} from '#/state/queries/preferences' 20 - import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 21 - import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 22 - import {List} from '#/view/com/util/List' 23 - import { 24 - FeedFeedLoadingPlaceholder, 25 - ProfileCardFeedLoadingPlaceholder, 26 - } from '#/view/com/util/LoadingPlaceholder' 27 - import {UserAvatar} from '#/view/com/util/UserAvatar' 28 - import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' 29 - import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' 30 - import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos' 31 - import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 32 - import {Button} from '#/components/Button' 33 - import * as FeedCard from '#/components/FeedCard' 34 - import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' 35 - import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 36 - import {Props as SVGIconProps} from '#/components/icons/common' 37 - import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 38 - import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 39 - import {Loader} from '#/components/Loader' 40 - import {Text} from '#/components/Typography' 41 - 42 - function SuggestedItemsHeader({ 43 - title, 44 - description, 45 - style, 46 - icon: Icon, 47 - }: { 48 - title: string 49 - description: string 50 - icon: React.ComponentType<SVGIconProps> 51 - } & ViewStyleProp) { 52 - const t = useTheme() 53 - 54 - return ( 55 - <View 56 - style={[ 57 - isWeb 58 - ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 59 - : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], 60 - a.border_b, 61 - t.atoms.border_contrast_low, 62 - style, 63 - ]}> 64 - <View style={[a.flex_1, a.gap_sm]}> 65 - <View style={[a.flex_row, a.align_center, a.gap_sm]}> 66 - <Icon 67 - size="lg" 68 - fill={t.palette.primary_500} 69 - style={{marginLeft: -2}} 70 - /> 71 - <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text> 72 - </View> 73 - <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 74 - {description} 75 - </Text> 76 - </View> 77 - </View> 78 - ) 79 - } 80 - 81 - type LoadMoreItem = 82 - | { 83 - type: 'profile' 84 - key: string 85 - avatar: string | undefined 86 - moderation: ModerationDecision 87 - } 88 - | { 89 - type: 'feed' 90 - key: string 91 - avatar: string | undefined 92 - moderation: undefined 93 - } 94 - 95 - function LoadMore({ 96 - item, 97 - moderationOpts, 98 - }: { 99 - item: ExploreScreenItems & {type: 'loadMore'} 100 - moderationOpts?: ModerationOpts 101 - }) { 102 - const t = useTheme() 103 - const {_} = useLingui() 104 - const items: LoadMoreItem[] = React.useMemo(() => { 105 - return item.items 106 - .map(_item => { 107 - let loadMoreItem: LoadMoreItem | undefined 108 - if (_item.type === 'profile') { 109 - loadMoreItem = { 110 - type: 'profile', 111 - key: _item.profile.did, 112 - avatar: _item.profile.avatar, 113 - moderation: moderateProfile(_item.profile, moderationOpts!), 114 - } 115 - } else if (_item.type === 'feed') { 116 - loadMoreItem = { 117 - type: 'feed', 118 - key: _item.feed.uri, 119 - avatar: _item.feed.avatar, 120 - moderation: undefined, 121 - } 122 - } 123 - return loadMoreItem 124 - }) 125 - .filter(n => !!n) 126 - }, [item.items, moderationOpts]) 127 - 128 - if (items.length === 0) return null 129 - 130 - const type = items[0].type 131 - 132 - return ( 133 - <View style={[]}> 134 - <Button 135 - label={_(msg`Load more`)} 136 - onPress={item.onLoadMore} 137 - style={[a.relative, a.w_full]}> 138 - {({hovered, pressed}) => ( 139 - <View 140 - style={[ 141 - a.flex_1, 142 - a.flex_row, 143 - a.align_center, 144 - a.px_lg, 145 - a.py_md, 146 - (hovered || pressed) && t.atoms.bg_contrast_25, 147 - ]}> 148 - <View 149 - style={[ 150 - a.relative, 151 - { 152 - height: 32, 153 - width: 32 + 15 * items.length, 154 - }, 155 - ]}> 156 - <View 157 - style={[ 158 - a.align_center, 159 - a.justify_center, 160 - t.atoms.bg_contrast_25, 161 - a.absolute, 162 - { 163 - width: 30, 164 - height: 30, 165 - left: 0, 166 - borderWidth: 1, 167 - backgroundColor: t.palette.primary_500, 168 - borderColor: t.atoms.bg.backgroundColor, 169 - borderRadius: type === 'profile' ? 999 : 4, 170 - zIndex: 4, 171 - }, 172 - ]}> 173 - <ArrowBottom fill={t.palette.white} /> 174 - </View> 175 - {items.map((_item, i) => { 176 - return ( 177 - <View 178 - key={_item.key} 179 - style={[ 180 - t.atoms.bg_contrast_25, 181 - a.absolute, 182 - { 183 - width: 30, 184 - height: 30, 185 - left: (i + 1) * 15, 186 - borderWidth: 1, 187 - borderColor: t.atoms.bg.backgroundColor, 188 - borderRadius: _item.type === 'profile' ? 999 : 4, 189 - zIndex: 3 - i, 190 - }, 191 - ]}> 192 - {moderationOpts && ( 193 - <> 194 - {_item.type === 'profile' ? ( 195 - <UserAvatar 196 - size={28} 197 - avatar={_item.avatar} 198 - moderation={_item.moderation.ui('avatar')} 199 - type="user" 200 - /> 201 - ) : _item.type === 'feed' ? ( 202 - <UserAvatar 203 - size={28} 204 - avatar={_item.avatar} 205 - type="algo" 206 - /> 207 - ) : null} 208 - </> 209 - )} 210 - </View> 211 - ) 212 - })} 213 - </View> 214 - 215 - <Text 216 - style={[ 217 - a.pl_sm, 218 - a.leading_snug, 219 - hovered ? t.atoms.text : t.atoms.text_contrast_medium, 220 - ]}> 221 - {type === 'profile' ? ( 222 - <Trans>Load more suggested follows</Trans> 223 - ) : ( 224 - <Trans>Load more suggested feeds</Trans> 225 - )} 226 - </Text> 227 - 228 - <View style={[a.flex_1, a.align_end]}> 229 - {item.isLoadingMore && <Loader size="lg" />} 230 - </View> 231 - </View> 232 - )} 233 - </Button> 234 - </View> 235 - ) 236 - } 237 - 238 - type ExploreScreenItems = 239 - | { 240 - type: 'header' 241 - key: string 242 - title: string 243 - description: string 244 - style?: ViewStyleProp['style'] 245 - icon: React.ComponentType<SVGIconProps> 246 - } 247 - | { 248 - type: 'trendingTopics' 249 - key: string 250 - } 251 - | { 252 - type: 'trendingVideos' 253 - key: string 254 - } 255 - | { 256 - type: 'recommendations' 257 - key: string 258 - } 259 - | { 260 - type: 'profile' 261 - key: string 262 - profile: AppBskyActorDefs.ProfileView 263 - recId?: number 264 - } 265 - | { 266 - type: 'feed' 267 - key: string 268 - feed: AppBskyFeedDefs.GeneratorView 269 - } 270 - | { 271 - type: 'loadMore' 272 - key: string 273 - isLoadingMore: boolean 274 - onLoadMore: () => void 275 - items: ExploreScreenItems[] 276 - } 277 - | { 278 - type: 'profilePlaceholder' 279 - key: string 280 - } 281 - | { 282 - type: 'feedPlaceholder' 283 - key: string 284 - } 285 - | { 286 - type: 'error' 287 - key: string 288 - message: string 289 - error: string 290 - } 291 - 292 - export function Explore() { 293 - const {_} = useLingui() 294 - const t = useTheme() 295 - const {data: preferences, error: preferencesError} = usePreferencesQuery() 296 - const moderationOpts = useModerationOpts() 297 - const { 298 - data: profiles, 299 - hasNextPage: hasNextProfilesPage, 300 - isLoading: isLoadingProfiles, 301 - isFetchingNextPage: isFetchingNextProfilesPage, 302 - error: profilesError, 303 - fetchNextPage: fetchNextProfilesPage, 304 - } = useSuggestedFollowsQuery({limit: 6, subsequentPageLimit: 10}) 305 - const { 306 - data: feeds, 307 - hasNextPage: hasNextFeedsPage, 308 - isLoading: isLoadingFeeds, 309 - isFetchingNextPage: isFetchingNextFeedsPage, 310 - error: feedsError, 311 - fetchNextPage: fetchNextFeedsPage, 312 - } = useGetPopularFeedsQuery({limit: 10}) 313 - 314 - const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles 315 - const onLoadMoreProfiles = React.useCallback(async () => { 316 - if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) 317 - return 318 - try { 319 - await fetchNextProfilesPage() 320 - } catch (err) { 321 - logger.error('Failed to load more suggested follows', {message: err}) 322 - } 323 - }, [ 324 - isFetchingNextProfilesPage, 325 - hasNextProfilesPage, 326 - profilesError, 327 - fetchNextProfilesPage, 328 - ]) 329 - 330 - const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds 331 - const onLoadMoreFeeds = React.useCallback(async () => { 332 - if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return 333 - try { 334 - await fetchNextFeedsPage() 335 - } catch (err) { 336 - logger.error('Failed to load more suggested follows', {message: err}) 337 - } 338 - }, [ 339 - isFetchingNextFeedsPage, 340 - hasNextFeedsPage, 341 - feedsError, 342 - fetchNextFeedsPage, 343 - ]) 344 - 345 - const items = React.useMemo<ExploreScreenItems[]>(() => { 346 - const i: ExploreScreenItems[] = [] 347 - 348 - i.push({ 349 - type: 'trendingTopics', 350 - key: `trending-topics`, 351 - }) 352 - 353 - if (isNative) { 354 - i.push({ 355 - type: 'trendingVideos', 356 - key: `trending-videos`, 357 - }) 358 - } 359 - 360 - i.push({ 361 - type: 'recommendations', 362 - key: `recommendations`, 363 - }) 364 - 365 - i.push({ 366 - type: 'header', 367 - key: 'suggested-follows-header', 368 - title: _(msg`Suggested accounts`), 369 - description: _( 370 - msg`Follow more accounts to get connected to your interests and build your network.`, 371 - ), 372 - icon: Person, 373 - }) 374 - 375 - if (profiles) { 376 - // Currently the responses contain duplicate items. 377 - // Needs to be fixed on backend, but let's dedupe to be safe. 378 - let seen = new Set() 379 - const profileItems: ExploreScreenItems[] = [] 380 - for (const page of profiles.pages) { 381 - for (const actor of page.actors) { 382 - if (!seen.has(actor.did)) { 383 - seen.add(actor.did) 384 - profileItems.push({ 385 - type: 'profile', 386 - key: actor.did, 387 - profile: actor, 388 - recId: page.recId, 389 - }) 390 - } 391 - } 392 - } 393 - 394 - if (hasNextProfilesPage) { 395 - // splice off 3 as previews if we have a next page 396 - const previews = profileItems.splice(-3) 397 - // push remainder 398 - i.push(...profileItems) 399 - i.push({ 400 - type: 'loadMore', 401 - key: 'loadMoreProfiles', 402 - isLoadingMore: isLoadingMoreProfiles, 403 - onLoadMore: onLoadMoreProfiles, 404 - items: previews, 405 - }) 406 - } else { 407 - i.push(...profileItems) 408 - } 409 - } else { 410 - if (profilesError) { 411 - i.push({ 412 - type: 'error', 413 - key: 'profilesError', 414 - message: _(msg`Failed to load suggested follows`), 415 - error: cleanError(profilesError), 416 - }) 417 - } else { 418 - i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 419 - } 420 - } 421 - 422 - i.push({ 423 - type: 'header', 424 - key: 'suggested-feeds-header', 425 - title: _(msg`Discover new feeds`), 426 - description: _( 427 - msg`Choose your own timeline! Feeds built by the community help you find content you love.`, 428 - ), 429 - style: [a.pt_5xl], 430 - icon: ListSparkle, 431 - }) 432 - 433 - if (feeds && preferences) { 434 - // Currently the responses contain duplicate items. 435 - // Needs to be fixed on backend, but let's dedupe to be safe. 436 - let seen = new Set() 437 - const feedItems: ExploreScreenItems[] = [] 438 - for (const page of feeds.pages) { 439 - for (const feed of page.feeds) { 440 - if (!seen.has(feed.uri)) { 441 - seen.add(feed.uri) 442 - feedItems.push({ 443 - type: 'feed', 444 - key: feed.uri, 445 - feed, 446 - }) 447 - } 448 - } 449 - } 450 - 451 - // feeds errors can occur during pagination, so feeds is truthy 452 - if (feedsError) { 453 - i.push({ 454 - type: 'error', 455 - key: 'feedsError', 456 - message: _(msg`Failed to load suggested feeds`), 457 - error: cleanError(feedsError), 458 - }) 459 - } else if (preferencesError) { 460 - i.push({ 461 - type: 'error', 462 - key: 'preferencesError', 463 - message: _(msg`Failed to load feeds preferences`), 464 - error: cleanError(preferencesError), 465 - }) 466 - } else if (hasNextFeedsPage) { 467 - const preview = feedItems.splice(-3) 468 - i.push(...feedItems) 469 - i.push({ 470 - type: 'loadMore', 471 - key: 'loadMoreFeeds', 472 - isLoadingMore: isLoadingMoreFeeds, 473 - onLoadMore: onLoadMoreFeeds, 474 - items: preview, 475 - }) 476 - } else { 477 - i.push(...feedItems) 478 - } 479 - } else { 480 - if (feedsError) { 481 - i.push({ 482 - type: 'error', 483 - key: 'feedsError', 484 - message: _(msg`Failed to load suggested feeds`), 485 - error: cleanError(feedsError), 486 - }) 487 - } else if (preferencesError) { 488 - i.push({ 489 - type: 'error', 490 - key: 'preferencesError', 491 - message: _(msg`Failed to load feeds preferences`), 492 - error: cleanError(preferencesError), 493 - }) 494 - } else { 495 - i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) 496 - } 497 - } 498 - 499 - return i 500 - }, [ 501 - _, 502 - profiles, 503 - feeds, 504 - preferences, 505 - onLoadMoreFeeds, 506 - onLoadMoreProfiles, 507 - isLoadingMoreProfiles, 508 - isLoadingMoreFeeds, 509 - profilesError, 510 - feedsError, 511 - preferencesError, 512 - hasNextProfilesPage, 513 - hasNextFeedsPage, 514 - ]) 515 - 516 - const renderItem = React.useCallback( 517 - ({item, index}: {item: ExploreScreenItems; index: number}) => { 518 - switch (item.type) { 519 - case 'header': { 520 - return ( 521 - <SuggestedItemsHeader 522 - title={item.title} 523 - description={item.description} 524 - style={item.style} 525 - icon={item.icon} 526 - /> 527 - ) 528 - } 529 - case 'trendingTopics': { 530 - return <ExploreTrendingTopics /> 531 - } 532 - case 'trendingVideos': { 533 - return <ExploreTrendingVideos /> 534 - } 535 - case 'recommendations': { 536 - return <ExploreRecommendations /> 537 - } 538 - case 'profile': { 539 - return ( 540 - <View style={[a.border_b, t.atoms.border_contrast_low]}> 541 - <ProfileCardWithFollowBtn 542 - profile={item.profile} 543 - noBg 544 - noBorder 545 - showKnownFollowers 546 - onPress={() => { 547 - logEvent('suggestedUser:press', { 548 - logContext: 'Explore', 549 - recId: item.recId, 550 - position: index, 551 - }) 552 - }} 553 - onFollow={() => { 554 - logEvent('suggestedUser:follow', { 555 - logContext: 'Explore', 556 - location: 'Card', 557 - recId: item.recId, 558 - position: index, 559 - }) 560 - }} 561 - /> 562 - </View> 563 - ) 564 - } 565 - case 'feed': { 566 - return ( 567 - <View 568 - style={[ 569 - a.border_b, 570 - t.atoms.border_contrast_low, 571 - a.px_lg, 572 - a.py_lg, 573 - ]}> 574 - <FeedCard.Default view={item.feed} /> 575 - </View> 576 - ) 577 - } 578 - case 'loadMore': { 579 - return <LoadMore item={item} moderationOpts={moderationOpts} /> 580 - } 581 - case 'profilePlaceholder': { 582 - return <ProfileCardFeedLoadingPlaceholder /> 583 - } 584 - case 'feedPlaceholder': { 585 - return <FeedFeedLoadingPlaceholder /> 586 - } 587 - case 'error': { 588 - return ( 589 - <View 590 - style={[ 591 - a.border_t, 592 - a.pt_md, 593 - a.px_md, 594 - t.atoms.border_contrast_low, 595 - ]}> 596 - <View 597 - style={[ 598 - a.flex_row, 599 - a.gap_md, 600 - a.p_lg, 601 - a.rounded_sm, 602 - t.atoms.bg_contrast_25, 603 - ]}> 604 - <CircleInfo size="md" fill={t.palette.negative_400} /> 605 - <View style={[a.flex_1, a.gap_sm]}> 606 - <Text style={[a.font_bold, a.leading_snug]}> 607 - {item.message} 608 - </Text> 609 - <Text 610 - style={[ 611 - a.italic, 612 - a.leading_snug, 613 - t.atoms.text_contrast_medium, 614 - ]}> 615 - {item.error} 616 - </Text> 617 - </View> 618 - </View> 619 - </View> 620 - ) 621 - } 622 - } 623 - }, 624 - [t, moderationOpts], 625 - ) 626 - 627 - // note: actually not a screen, instead it's nested within 628 - // the search screen. so we don't need Layout.Screen 629 - return ( 630 - <List 631 - data={items} 632 - renderItem={renderItem} 633 - keyExtractor={item => item.key} 634 - // @ts-ignore web only -prf 635 - desktopFixedHeight 636 - contentContainerStyle={{paddingBottom: 100}} 637 - keyboardShouldPersistTaps="handled" 638 - keyboardDismissMode="on-drag" 639 - /> 640 - ) 641 - }
···
-1165
src/view/screens/Search/Search.tsx
··· 1 - import React, {useCallback, useLayoutEffect, useMemo} from 'react' 2 - import { 3 - ActivityIndicator, 4 - Pressable, 5 - StyleProp, 6 - StyleSheet, 7 - TextInput, 8 - View, 9 - ViewStyle, 10 - } from 'react-native' 11 - import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' 12 - import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' 13 - import {msg, Trans} from '@lingui/macro' 14 - import {useLingui} from '@lingui/react' 15 - import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' 16 - import {useQueryClient} from '@tanstack/react-query' 17 - 18 - import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' 19 - import {createHitslop, HITSLOP_20} from '#/lib/constants' 20 - import {HITSLOP_10} from '#/lib/constants' 21 - import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 22 - import {MagnifyingGlassIcon} from '#/lib/icons' 23 - import {makeProfileLink} from '#/lib/routes/links' 24 - import {NavigationProp} from '#/lib/routes/types' 25 - import { 26 - NativeStackScreenProps, 27 - SearchTabNavigatorParams, 28 - } from '#/lib/routes/types' 29 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 30 - import {augmentSearchQuery} from '#/lib/strings/helpers' 31 - import {languageName} from '#/locale/helpers' 32 - import {isNative, isWeb} from '#/platform/detection' 33 - import {listenSoftReset} from '#/state/events' 34 - import {useLanguagePrefs} from '#/state/preferences/languages' 35 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 36 - import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 37 - import {useActorSearch} from '#/state/queries/actor-search' 38 - import {usePopularFeedsSearch} from '#/state/queries/feed' 39 - import { 40 - unstableCacheProfileView, 41 - useProfilesQuery, 42 - } from '#/state/queries/profile' 43 - import {useSearchPostsQuery} from '#/state/queries/search-posts' 44 - import {useSession} from '#/state/session' 45 - import {useSetMinimalShellMode} from '#/state/shell' 46 - import {Pager} from '#/view/com/pager/Pager' 47 - import {TabBar} from '#/view/com/pager/TabBar' 48 - import {Post} from '#/view/com/post/Post' 49 - import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 50 - import {Link} from '#/view/com/util/Link' 51 - import {List} from '#/view/com/util/List' 52 - import {UserAvatar} from '#/view/com/util/UserAvatar' 53 - import {Explore} from '#/view/screens/Search/Explore' 54 - import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 55 - import {makeSearchQuery, Params, parseSearchQuery} from '#/screens/Search/utils' 56 - import { 57 - atoms as a, 58 - native, 59 - platform, 60 - tokens, 61 - useBreakpoints, 62 - useTheme, 63 - web, 64 - } from '#/alf' 65 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 66 - import * as FeedCard from '#/components/FeedCard' 67 - import {SearchInput} from '#/components/forms/SearchInput' 68 - import { 69 - ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 70 - ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, 71 - } from '#/components/icons/Chevron' 72 - import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 73 - import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 74 - import * as Layout from '#/components/Layout' 75 - import * as Menu from '#/components/Menu' 76 - import {Text} from '#/components/Typography' 77 - import {account, useStorage} from '#/storage' 78 - import * as bsky from '#/types/bsky' 79 - 80 - function Loader() { 81 - return ( 82 - <Layout.Content> 83 - <View style={[a.py_xl]}> 84 - <ActivityIndicator /> 85 - </View> 86 - </Layout.Content> 87 - ) 88 - } 89 - 90 - function EmptyState({message, error}: {message: string; error?: string}) { 91 - const t = useTheme() 92 - 93 - return ( 94 - <Layout.Content> 95 - <View style={[a.p_xl]}> 96 - <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}> 97 - <Text style={[a.text_md]}>{message}</Text> 98 - 99 - {error && ( 100 - <> 101 - <View 102 - style={[ 103 - { 104 - marginVertical: 12, 105 - height: 1, 106 - width: '100%', 107 - backgroundColor: t.atoms.text.color, 108 - opacity: 0.2, 109 - }, 110 - ]} 111 - /> 112 - 113 - <Text style={[t.atoms.text_contrast_medium]}> 114 - <Trans>Error:</Trans> {error} 115 - </Text> 116 - </> 117 - )} 118 - </View> 119 - </View> 120 - </Layout.Content> 121 - ) 122 - } 123 - 124 - type SearchResultSlice = 125 - | { 126 - type: 'post' 127 - key: string 128 - post: AppBskyFeedDefs.PostView 129 - } 130 - | { 131 - type: 'loadingMore' 132 - key: string 133 - } 134 - 135 - let SearchScreenPostResults = ({ 136 - query, 137 - sort, 138 - active, 139 - }: { 140 - query: string 141 - sort?: 'top' | 'latest' 142 - active: boolean 143 - }): React.ReactNode => { 144 - const {_} = useLingui() 145 - const {currentAccount} = useSession() 146 - const [isPTR, setIsPTR] = React.useState(false) 147 - 148 - const augmentedQuery = React.useMemo(() => { 149 - return augmentSearchQuery(query || '', {did: currentAccount?.did}) 150 - }, [query, currentAccount]) 151 - 152 - const { 153 - isFetched, 154 - data: results, 155 - isFetching, 156 - error, 157 - refetch, 158 - fetchNextPage, 159 - isFetchingNextPage, 160 - hasNextPage, 161 - } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) 162 - 163 - const onPullToRefresh = React.useCallback(async () => { 164 - setIsPTR(true) 165 - await refetch() 166 - setIsPTR(false) 167 - }, [setIsPTR, refetch]) 168 - const onEndReached = React.useCallback(() => { 169 - if (isFetching || !hasNextPage || error) return 170 - fetchNextPage() 171 - }, [isFetching, error, hasNextPage, fetchNextPage]) 172 - 173 - const posts = React.useMemo(() => { 174 - return results?.pages.flatMap(page => page.posts) || [] 175 - }, [results]) 176 - const items = React.useMemo(() => { 177 - let temp: SearchResultSlice[] = [] 178 - 179 - const seenUris = new Set() 180 - for (const post of posts) { 181 - if (seenUris.has(post.uri)) { 182 - continue 183 - } 184 - temp.push({ 185 - type: 'post', 186 - key: post.uri, 187 - post, 188 - }) 189 - seenUris.add(post.uri) 190 - } 191 - 192 - if (isFetchingNextPage) { 193 - temp.push({ 194 - type: 'loadingMore', 195 - key: 'loadingMore', 196 - }) 197 - } 198 - 199 - return temp 200 - }, [posts, isFetchingNextPage]) 201 - 202 - return error ? ( 203 - <EmptyState 204 - message={_( 205 - msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, 206 - )} 207 - error={error.toString()} 208 - /> 209 - ) : ( 210 - <> 211 - {isFetched ? ( 212 - <> 213 - {posts.length ? ( 214 - <List 215 - data={items} 216 - renderItem={({item}) => { 217 - if (item.type === 'post') { 218 - return <Post post={item.post} /> 219 - } else { 220 - return null 221 - } 222 - }} 223 - keyExtractor={item => item.key} 224 - refreshing={isPTR} 225 - onRefresh={onPullToRefresh} 226 - onEndReached={onEndReached} 227 - desktopFixedHeight 228 - contentContainerStyle={{paddingBottom: 100}} 229 - /> 230 - ) : ( 231 - <EmptyState message={_(msg`No results found for ${query}`)} /> 232 - )} 233 - </> 234 - ) : ( 235 - <Loader /> 236 - )} 237 - </> 238 - ) 239 - } 240 - SearchScreenPostResults = React.memo(SearchScreenPostResults) 241 - 242 - let SearchScreenUserResults = ({ 243 - query, 244 - active, 245 - }: { 246 - query: string 247 - active: boolean 248 - }): React.ReactNode => { 249 - const {_} = useLingui() 250 - 251 - const {data: results, isFetched} = useActorSearch({ 252 - query, 253 - enabled: active, 254 - }) 255 - 256 - return isFetched && results ? ( 257 - <> 258 - {results.length ? ( 259 - <List 260 - data={results} 261 - renderItem={({item}) => ( 262 - <ProfileCardWithFollowBtn profile={item} noBg /> 263 - )} 264 - keyExtractor={item => item.did} 265 - desktopFixedHeight 266 - contentContainerStyle={{paddingBottom: 100}} 267 - /> 268 - ) : ( 269 - <EmptyState message={_(msg`No results found for ${query}`)} /> 270 - )} 271 - </> 272 - ) : ( 273 - <Loader /> 274 - ) 275 - } 276 - SearchScreenUserResults = React.memo(SearchScreenUserResults) 277 - 278 - let SearchScreenFeedsResults = ({ 279 - query, 280 - active, 281 - }: { 282 - query: string 283 - active: boolean 284 - }): React.ReactNode => { 285 - const t = useTheme() 286 - const {_} = useLingui() 287 - 288 - const {data: results, isFetched} = usePopularFeedsSearch({ 289 - query, 290 - enabled: active, 291 - }) 292 - 293 - return isFetched && results ? ( 294 - <> 295 - {results.length ? ( 296 - <List 297 - data={results} 298 - renderItem={({item}) => ( 299 - <View 300 - style={[ 301 - a.border_b, 302 - t.atoms.border_contrast_low, 303 - a.px_lg, 304 - a.py_lg, 305 - ]}> 306 - <FeedCard.Default view={item} /> 307 - </View> 308 - )} 309 - keyExtractor={item => item.uri} 310 - desktopFixedHeight 311 - contentContainerStyle={{paddingBottom: 100}} 312 - /> 313 - ) : ( 314 - <EmptyState message={_(msg`No results found for ${query}`)} /> 315 - )} 316 - </> 317 - ) : ( 318 - <Loader /> 319 - ) 320 - } 321 - SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) 322 - 323 - function SearchLanguageDropdown({ 324 - value, 325 - onChange, 326 - }: { 327 - value: string 328 - onChange(value: string): void 329 - }) { 330 - const {_} = useLingui() 331 - const {appLanguage, contentLanguages} = useLanguagePrefs() 332 - 333 - const languages = useMemo(() => { 334 - return LANGUAGES.filter( 335 - (lang, index, self) => 336 - Boolean(lang.code2) && // reduce to the code2 varieties 337 - index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen) 338 - ) 339 - .map(l => ({ 340 - label: languageName(l, appLanguage), 341 - value: l.code2, 342 - key: l.code2 + l.code3, 343 - })) 344 - .sort((a, b) => { 345 - // prioritize user's languages 346 - const aIsUser = contentLanguages.includes(a.value) 347 - const bIsUser = contentLanguages.includes(b.value) 348 - if (aIsUser && !bIsUser) return -1 349 - if (bIsUser && !aIsUser) return 1 350 - // prioritize "common" langs in the network 351 - const aIsCommon = !!APP_LANGUAGES.find( 352 - al => 353 - // skip `ast`, because it uses a 3-letter code which conflicts with `as` 354 - // it begins with `a` anyway so still is top of the list 355 - al.code2 !== 'ast' && al.code2.startsWith(a.value), 356 - ) 357 - const bIsCommon = !!APP_LANGUAGES.find( 358 - al => 359 - // ditto 360 - al.code2 !== 'ast' && al.code2.startsWith(b.value), 361 - ) 362 - if (aIsCommon && !bIsCommon) return -1 363 - if (bIsCommon && !aIsCommon) return 1 364 - // fall back to alphabetical 365 - return a.label.localeCompare(b.label) 366 - }) 367 - }, [appLanguage, contentLanguages]) 368 - 369 - const currentLanguageLabel = 370 - languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) 371 - 372 - return ( 373 - <Menu.Root> 374 - <Menu.Trigger 375 - label={_( 376 - msg`Filter search by language (currently: ${currentLanguageLabel})`, 377 - )}> 378 - {({props}) => ( 379 - <Button 380 - {...props} 381 - label={props.accessibilityLabel} 382 - size="small" 383 - color={platform({native: 'primary', default: 'secondary'})} 384 - variant={platform({native: 'ghost', default: 'solid'})} 385 - style={native([ 386 - a.py_sm, 387 - a.px_sm, 388 - {marginRight: tokens.space.sm * -1}, 389 - ])}> 390 - <ButtonIcon icon={EarthIcon} /> 391 - <ButtonText>{currentLanguageLabel}</ButtonText> 392 - <ButtonIcon 393 - icon={platform({ 394 - native: ChevronUpDownIcon, 395 - default: ChevronDownIcon, 396 - })} 397 - /> 398 - </Button> 399 - )} 400 - </Menu.Trigger> 401 - <Menu.Outer> 402 - <Menu.LabelText> 403 - <Trans>Filter search by language</Trans> 404 - </Menu.LabelText> 405 - <Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}> 406 - <Menu.ItemText> 407 - <Trans>All languages</Trans> 408 - </Menu.ItemText> 409 - <Menu.ItemRadio selected={value === ''} /> 410 - </Menu.Item> 411 - <Menu.Divider /> 412 - <Menu.Group> 413 - {languages.map(lang => ( 414 - <Menu.Item 415 - key={lang.key} 416 - label={lang.label} 417 - onPress={() => onChange(lang.value)}> 418 - <Menu.ItemText>{lang.label}</Menu.ItemText> 419 - <Menu.ItemRadio selected={value === lang.value} /> 420 - </Menu.Item> 421 - ))} 422 - </Menu.Group> 423 - </Menu.Outer> 424 - </Menu.Root> 425 - ) 426 - } 427 - 428 - function useQueryManager({ 429 - initialQuery, 430 - fixedParams, 431 - }: { 432 - initialQuery: string 433 - fixedParams?: Params 434 - }) { 435 - const {query, params: initialParams} = React.useMemo(() => { 436 - return parseSearchQuery(initialQuery || '') 437 - }, [initialQuery]) 438 - const [prevInitialQuery, setPrevInitialQuery] = React.useState(initialQuery) 439 - const [lang, setLang] = React.useState(initialParams.lang || '') 440 - 441 - if (initialQuery !== prevInitialQuery) { 442 - // handle new queryParam change (from manual search entry) 443 - setPrevInitialQuery(initialQuery) 444 - setLang(initialParams.lang || '') 445 - } 446 - 447 - const params = React.useMemo( 448 - () => ({ 449 - // default stuff 450 - ...initialParams, 451 - // managed stuff 452 - lang, 453 - ...fixedParams, 454 - }), 455 - [lang, initialParams, fixedParams], 456 - ) 457 - const handlers = React.useMemo( 458 - () => ({ 459 - setLang, 460 - }), 461 - [setLang], 462 - ) 463 - 464 - return React.useMemo(() => { 465 - return { 466 - query, 467 - queryWithParams: makeSearchQuery(query, params), 468 - params: { 469 - ...params, 470 - ...handlers, 471 - }, 472 - } 473 - }, [query, params, handlers]) 474 - } 475 - 476 - let SearchScreenInner = ({ 477 - query, 478 - queryWithParams, 479 - headerHeight, 480 - }: { 481 - query: string 482 - queryWithParams: string 483 - headerHeight: number 484 - }): React.ReactNode => { 485 - const t = useTheme() 486 - const setMinimalShellMode = useSetMinimalShellMode() 487 - const {hasSession} = useSession() 488 - const {gtTablet} = useBreakpoints() 489 - const [activeTab, setActiveTab] = React.useState(0) 490 - const {_} = useLingui() 491 - 492 - const onPageSelected = React.useCallback( 493 - (index: number) => { 494 - setMinimalShellMode(false) 495 - setActiveTab(index) 496 - }, 497 - [setMinimalShellMode], 498 - ) 499 - 500 - const sections = React.useMemo(() => { 501 - if (!queryWithParams) return [] 502 - const noParams = queryWithParams === query 503 - return [ 504 - { 505 - title: _(msg`Top`), 506 - component: ( 507 - <SearchScreenPostResults 508 - query={queryWithParams} 509 - sort="top" 510 - active={activeTab === 0} 511 - /> 512 - ), 513 - }, 514 - { 515 - title: _(msg`Latest`), 516 - component: ( 517 - <SearchScreenPostResults 518 - query={queryWithParams} 519 - sort="latest" 520 - active={activeTab === 1} 521 - /> 522 - ), 523 - }, 524 - noParams && { 525 - title: _(msg`People`), 526 - component: ( 527 - <SearchScreenUserResults query={query} active={activeTab === 2} /> 528 - ), 529 - }, 530 - noParams && { 531 - title: _(msg`Feeds`), 532 - component: ( 533 - <SearchScreenFeedsResults query={query} active={activeTab === 3} /> 534 - ), 535 - }, 536 - ].filter(Boolean) as { 537 - title: string 538 - component: React.ReactNode 539 - }[] 540 - }, [_, query, queryWithParams, activeTab]) 541 - 542 - return queryWithParams ? ( 543 - <Pager 544 - onPageSelected={onPageSelected} 545 - renderTabBar={props => ( 546 - <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}> 547 - <TabBar items={sections.map(section => section.title)} {...props} /> 548 - </Layout.Center> 549 - )} 550 - initialPage={0}> 551 - {sections.map((section, i) => ( 552 - <View key={i}>{section.component}</View> 553 - ))} 554 - </Pager> 555 - ) : hasSession ? ( 556 - <Explore /> 557 - ) : ( 558 - <Layout.Center> 559 - <View style={a.flex_1}> 560 - {gtTablet && ( 561 - <View 562 - style={[ 563 - a.border_b, 564 - t.atoms.border_contrast_low, 565 - a.px_lg, 566 - a.pt_sm, 567 - a.pb_lg, 568 - ]}> 569 - <Text style={[a.text_2xl, a.font_heavy]}> 570 - <Trans>Search</Trans> 571 - </Text> 572 - </View> 573 - )} 574 - 575 - <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}> 576 - <MagnifyingGlassIcon 577 - strokeWidth={3} 578 - size={60} 579 - style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>} 580 - /> 581 - <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 582 - <Trans>Find posts, users, and feeds on Bluesky</Trans> 583 - </Text> 584 - </View> 585 - </View> 586 - </Layout.Center> 587 - ) 588 - } 589 - SearchScreenInner = React.memo(SearchScreenInner) 590 - 591 - export function SearchScreen( 592 - props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 593 - ) { 594 - const queryParam = props.route?.params?.q ?? '' 595 - 596 - return <SearchScreenShell queryParam={queryParam} testID="searchScreen" /> 597 - } 598 - 599 - export function SearchScreenShell({ 600 - queryParam, 601 - testID, 602 - fixedParams, 603 - navButton = 'menu', 604 - inputPlaceholder, 605 - }: { 606 - queryParam: string 607 - testID: string 608 - fixedParams?: Params 609 - navButton?: 'back' | 'menu' 610 - inputPlaceholder?: string 611 - }) { 612 - const t = useTheme() 613 - const {gtMobile} = useBreakpoints() 614 - const navigation = useNavigation<NavigationProp>() 615 - const route = useRoute() 616 - const textInput = React.useRef<TextInput>(null) 617 - const {_} = useLingui() 618 - const setMinimalShellMode = useSetMinimalShellMode() 619 - const {currentAccount} = useSession() 620 - const queryClient = useQueryClient() 621 - 622 - // Query terms 623 - const [searchText, setSearchText] = React.useState<string>(queryParam) 624 - const {data: autocompleteData, isFetching: isAutocompleteFetching} = 625 - useActorAutocompleteQuery(searchText, true) 626 - 627 - const [showAutocomplete, setShowAutocomplete] = React.useState(false) 628 - 629 - const [termHistory = [], setTermHistory] = useStorage(account, [ 630 - currentAccount?.did ?? 'pwi', 631 - 'searchTermHistory', 632 - ] as const) 633 - const [accountHistory = [], setAccountHistory] = useStorage(account, [ 634 - currentAccount?.did ?? 'pwi', 635 - 'searchAccountHistory', 636 - ]) 637 - 638 - const {data: accountHistoryProfiles} = useProfilesQuery({ 639 - handles: accountHistory, 640 - maintainData: true, 641 - }) 642 - 643 - const updateSearchHistory = useCallback( 644 - async (item: string) => { 645 - if (!item) return 646 - const newSearchHistory = [ 647 - item, 648 - ...termHistory.filter(search => search !== item), 649 - ].slice(0, 6) 650 - setTermHistory(newSearchHistory) 651 - }, 652 - [termHistory, setTermHistory], 653 - ) 654 - 655 - const updateProfileHistory = useCallback( 656 - async (item: bsky.profile.AnyProfileView) => { 657 - const newAccountHistory = [ 658 - item.did, 659 - ...accountHistory.filter(p => p !== item.did), 660 - ].slice(0, 5) 661 - setAccountHistory(newAccountHistory) 662 - }, 663 - [accountHistory, setAccountHistory], 664 - ) 665 - 666 - const deleteSearchHistoryItem = useCallback( 667 - async (item: string) => { 668 - setTermHistory(termHistory.filter(search => search !== item)) 669 - }, 670 - [termHistory, setTermHistory], 671 - ) 672 - const deleteProfileHistoryItem = useCallback( 673 - async (item: AppBskyActorDefs.ProfileViewDetailed) => { 674 - setAccountHistory(accountHistory.filter(p => p !== item.did)) 675 - }, 676 - [accountHistory, setAccountHistory], 677 - ) 678 - 679 - const {params, query, queryWithParams} = useQueryManager({ 680 - initialQuery: queryParam, 681 - fixedParams, 682 - }) 683 - const showFilters = Boolean(queryWithParams && !showAutocomplete) 684 - 685 - // web only - measure header height for sticky positioning 686 - const [headerHeight, setHeaderHeight] = React.useState(0) 687 - const headerRef = React.useRef(null) 688 - useLayoutEffect(() => { 689 - if (isWeb) { 690 - if (!headerRef.current) return 691 - const measurement = (headerRef.current as Element).getBoundingClientRect() 692 - setHeaderHeight(measurement.height) 693 - } 694 - }, []) 695 - 696 - useFocusEffect( 697 - useNonReactiveCallback(() => { 698 - if (isWeb) { 699 - setSearchText(queryParam) 700 - } 701 - }), 702 - ) 703 - 704 - const onPressClearQuery = React.useCallback(() => { 705 - scrollToTopWeb() 706 - setSearchText('') 707 - textInput.current?.focus() 708 - }, []) 709 - 710 - const onChangeText = React.useCallback(async (text: string) => { 711 - scrollToTopWeb() 712 - setSearchText(text) 713 - }, []) 714 - 715 - const navigateToItem = React.useCallback( 716 - (item: string) => { 717 - scrollToTopWeb() 718 - setShowAutocomplete(false) 719 - updateSearchHistory(item) 720 - 721 - if (isWeb) { 722 - // @ts-expect-error route is not typesafe 723 - navigation.push(route.name, {...route.params, q: item}) 724 - } else { 725 - textInput.current?.blur() 726 - navigation.setParams({q: item}) 727 - } 728 - }, 729 - [updateSearchHistory, navigation, route], 730 - ) 731 - 732 - const onPressCancelSearch = React.useCallback(() => { 733 - scrollToTopWeb() 734 - textInput.current?.blur() 735 - setShowAutocomplete(false) 736 - if (isWeb) { 737 - // Empty params resets the URL to be /search rather than /search?q= 738 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 739 - const {q: _q, ...parameters} = (route.params ?? {}) as { 740 - [key: string]: string 741 - } 742 - // @ts-expect-error route is not typesafe 743 - navigation.replace(route.name, parameters) 744 - } else { 745 - setSearchText('') 746 - navigation.setParams({q: ''}) 747 - } 748 - }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) 749 - 750 - const onSubmit = React.useCallback(() => { 751 - navigateToItem(searchText) 752 - }, [navigateToItem, searchText]) 753 - 754 - const onAutocompleteResultPress = React.useCallback(() => { 755 - if (isWeb) { 756 - setShowAutocomplete(false) 757 - } else { 758 - textInput.current?.blur() 759 - } 760 - }, []) 761 - 762 - const handleHistoryItemClick = React.useCallback( 763 - (item: string) => { 764 - setSearchText(item) 765 - navigateToItem(item) 766 - }, 767 - [navigateToItem], 768 - ) 769 - 770 - const handleProfileClick = React.useCallback( 771 - (profile: bsky.profile.AnyProfileView) => { 772 - unstableCacheProfileView(queryClient, profile) 773 - // Slight delay to avoid updating during push nav animation. 774 - setTimeout(() => { 775 - updateProfileHistory(profile) 776 - }, 400) 777 - }, 778 - [updateProfileHistory, queryClient], 779 - ) 780 - 781 - const onSoftReset = React.useCallback(() => { 782 - if (isWeb) { 783 - // Empty params resets the URL to be /search rather than /search?q= 784 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 785 - const {q: _q, ...parameters} = (route.params ?? {}) as { 786 - [key: string]: string 787 - } 788 - // @ts-expect-error route is not typesafe 789 - navigation.replace(route.name, parameters) 790 - } else { 791 - setSearchText('') 792 - navigation.setParams({q: ''}) 793 - textInput.current?.focus() 794 - } 795 - }, [navigation, route]) 796 - 797 - useFocusEffect( 798 - React.useCallback(() => { 799 - setMinimalShellMode(false) 800 - return listenSoftReset(onSoftReset) 801 - }, [onSoftReset, setMinimalShellMode]), 802 - ) 803 - 804 - const onSearchInputFocus = React.useCallback(() => { 805 - if (isWeb) { 806 - // Prevent a jump on iPad by ensuring that 807 - // the initial focused render has no result list. 808 - requestAnimationFrame(() => { 809 - setShowAutocomplete(true) 810 - }) 811 - } else { 812 - setShowAutocomplete(true) 813 - } 814 - }, [setShowAutocomplete]) 815 - 816 - const showHeader = !gtMobile || navButton !== 'menu' 817 - 818 - return ( 819 - <Layout.Screen testID={testID}> 820 - <View 821 - ref={headerRef} 822 - onLayout={evt => { 823 - if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) 824 - }} 825 - style={[ 826 - a.relative, 827 - a.z_10, 828 - web({ 829 - position: 'sticky', 830 - top: 0, 831 - }), 832 - ]}> 833 - <Layout.Center style={t.atoms.bg}> 834 - {showHeader && ( 835 - <View 836 - // HACK: shift up search input. we can't remove the top padding 837 - // on the search input because it messes up the layout animation 838 - // if we add it only when the header is hidden 839 - style={{marginBottom: tokens.space.xs * -1}}> 840 - <Layout.Header.Outer noBottomBorder> 841 - {navButton === 'menu' ? ( 842 - <Layout.Header.MenuButton /> 843 - ) : ( 844 - <Layout.Header.BackButton /> 845 - )} 846 - <Layout.Header.Content align="left"> 847 - <Layout.Header.TitleText> 848 - <Trans>Search</Trans> 849 - </Layout.Header.TitleText> 850 - </Layout.Header.Content> 851 - {showFilters ? ( 852 - <SearchLanguageDropdown 853 - value={params.lang} 854 - onChange={params.setLang} 855 - /> 856 - ) : ( 857 - <Layout.Header.Slot /> 858 - )} 859 - </Layout.Header.Outer> 860 - </View> 861 - )} 862 - <View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}> 863 - <View style={[a.gap_sm]}> 864 - <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}> 865 - <View style={[a.flex_1]}> 866 - <SearchInput 867 - ref={textInput} 868 - value={searchText} 869 - onFocus={onSearchInputFocus} 870 - onChangeText={onChangeText} 871 - onClearText={onPressClearQuery} 872 - onSubmitEditing={onSubmit} 873 - placeholder={ 874 - inputPlaceholder ?? 875 - _(msg`Search for posts, users, or feeds`) 876 - } 877 - hitSlop={{...HITSLOP_20, top: 0}} 878 - /> 879 - </View> 880 - {showAutocomplete && ( 881 - <Button 882 - label={_(msg`Cancel search`)} 883 - size="large" 884 - variant="ghost" 885 - color="secondary" 886 - style={[a.px_sm]} 887 - onPress={onPressCancelSearch} 888 - hitSlop={HITSLOP_10}> 889 - <ButtonText> 890 - <Trans>Cancel</Trans> 891 - </ButtonText> 892 - </Button> 893 - )} 894 - </View> 895 - 896 - {showFilters && !showHeader && ( 897 - <View 898 - style={[ 899 - a.flex_row, 900 - a.align_center, 901 - a.justify_between, 902 - a.gap_sm, 903 - ]}> 904 - <SearchLanguageDropdown 905 - value={params.lang} 906 - onChange={params.setLang} 907 - /> 908 - </View> 909 - )} 910 - </View> 911 - </View> 912 - </Layout.Center> 913 - </View> 914 - 915 - <View 916 - style={{ 917 - display: showAutocomplete && !fixedParams ? 'flex' : 'none', 918 - flex: 1, 919 - }}> 920 - {searchText.length > 0 ? ( 921 - <AutocompleteResults 922 - isAutocompleteFetching={isAutocompleteFetching} 923 - autocompleteData={autocompleteData} 924 - searchText={searchText} 925 - onSubmit={onSubmit} 926 - onResultPress={onAutocompleteResultPress} 927 - onProfileClick={handleProfileClick} 928 - /> 929 - ) : ( 930 - <SearchHistory 931 - searchHistory={termHistory} 932 - selectedProfiles={accountHistoryProfiles?.profiles || []} 933 - onItemClick={handleHistoryItemClick} 934 - onProfileClick={handleProfileClick} 935 - onRemoveItemClick={deleteSearchHistoryItem} 936 - onRemoveProfileClick={deleteProfileHistoryItem} 937 - /> 938 - )} 939 - </View> 940 - <View 941 - style={{ 942 - display: showAutocomplete ? 'none' : 'flex', 943 - flex: 1, 944 - }}> 945 - <SearchScreenInner 946 - query={query} 947 - queryWithParams={queryWithParams} 948 - headerHeight={headerHeight} 949 - /> 950 - </View> 951 - </Layout.Screen> 952 - ) 953 - } 954 - 955 - let AutocompleteResults = ({ 956 - isAutocompleteFetching, 957 - autocompleteData, 958 - searchText, 959 - onSubmit, 960 - onResultPress, 961 - onProfileClick, 962 - }: { 963 - isAutocompleteFetching: boolean 964 - autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined 965 - searchText: string 966 - onSubmit: () => void 967 - onResultPress: () => void 968 - onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void 969 - }): React.ReactNode => { 970 - const moderationOpts = useModerationOpts() 971 - const {_} = useLingui() 972 - return ( 973 - <> 974 - {(isAutocompleteFetching && !autocompleteData?.length) || 975 - !moderationOpts ? ( 976 - <Loader /> 977 - ) : ( 978 - <Layout.Content 979 - keyboardShouldPersistTaps="handled" 980 - keyboardDismissMode="on-drag"> 981 - <SearchLinkCard 982 - label={_(msg`Search for "${searchText}"`)} 983 - onPress={isNative ? onSubmit : undefined} 984 - to={ 985 - isNative 986 - ? undefined 987 - : `/search?q=${encodeURIComponent(searchText)}` 988 - } 989 - style={{borderBottomWidth: 1}} 990 - /> 991 - {autocompleteData?.map(item => ( 992 - <SearchProfileCard 993 - key={item.did} 994 - profile={item} 995 - moderation={moderateProfile(item, moderationOpts)} 996 - onPress={() => { 997 - onProfileClick(item) 998 - onResultPress() 999 - }} 1000 - /> 1001 - ))} 1002 - <View style={{height: 200}} /> 1003 - </Layout.Content> 1004 - )} 1005 - </> 1006 - ) 1007 - } 1008 - AutocompleteResults = React.memo(AutocompleteResults) 1009 - 1010 - function SearchHistory({ 1011 - searchHistory, 1012 - selectedProfiles, 1013 - onItemClick, 1014 - onProfileClick, 1015 - onRemoveItemClick, 1016 - onRemoveProfileClick, 1017 - }: { 1018 - searchHistory: string[] 1019 - selectedProfiles: AppBskyActorDefs.ProfileViewDetailed[] 1020 - onItemClick: (item: string) => void 1021 - onProfileClick: (profile: AppBskyActorDefs.ProfileViewDetailed) => void 1022 - onRemoveItemClick: (item: string) => void 1023 - onRemoveProfileClick: (profile: AppBskyActorDefs.ProfileViewDetailed) => void 1024 - }) { 1025 - const {gtMobile} = useBreakpoints() 1026 - const t = useTheme() 1027 - const {_} = useLingui() 1028 - 1029 - return ( 1030 - <Layout.Content 1031 - keyboardDismissMode="interactive" 1032 - keyboardShouldPersistTaps="handled"> 1033 - <View style={[a.w_full, a.px_md]}> 1034 - {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( 1035 - <Text style={[a.text_md, a.font_bold, a.p_md]}> 1036 - <Trans>Recent Searches</Trans> 1037 - </Text> 1038 - )} 1039 - {selectedProfiles.length > 0 && ( 1040 - <View 1041 - style={[ 1042 - styles.selectedProfilesContainer, 1043 - !gtMobile && styles.selectedProfilesContainerMobile, 1044 - ]}> 1045 - <RNGHScrollView 1046 - keyboardShouldPersistTaps="handled" 1047 - horizontal={true} 1048 - style={[ 1049 - a.flex_row, 1050 - a.flex_nowrap, 1051 - {marginHorizontal: tokens.space._2xl * -1}, 1052 - ]} 1053 - contentContainerStyle={[a.px_2xl, a.border_0]}> 1054 - {selectedProfiles.slice(0, 5).map((profile, index) => ( 1055 - <View 1056 - key={index} 1057 - style={[ 1058 - styles.profileItem, 1059 - !gtMobile && styles.profileItemMobile, 1060 - ]}> 1061 - <Link 1062 - href={makeProfileLink(profile)} 1063 - title={profile.handle} 1064 - asAnchor 1065 - anchorNoUnderline 1066 - onBeforePress={() => onProfileClick(profile)} 1067 - style={[a.align_center, a.w_full]}> 1068 - <UserAvatar 1069 - avatar={profile.avatar} 1070 - type={profile.associated?.labeler ? 'labeler' : 'user'} 1071 - size={60} 1072 - /> 1073 - <Text 1074 - emoji 1075 - style={[a.text_xs, a.text_center, styles.profileName]} 1076 - numberOfLines={1}> 1077 - {sanitizeDisplayName( 1078 - profile.displayName || profile.handle, 1079 - )} 1080 - </Text> 1081 - </Link> 1082 - <Pressable 1083 - accessibilityRole="button" 1084 - accessibilityLabel={_(msg`Remove profile`)} 1085 - accessibilityHint={_( 1086 - msg`Removes profile from search history`, 1087 - )} 1088 - onPress={() => onRemoveProfileClick(profile)} 1089 - hitSlop={createHitslop(6)} 1090 - style={styles.profileRemoveBtn}> 1091 - <XIcon size="xs" style={t.atoms.text_contrast_low} /> 1092 - </Pressable> 1093 - </View> 1094 - ))} 1095 - </RNGHScrollView> 1096 - </View> 1097 - )} 1098 - {searchHistory.length > 0 && ( 1099 - <View style={[a.pl_md, a.pr_xs, a.mt_md]}> 1100 - {searchHistory.slice(0, 5).map((historyItem, index) => ( 1101 - <View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}> 1102 - <Pressable 1103 - accessibilityRole="button" 1104 - onPress={() => onItemClick(historyItem)} 1105 - hitSlop={HITSLOP_10} 1106 - style={[a.flex_1, a.py_md]}> 1107 - <Text style={[a.text_md]}>{historyItem}</Text> 1108 - </Pressable> 1109 - <Button 1110 - label={_(msg`Remove ${historyItem}`)} 1111 - onPress={() => onRemoveItemClick(historyItem)} 1112 - size="small" 1113 - variant="ghost" 1114 - color="secondary" 1115 - shape="round"> 1116 - <ButtonIcon icon={XIcon} /> 1117 - </Button> 1118 - </View> 1119 - ))} 1120 - </View> 1121 - )} 1122 - </View> 1123 - </Layout.Content> 1124 - ) 1125 - } 1126 - 1127 - function scrollToTopWeb() { 1128 - if (isWeb) { 1129 - window.scrollTo(0, 0) 1130 - } 1131 - } 1132 - 1133 - const styles = StyleSheet.create({ 1134 - selectedProfilesContainer: { 1135 - marginTop: 10, 1136 - paddingHorizontal: 12, 1137 - height: 80, 1138 - }, 1139 - selectedProfilesContainerMobile: { 1140 - height: 100, 1141 - }, 1142 - profileItem: { 1143 - alignItems: 'center', 1144 - marginRight: 15, 1145 - width: 78, 1146 - }, 1147 - profileItemMobile: { 1148 - width: 70, 1149 - }, 1150 - profileName: { 1151 - width: 78, 1152 - marginTop: 6, 1153 - }, 1154 - profileRemoveBtn: { 1155 - position: 'absolute', 1156 - top: 0, 1157 - right: 5, 1158 - backgroundColor: 'white', 1159 - borderRadius: 10, 1160 - width: 18, 1161 - height: 18, 1162 - alignItems: 'center', 1163 - justifyContent: 'center', 1164 - }, 1165 - })
···
-1
src/view/screens/Search/index.tsx
··· 1 - export {SearchScreen} from '#/view/screens/Search/Search'
···
+1 -1
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 14 import {Button, ButtonIcon} from '#/components/Button' 15 import {Divider} from '#/components/Divider' 16 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 17 - import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 18 import * as Prompt from '#/components/Prompt' 19 import { 20 TrendingTopic,
··· 14 import {Button, ButtonIcon} from '#/components/Button' 15 import {Divider} from '#/components/Divider' 16 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 17 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 18 import * as Prompt from '#/components/Prompt' 19 import { 20 TrendingTopic,
+37 -65
yarn.lock
··· 80 tlds "^1.234.0" 81 zod "^3.23.8" 82 83 - "@atproto/api@^0.14.16": 84 - version "0.14.16" 85 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.16.tgz#7b59eb83a27e906e0dc442d3de0f0d3869092b4a" 86 - integrity sha512-xzUK3KVdp1TDJJ09Di2rvS/fisVctvMHO7Er0XhYviL3V4lxGQPNT3pHwbTbbb22QP7xH/d5ghCgfdIoS5Z8/A== 87 dependencies: 88 - "@atproto/common-web" "^0.4.0" 89 - "@atproto/lexicon" "^0.4.9" 90 "@atproto/syntax" "^0.4.0" 91 - "@atproto/xrpc" "^0.6.11" 92 await-lock "^2.2.2" 93 multiformats "^9.9.0" 94 tlds "^1.234.0" ··· 176 version "0.4.0" 177 resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.0.tgz#b1407ae3f964f0ee23c2c3184f38041bac99d1f4" 178 integrity sha512-ZYL0P9myHybNgwh/hBY0HaBzqiLR1B5/ie5bJpLQAg0whRzNA28t8/nU2vh99tbsWcAF0LOD29M8++LyENJLNQ== 179 dependencies: 180 graphemer "^1.4.0" 181 multiformats "^9.9.0" ··· 291 multiformats "^9.9.0" 292 zod "^3.23.8" 293 294 - "@atproto/lexicon@^0.4.7": 295 - version "0.4.7" 296 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86" 297 - integrity sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g== 298 dependencies: 299 - "@atproto/common-web" "^0.4.0" 300 - "@atproto/syntax" "^0.3.3" 301 iso-datestring-validator "^2.2.2" 302 multiformats "^9.9.0" 303 zod "^3.23.8" 304 305 - "@atproto/lexicon@^0.4.9": 306 - version "0.4.9" 307 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.9.tgz#612951a85ecc1398366bd837cda6be89440f179d" 308 - integrity sha512-/tmEuHQFr51V2V7EAVJzaA40sqJ7ylAZpR962VbOsPtmcdOHvezbjVHYEMXgfb927hS+xqbVyzBTbu5w9v8prA== 309 dependencies: 310 "@atproto/common-web" "^0.4.0" 311 - "@atproto/syntax" "^0.4.0" 312 iso-datestring-validator "^2.2.2" 313 multiformats "^9.9.0" 314 zod "^3.23.8" ··· 480 ws "^8.12.0" 481 zod "^3.23.8" 482 483 - "@atproto/xrpc@^0.6.11": 484 - version "0.6.11" 485 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.11.tgz#54c527e39a2f5ddd2655b11f7cb99b8f303d8364" 486 - integrity sha512-J2cZP8FjoDN0UkyTYBlCvKvxwBbDm4dld47u6FQK30RJy9YpSiUkdxJJ10NYqpi7JVny3M0qWQgpWJDV94+PdA== 487 dependencies: 488 - "@atproto/lexicon" "^0.4.9" 489 zod "^3.23.8" 490 491 "@atproto/xrpc@^0.6.9": ··· 3370 "@babel/parser" "^7.26.9" 3371 "@babel/types" "^7.26.9" 3372 3373 - "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": 3374 version "7.25.9" 3375 resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" 3376 integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== ··· 3428 "@babel/helper-split-export-declaration" "^7.24.5" 3429 "@babel/parser" "^7.24.5" 3430 "@babel/types" "^7.24.5" 3431 - debug "^4.3.1" 3432 - globals "^11.1.0" 3433 - 3434 - "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": 3435 - version "7.25.9" 3436 - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" 3437 - integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== 3438 - dependencies: 3439 - "@babel/code-frame" "^7.25.9" 3440 - "@babel/generator" "^7.25.9" 3441 - "@babel/parser" "^7.25.9" 3442 - "@babel/template" "^7.25.9" 3443 - "@babel/types" "^7.25.9" 3444 debug "^4.3.1" 3445 globals "^11.1.0" 3446 ··· 18460 resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" 18461 integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== 18462 18463 - "string-width-cjs@npm:string-width@^4.2.0": 18464 - version "4.2.3" 18465 - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 18466 - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 18467 - dependencies: 18468 - emoji-regex "^8.0.0" 18469 - is-fullwidth-code-point "^3.0.0" 18470 - strip-ansi "^6.0.1" 18471 - 18472 - string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 18473 version "4.2.3" 18474 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 18475 integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== ··· 18601 dependencies: 18602 safe-buffer "~5.1.0" 18603 18604 - "strip-ansi-cjs@npm:strip-ansi@^6.0.1": 18605 version "6.0.1" 18606 resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 18607 integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== ··· 18614 integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== 18615 dependencies: 18616 ansi-regex "^4.1.0" 18617 - 18618 - strip-ansi@^6.0.0, strip-ansi@^6.0.1: 18619 - version "6.0.1" 18620 - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 18621 - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 18622 - dependencies: 18623 - ansi-regex "^5.0.1" 18624 18625 strip-ansi@^7.0.1: 18626 version "7.1.0" ··· 20054 resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" 20055 integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== 20056 20057 - "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": 20058 version "7.0.0" 20059 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 20060 integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== ··· 20067 version "6.2.0" 20068 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" 20069 integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== 20070 - dependencies: 20071 - ansi-styles "^4.0.0" 20072 - string-width "^4.1.0" 20073 - strip-ansi "^6.0.0" 20074 - 20075 - wrap-ansi@^7.0.0: 20076 - version "7.0.0" 20077 - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 20078 - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 20079 dependencies: 20080 ansi-styles "^4.0.0" 20081 string-width "^4.1.0"
··· 80 tlds "^1.234.0" 81 zod "^3.23.8" 82 83 + "@atproto/api@^0.14.19": 84 + version "0.14.19" 85 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.14.19.tgz#fef8994e2b14e69a9e3a0aef043c7fcb34d6bf8c" 86 + integrity sha512-YYTqM0K0qk2TP7PguktPzlAQGLTL1bEGz6PgY5kqKJNX4o1318kJYB22DzjJYqV2NUCq0JQ9Lb0oskLvTisEOg== 87 dependencies: 88 + "@atproto/common-web" "^0.4.1" 89 + "@atproto/lexicon" "^0.4.10" 90 "@atproto/syntax" "^0.4.0" 91 + "@atproto/xrpc" "^0.6.12" 92 await-lock "^2.2.2" 93 multiformats "^9.9.0" 94 tlds "^1.234.0" ··· 176 version "0.4.0" 177 resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.0.tgz#b1407ae3f964f0ee23c2c3184f38041bac99d1f4" 178 integrity sha512-ZYL0P9myHybNgwh/hBY0HaBzqiLR1B5/ie5bJpLQAg0whRzNA28t8/nU2vh99tbsWcAF0LOD29M8++LyENJLNQ== 179 + dependencies: 180 + graphemer "^1.4.0" 181 + multiformats "^9.9.0" 182 + uint8arrays "3.0.0" 183 + zod "^3.23.8" 184 + 185 + "@atproto/common-web@^0.4.1": 186 + version "0.4.1" 187 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.1.tgz#f31054f689f4f52b06da6ffd727e40ecd67a30b6" 188 + integrity sha512-Ghh+djHYMAUCktLKwr2IuGgtjcwSWGudp+K7+N7KBA9pDDloOXUEY8Agjc5SHSo9B1QIEFkegClU5n+apn2e0w== 189 dependencies: 190 graphemer "^1.4.0" 191 multiformats "^9.9.0" ··· 301 multiformats "^9.9.0" 302 zod "^3.23.8" 303 304 + "@atproto/lexicon@^0.4.10": 305 + version "0.4.10" 306 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.10.tgz#276790a1bca060a55c80d556ce763eaa81f6e944" 307 + integrity sha512-uDbP20vetBgtXPuxoyRcvOGBt2gNe1dFc9yYKcb6jWmXfseHiGTnIlORJOLBXIT2Pz15Eap4fLxAu6zFAykD5A== 308 dependencies: 309 + "@atproto/common-web" "^0.4.1" 310 + "@atproto/syntax" "^0.4.0" 311 iso-datestring-validator "^2.2.2" 312 multiformats "^9.9.0" 313 zod "^3.23.8" 314 315 + "@atproto/lexicon@^0.4.7": 316 + version "0.4.7" 317 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.7.tgz#f5d31615c21bcfd3e655f1e4f11a40a62fea9f86" 318 + integrity sha512-/x6h3tAiDNzSi4eXtC8ke65B7UzsagtlGRHmUD95698x5lBRpDnpizj0fZWTZVYed5qnOmz/ZEue+v3wDmO61g== 319 dependencies: 320 "@atproto/common-web" "^0.4.0" 321 + "@atproto/syntax" "^0.3.3" 322 iso-datestring-validator "^2.2.2" 323 multiformats "^9.9.0" 324 zod "^3.23.8" ··· 490 ws "^8.12.0" 491 zod "^3.23.8" 492 493 + "@atproto/xrpc@^0.6.12": 494 + version "0.6.12" 495 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.12.tgz#a21ee5b87fde63994c98c34098d5e092252e25d0" 496 + integrity sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w== 497 dependencies: 498 + "@atproto/lexicon" "^0.4.10" 499 zod "^3.23.8" 500 501 "@atproto/xrpc@^0.6.9": ··· 3380 "@babel/parser" "^7.26.9" 3381 "@babel/types" "^7.26.9" 3382 3383 + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": 3384 version "7.25.9" 3385 resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" 3386 integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== ··· 3438 "@babel/helper-split-export-declaration" "^7.24.5" 3439 "@babel/parser" "^7.24.5" 3440 "@babel/types" "^7.24.5" 3441 debug "^4.3.1" 3442 globals "^11.1.0" 3443 ··· 18457 resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" 18458 integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== 18459 18460 + "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 18461 version "4.2.3" 18462 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 18463 integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== ··· 18589 dependencies: 18590 safe-buffer "~5.1.0" 18591 18592 + "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: 18593 version "6.0.1" 18594 resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 18595 integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== ··· 18602 integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== 18603 dependencies: 18604 ansi-regex "^4.1.0" 18605 18606 strip-ansi@^7.0.1: 18607 version "7.1.0" ··· 20035 resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" 20036 integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== 20037 20038 + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: 20039 version "7.0.0" 20040 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 20041 integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== ··· 20048 version "6.2.0" 20049 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" 20050 integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== 20051 dependencies: 20052 ansi-styles "^4.0.0" 20053 string-width "^4.1.0"