this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'bluesky-social:main' into zh

authored by

Kuwa Lee and committed by
GitHub
01f505d0 b80594a5

+1017 -166
+1
assets/icons/arrowBottom_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z" clip-rule="evenodd"/></svg>
+1 -1
package.json
··· 1 1 { 2 2 "name": "bsky.app", 3 - "version": "1.86.0", 3 + "version": "1.87.0", 4 4 "private": true, 5 5 "engines": { 6 6 "node": ">=18"
+2 -2
patches/expo-haptics+12.8.1.patch patches/expo-haptics+13.0.1.patch
··· 1 1 diff --git a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt 2 - index 26c52af..b949a4c 100644 2 + index 1520465..6ea988a 100644 3 3 --- a/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt 4 4 +++ b/node_modules/expo-haptics/android/src/main/java/expo/modules/haptics/HapticsModule.kt 5 5 @@ -42,7 +42,7 @@ class HapticsModule : Module() { 6 - 6 + 7 7 private fun vibrate(type: HapticsVibrationType) { 8 8 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 9 9 - vibrator.vibrate(VibrationEffect.createWaveform(type.timings, type.amplitudes, -1))
+2 -2
patches/expo-updates+0.25.11.patch patches/expo-updates+0.25.14.patch
··· 1 1 diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 2 - index b85291e..07a5d3c 100644 2 + index b85291e..546709d 100644 3 3 --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 4 4 +++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 5 5 @@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update { 6 6 status = UpdateStatus.StatusPending 7 7 } 8 - 8 + 9 9 + // Instead of relying on various hacks to get the correct format for the specific 10 10 + // platform on the backend, we can just add this little patch.. 11 11 + let dateFormatter = DateFormatter()
+198
src/components/FeedCard.tsx
··· 1 + import React from 'react' 2 + import {GestureResponderEvent, View} from 'react-native' 3 + import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' 4 + import {msg, plural, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {logger} from '#/logger' 8 + import { 9 + useAddSavedFeedsMutation, 10 + usePreferencesQuery, 11 + useRemoveFeedMutation, 12 + } from '#/state/queries/preferences' 13 + import {sanitizeHandle} from 'lib/strings/handles' 14 + import {UserAvatar} from '#/view/com/util/UserAvatar' 15 + import * as Toast from 'view/com/util/Toast' 16 + import {useTheme} from '#/alf' 17 + import {atoms as a} from '#/alf' 18 + import {Button, ButtonIcon} from '#/components/Button' 19 + import {useRichText} from '#/components/hooks/useRichText' 20 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 21 + import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 22 + import {Link as InternalLink} from '#/components/Link' 23 + import {Loader} from '#/components/Loader' 24 + import * as Prompt from '#/components/Prompt' 25 + import {RichText} from '#/components/RichText' 26 + import {Text} from '#/components/Typography' 27 + 28 + export function Default({feed}: {feed: AppBskyFeedDefs.GeneratorView}) { 29 + return ( 30 + <Link feed={feed}> 31 + <Outer> 32 + <Header> 33 + <Avatar src={feed.avatar} /> 34 + <TitleAndByline title={feed.displayName} creator={feed.creator} /> 35 + <Action uri={feed.uri} pin /> 36 + </Header> 37 + <Description description={feed.description} /> 38 + <Likes count={feed.likeCount || 0} /> 39 + </Outer> 40 + </Link> 41 + ) 42 + } 43 + 44 + export function Link({ 45 + children, 46 + feed, 47 + }: { 48 + children: React.ReactElement 49 + feed: AppBskyFeedDefs.GeneratorView 50 + }) { 51 + const href = React.useMemo(() => { 52 + const urip = new AtUri(feed.uri) 53 + const handleOrDid = feed.creator.handle || feed.creator.did 54 + return `/profile/${handleOrDid}/feed/${urip.rkey}` 55 + }, [feed]) 56 + return <InternalLink to={href}>{children}</InternalLink> 57 + } 58 + 59 + export function Outer({children}: {children: React.ReactNode}) { 60 + return <View style={[a.flex_1, a.gap_md]}>{children}</View> 61 + } 62 + 63 + export function Header({children}: {children: React.ReactNode}) { 64 + return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View> 65 + } 66 + 67 + export function Avatar({src}: {src: string | undefined}) { 68 + return <UserAvatar type="algo" size={40} avatar={src} /> 69 + } 70 + 71 + export function TitleAndByline({ 72 + title, 73 + creator, 74 + }: { 75 + title: string 76 + creator: AppBskyActorDefs.ProfileViewBasic 77 + }) { 78 + const t = useTheme() 79 + 80 + return ( 81 + <View style={[a.flex_1]}> 82 + <Text 83 + style={[a.text_md, a.font_bold, a.flex_1, a.leading_snug]} 84 + numberOfLines={1}> 85 + {title} 86 + </Text> 87 + <Text 88 + style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]} 89 + numberOfLines={1}> 90 + <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> 91 + </Text> 92 + </View> 93 + ) 94 + } 95 + 96 + export function Description({description}: {description?: string}) { 97 + const [rt, isResolving] = useRichText(description || '') 98 + if (!description) return null 99 + return isResolving ? ( 100 + <RichText value={description} style={[a.leading_snug]} /> 101 + ) : ( 102 + <RichText value={rt} style={[a.leading_snug]} /> 103 + ) 104 + } 105 + 106 + export function Likes({count}: {count: number}) { 107 + const t = useTheme() 108 + return ( 109 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 110 + {plural(count || 0, { 111 + one: 'Liked by # user', 112 + other: 'Liked by # users', 113 + })} 114 + </Text> 115 + ) 116 + } 117 + 118 + export function Action({uri, pin}: {uri: string; pin?: boolean}) { 119 + const {_} = useLingui() 120 + const {data: preferences} = usePreferencesQuery() 121 + const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = 122 + useAddSavedFeedsMutation() 123 + const {isPending: isRemovePending, mutateAsync: removeFeed} = 124 + useRemoveFeedMutation() 125 + const savedFeedConfig = React.useMemo(() => { 126 + return preferences?.savedFeeds?.find( 127 + feed => feed.type === 'feed' && feed.value === uri, 128 + ) 129 + }, [preferences?.savedFeeds, uri]) 130 + const removePromptControl = Prompt.usePromptControl() 131 + const isPending = isAddSavedFeedPending || isRemovePending 132 + 133 + const toggleSave = React.useCallback( 134 + async (e: GestureResponderEvent) => { 135 + e.preventDefault() 136 + e.stopPropagation() 137 + 138 + try { 139 + if (savedFeedConfig) { 140 + await removeFeed(savedFeedConfig) 141 + } else { 142 + await saveFeeds([ 143 + { 144 + type: 'feed', 145 + value: uri, 146 + pinned: pin || false, 147 + }, 148 + ]) 149 + } 150 + Toast.show(_(msg`Feeds updated!`)) 151 + } catch (e: any) { 152 + logger.error(e, {context: `FeedCard: failed to update feeds`, pin}) 153 + Toast.show(_(msg`Failed to update feeds`)) 154 + } 155 + }, 156 + [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig], 157 + ) 158 + 159 + const onPrompRemoveFeed = React.useCallback( 160 + async (e: GestureResponderEvent) => { 161 + e.preventDefault() 162 + e.stopPropagation() 163 + 164 + removePromptControl.open() 165 + }, 166 + [removePromptControl], 167 + ) 168 + 169 + return ( 170 + <> 171 + <Button 172 + disabled={isPending} 173 + label={_(msg`Add this feed to your feeds`)} 174 + size="small" 175 + variant="ghost" 176 + color="secondary" 177 + shape="square" 178 + onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}> 179 + {savedFeedConfig ? ( 180 + <ButtonIcon size="md" icon={isPending ? Loader : Trash} /> 181 + ) : ( 182 + <ButtonIcon size="md" icon={isPending ? Loader : Plus} /> 183 + )} 184 + </Button> 185 + 186 + <Prompt.Basic 187 + control={removePromptControl} 188 + title={_(msg`Remove from my feeds?`)} 189 + description={_( 190 + msg`Are you sure you want to remove this from your feeds?`, 191 + )} 192 + onConfirm={toggleSave} 193 + confirmButtonCta={_(msg`Remove`)} 194 + confirmButtonColor="negative" 195 + /> 196 + </> 197 + ) 198 + }
+1 -1
src/components/KnownFollowers.tsx
··· 100 100 moderation, 101 101 } 102 102 }) 103 - const count = cachedKnownFollowers.count - Math.min(slice.length, 2) 103 + const count = cachedKnownFollowers.count 104 104 105 105 return ( 106 106 <Link
+10 -7
src/components/Prompt.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {GestureResponderEvent, View} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 7 - import {Button, ButtonColor, ButtonText} from '#/components/Button' 7 + import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' 8 8 import * as Dialog from '#/components/Dialog' 9 9 import {Text} from '#/components/Typography' 10 10 ··· 136 136 * Note: The dialog will close automatically when the action is pressed, you 137 137 * should NOT close the dialog as a side effect of this method. 138 138 */ 139 - onPress: () => void 139 + onPress: ButtonProps['onPress'] 140 140 color?: ButtonColor 141 141 /** 142 142 * Optional i18n string. If undefined, it will default to "Confirm". ··· 147 147 const {_} = useLingui() 148 148 const {gtMobile} = useBreakpoints() 149 149 const {close} = Dialog.useDialogContext() 150 - const handleOnPress = React.useCallback(() => { 151 - close(onPress) 152 - }, [close, onPress]) 150 + const handleOnPress = React.useCallback( 151 + (e: GestureResponderEvent) => { 152 + close(() => onPress?.(e)) 153 + }, 154 + [close, onPress], 155 + ) 153 156 154 157 return ( 155 158 <Button ··· 186 189 * Note: The dialog will close automatically when the action is pressed, you 187 190 * should NOT close the dialog as a side effect of this method. 188 191 */ 189 - onConfirm: () => void 192 + onConfirm: ButtonProps['onPress'] 190 193 confirmButtonColor?: ButtonColor 191 194 showCancel?: boolean 192 195 }>) {
+1 -1
src/components/dms/LeaveConvoPrompt.tsx
··· 49 49 )} 50 50 confirmButtonCta={_(msg`Leave`)} 51 51 confirmButtonColor="negative" 52 - onConfirm={leaveConvo} 52 + onConfirm={() => leaveConvo()} 53 53 /> 54 54 ) 55 55 }
+4
src/components/icons/Arrow.tsx
··· 7 7 export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 8 path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', 9 9 }) 10 + 11 + export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 12 + path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z', 13 + })
+1 -1
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 333 333 </Trans> 334 334 </Prompt.DescriptionText> 335 335 <Prompt.Actions> 336 - <Prompt.Action onPress={control.close} cta={_(msg`OK`)} /> 336 + <Prompt.Action onPress={() => control.close()} cta={_(msg`OK`)} /> 337 337 </Prompt.Actions> 338 338 </Prompt.Outer> 339 339 )
+2
src/state/cache/profile-shadow.ts
··· 5 5 6 6 import {batchedUpdates} from '#/lib/batchedUpdates' 7 7 import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search' 8 + import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '../queries/known-followers' 8 9 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members' 9 10 import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations' 10 11 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts' ··· 111 112 yield* findAllProfilesInListConvosQueryData(queryClient, did) 112 113 yield* findAllProfilesInFeedsQueryData(queryClient, did) 113 114 yield* findAllProfilesInPostThreadQueryData(queryClient, did) 115 + yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) 114 116 }
+125 -5
src/state/queries/feed.ts
··· 1 + import {useCallback, useEffect, useMemo, useRef} from 'react' 1 2 import { 2 3 AppBskyActorDefs, 3 4 AppBskyFeedDefs, ··· 171 172 }) 172 173 } 173 174 174 - export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] 175 + // HACK 176 + // the protocol doesn't yet tell us which feeds are personalized 177 + // this list is used to filter out feed recommendations from logged out users 178 + // for the ones we know need it 179 + // -prf 180 + export const KNOWN_AUTHED_ONLY_FEEDS = [ 181 + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app 182 + 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed 183 + 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed 184 + 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow 185 + 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz 186 + 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky 187 + 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz 188 + 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why 189 + ] 190 + 191 + type GetPopularFeedsOptions = {limit?: number} 175 192 176 - export function useGetPopularFeedsQuery() { 193 + export function createGetPopularFeedsQueryKey( 194 + options?: GetPopularFeedsOptions, 195 + ) { 196 + return ['getPopularFeeds', options] 197 + } 198 + 199 + export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { 200 + const {hasSession} = useSession() 177 201 const agent = useAgent() 178 - return useInfiniteQuery< 202 + const limit = options?.limit || 10 203 + const {data: preferences} = usePreferencesQuery() 204 + 205 + // Make sure this doesn't invalidate unless really needed. 206 + const selectArgs = useMemo( 207 + () => ({ 208 + hasSession, 209 + savedFeeds: preferences?.savedFeeds || [], 210 + }), 211 + [hasSession, preferences?.savedFeeds], 212 + ) 213 + const lastPageCountRef = useRef(0) 214 + 215 + const query = useInfiniteQuery< 179 216 AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema, 180 217 Error, 181 218 InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, 182 219 QueryKey, 183 220 string | undefined 184 221 >({ 185 - queryKey: useGetPopularFeedsQueryKey, 222 + queryKey: createGetPopularFeedsQueryKey(options), 186 223 queryFn: async ({pageParam}) => { 187 224 const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 188 - limit: 10, 225 + limit, 189 226 cursor: pageParam, 190 227 }) 191 228 return res.data 192 229 }, 193 230 initialPageParam: undefined, 194 231 getNextPageParam: lastPage => lastPage.cursor, 232 + select: useCallback( 233 + ( 234 + data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, 235 + ) => { 236 + const {savedFeeds, hasSession: hasSessionInner} = selectArgs 237 + data?.pages.map(page => { 238 + page.feeds = page.feeds.filter(feed => { 239 + if ( 240 + !hasSessionInner && 241 + KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) 242 + ) { 243 + return false 244 + } 245 + const alreadySaved = Boolean( 246 + savedFeeds?.find(f => { 247 + return f.value === feed.uri 248 + }), 249 + ) 250 + return !alreadySaved 251 + }) 252 + 253 + return page 254 + }) 255 + 256 + return data 257 + }, 258 + [selectArgs /* Don't change. Everything needs to go into selectArgs. */], 259 + ), 195 260 }) 261 + 262 + useEffect(() => { 263 + const {isFetching, hasNextPage, data} = query 264 + if (isFetching || !hasNextPage) { 265 + return 266 + } 267 + 268 + // avoid double-fires of fetchNextPage() 269 + if ( 270 + lastPageCountRef.current !== 0 && 271 + lastPageCountRef.current === data?.pages?.length 272 + ) { 273 + return 274 + } 275 + 276 + // fetch next page if we haven't gotten a full page of content 277 + let count = 0 278 + for (const page of data?.pages || []) { 279 + count += page.feeds.length 280 + } 281 + if (count < limit && (data?.pages.length || 0) < 6) { 282 + query.fetchNextPage() 283 + lastPageCountRef.current = data?.pages?.length || 0 284 + } 285 + }, [query, limit]) 286 + 287 + return query 196 288 } 197 289 198 290 export function useSearchPopularFeedsMutation() { 199 291 const agent = useAgent() 200 292 return useMutation({ 201 293 mutationFn: async (query: string) => { 294 + const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 295 + limit: 10, 296 + query: query, 297 + }) 298 + 299 + return res.data.feeds 300 + }, 301 + }) 302 + } 303 + 304 + const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch' 305 + export const createPopularFeedsSearchQueryKey = (query: string) => [ 306 + popularFeedsSearchQueryKeyRoot, 307 + query, 308 + ] 309 + 310 + export function usePopularFeedsSearch({ 311 + query, 312 + enabled, 313 + }: { 314 + query: string 315 + enabled?: boolean 316 + }) { 317 + const agent = useAgent() 318 + return useQuery({ 319 + enabled, 320 + queryKey: createPopularFeedsSearchQueryKey(query), 321 + queryFn: async () => { 202 322 const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ 203 323 limit: 10, 204 324 query: query,
+30 -2
src/state/queries/known-followers.ts
··· 1 - import {AppBskyGraphGetKnownFollowers} from '@atproto/api' 2 - import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' 1 + import {AppBskyActorDefs, AppBskyGraphGetKnownFollowers} from '@atproto/api' 2 + import { 3 + InfiniteData, 4 + QueryClient, 5 + QueryKey, 6 + useInfiniteQuery, 7 + } from '@tanstack/react-query' 3 8 4 9 import {useAgent} from '#/state/session' 5 10 ··· 32 37 enabled: !!did, 33 38 }) 34 39 } 40 + 41 + export function* findAllProfilesInQueryData( 42 + queryClient: QueryClient, 43 + did: string, 44 + ): Generator<AppBskyActorDefs.ProfileView, void> { 45 + const queryDatas = queryClient.getQueriesData< 46 + InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema> 47 + >({ 48 + queryKey: [RQKEY_ROOT], 49 + }) 50 + for (const [_queryKey, queryData] of queryDatas) { 51 + if (!queryData?.pages) { 52 + continue 53 + } 54 + for (const page of queryData?.pages) { 55 + for (const follow of page.followers) { 56 + if (follow.did === did) { 57 + yield follow 58 + } 59 + } 60 + } 61 + } 62 + }
+9 -4
src/state/queries/suggested-follows.ts
··· 23 23 import {useModerationOpts} from '../preferences/moderation-opts' 24 24 25 25 const suggestedFollowsQueryKeyRoot = 'suggested-follows' 26 - const suggestedFollowsQueryKey = [suggestedFollowsQueryKeyRoot] 26 + const suggestedFollowsQueryKey = (options?: SuggestedFollowsOptions) => [ 27 + suggestedFollowsQueryKeyRoot, 28 + options, 29 + ] 27 30 28 31 const suggestedFollowsByActorQueryKeyRoot = 'suggested-follows-by-actor' 29 32 const suggestedFollowsByActorQueryKey = (did: string) => [ ··· 31 34 did, 32 35 ] 33 36 34 - export function useSuggestedFollowsQuery() { 37 + type SuggestedFollowsOptions = {limit?: number} 38 + 39 + export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { 35 40 const {currentAccount} = useSession() 36 41 const agent = useAgent() 37 42 const moderationOpts = useModerationOpts() ··· 46 51 >({ 47 52 enabled: !!moderationOpts && !!preferences, 48 53 staleTime: STALE.HOURS.ONE, 49 - queryKey: suggestedFollowsQueryKey, 54 + queryKey: suggestedFollowsQueryKey(options), 50 55 queryFn: async ({pageParam}) => { 51 56 const contentLangs = getContentLanguages().join(',') 52 57 const res = await agent.app.bsky.actor.getSuggestions( 53 58 { 54 - limit: 25, 59 + limit: options?.limit || 25, 55 60 cursor: pageParam, 56 61 }, 57 62 {
+2 -1
src/view/com/home/HomeHeaderLayout.web.tsx
··· 57 57 t.atoms.bg, 58 58 t.atoms.border_contrast_low, 59 59 styles.bar, 60 + kawaii && {paddingTop: 22, paddingBottom: 16}, 60 61 ]}> 61 62 <View 62 63 style={[ ··· 66 67 a.m_auto, 67 68 kawaii && {paddingTop: 4, paddingBottom: 0}, 68 69 { 69 - width: kawaii ? 60 : 28, 70 + width: kawaii ? 84 : 28, 70 71 }, 71 72 ]}> 72 73 <Logo width={kawaii ? 60 : 28} />
+1 -1
src/view/com/util/post-embeds/GifEmbed.tsx
··· 181 181 <Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText> 182 182 <Prompt.Actions> 183 183 <Prompt.Action 184 - onPress={control.close} 184 + onPress={() => control.close()} 185 185 cta={_(msg`Close`)} 186 186 color="secondary" 187 187 />
+17 -51
src/view/screens/Feeds.tsx
··· 1 1 import React from 'react' 2 2 import {ActivityIndicator, type FlatList, StyleSheet, View} from 'react-native' 3 - import {AppBskyActorDefs} from '@atproto/api' 3 + import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 5 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' 6 6 import {msg, Trans} from '@lingui/macro' ··· 25 25 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 26 26 import {cleanError} from 'lib/strings/errors' 27 27 import {s} from 'lib/styles' 28 - import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 29 28 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 30 29 import {FAB} from 'view/com/util/fab/FAB' 31 30 import {SearchInput} from 'view/com/util/forms/SearchInput' ··· 46 45 import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' 47 46 import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' 48 47 import hairlineWidth = StyleSheet.hairlineWidth 48 + import {Divider} from '#/components/Divider' 49 + import * as FeedCard from '#/components/FeedCard' 49 50 50 51 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> 51 52 ··· 94 95 type: 'popularFeed' 95 96 key: string 96 97 feedUri: string 98 + feed: AppBskyFeedDefs.GeneratorView 97 99 } 98 100 | { 99 101 type: 'popularFeedsLoadingMore' ··· 103 105 type: 'noFollowingFeed' 104 106 key: string 105 107 } 106 - 107 - // HACK 108 - // the protocol doesn't yet tell us which feeds are personalized 109 - // this list is used to filter out feed recommendations from logged out users 110 - // for the ones we know need it 111 - // -prf 112 - const KNOWN_AUTHED_ONLY_FEEDS = [ 113 - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', // popular with friends, by bsky.app 114 - 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals', // mutuals, by skyfeed 115 - 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts', // only posts, by skyfeed 116 - 'at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions', // mentions, by flicknow 117 - 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers', // my bangers, by jaz 118 - 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals', // mutuals, by bluesky 119 - 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/my-followers', // followers, by jaz 120 - 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why 121 - ] 122 108 123 109 export function FeedsScreen(_props: Props) { 124 110 const pal = usePalette('default') ··· 316 302 key: `popularFeed:${feed.uri}`, 317 303 type: 'popularFeed', 318 304 feedUri: feed.uri, 305 + feed, 319 306 })), 320 307 ) 321 308 } ··· 327 314 type: 'popularFeedsLoading', 328 315 }) 329 316 } else { 330 - if ( 331 - !popularFeeds?.pages || 332 - popularFeeds?.pages[0]?.feeds?.length === 0 333 - ) { 317 + if (!popularFeeds?.pages) { 334 318 slices.push({ 335 319 key: 'popularFeedsNoResults', 336 320 type: 'popularFeedsNoResults', ··· 338 322 } else { 339 323 for (const page of popularFeeds.pages || []) { 340 324 slices = slices.concat( 341 - page.feeds 342 - .filter(feed => { 343 - if ( 344 - !hasSession && 345 - KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri) 346 - ) { 347 - return false 348 - } 349 - const alreadySaved = Boolean( 350 - preferences?.savedFeeds?.find(f => { 351 - return f.value === feed.uri 352 - }), 353 - ) 354 - return !alreadySaved 355 - }) 356 - .map(feed => ({ 357 - key: `popularFeed:${feed.uri}`, 358 - type: 'popularFeed', 359 - feedUri: feed.uri, 360 - })), 325 + page.feeds.map(feed => ({ 326 + key: `popularFeed:${feed.uri}`, 327 + type: 'popularFeed', 328 + feedUri: feed.uri, 329 + feed, 330 + })), 361 331 ) 362 332 } 363 333 ··· 495 465 return ( 496 466 <> 497 467 <FeedsAboutHeader /> 498 - <View style={{paddingHorizontal: 12, paddingBottom: 12}}> 468 + <View style={{paddingHorizontal: 12, paddingBottom: 4}}> 499 469 <SearchInput 500 470 query={query} 501 471 onChangeQuery={onChangeQuery} ··· 510 480 return <FeedFeedLoadingPlaceholder /> 511 481 } else if (item.type === 'popularFeed') { 512 482 return ( 513 - <FeedSourceCard 514 - feedUri={item.feedUri} 515 - showSaveBtn={hasSession} 516 - showDescription 517 - showLikes 518 - pinOnSave 519 - /> 483 + <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> 484 + <FeedCard.Default feed={item.feed} /> 485 + <Divider /> 486 + </View> 520 487 ) 521 488 } else if (item.type === 'popularFeedsNoResults') { 522 489 return ( ··· 559 526 onPressCancelSearch, 560 527 onSubmitQuery, 561 528 onChangeSearchFocus, 562 - hasSession, 563 529 ], 564 530 ) 565 531
+556
src/view/screens/Search/Explore.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + AppBskyActorDefs, 5 + AppBskyFeedDefs, 6 + moderateProfile, 7 + ModerationDecision, 8 + ModerationOpts, 9 + } from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {logger} from '#/logger' 14 + import {isWeb} from '#/platform/detection' 15 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 + import {useGetPopularFeedsQuery} from '#/state/queries/feed' 17 + import {usePreferencesQuery} from '#/state/queries/preferences' 18 + import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 19 + import {useSession} from '#/state/session' 20 + import {cleanError} from 'lib/strings/errors' 21 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 22 + import {List} from '#/view/com/util/List' 23 + import {UserAvatar} from '#/view/com/util/UserAvatar' 24 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 25 + import { 26 + FeedFeedLoadingPlaceholder, 27 + ProfileCardFeedLoadingPlaceholder, 28 + } from 'view/com/util/LoadingPlaceholder' 29 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 30 + import {Button} from '#/components/Button' 31 + import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' 32 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 33 + import {Props as SVGIconProps} from '#/components/icons/common' 34 + import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 35 + import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 36 + import {Loader} from '#/components/Loader' 37 + import {Text} from '#/components/Typography' 38 + 39 + function SuggestedItemsHeader({ 40 + title, 41 + description, 42 + style, 43 + icon: Icon, 44 + }: { 45 + title: string 46 + description: string 47 + icon: React.ComponentType<SVGIconProps> 48 + } & ViewStyleProp) { 49 + const t = useTheme() 50 + 51 + return ( 52 + <View 53 + style={[ 54 + isWeb 55 + ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 56 + : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], 57 + a.border_b, 58 + t.atoms.border_contrast_low, 59 + style, 60 + ]}> 61 + <View style={[a.flex_1, a.gap_sm]}> 62 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 63 + <Icon 64 + size="lg" 65 + fill={t.palette.primary_500} 66 + style={{marginLeft: -2}} 67 + /> 68 + <Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text> 69 + </View> 70 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 71 + {description} 72 + </Text> 73 + </View> 74 + </View> 75 + ) 76 + } 77 + 78 + type LoadMoreItems = 79 + | { 80 + type: 'profile' 81 + key: string 82 + avatar: string 83 + moderation: ModerationDecision 84 + } 85 + | { 86 + type: 'feed' 87 + key: string 88 + avatar: string 89 + moderation: undefined 90 + } 91 + 92 + function LoadMore({ 93 + item, 94 + moderationOpts, 95 + }: { 96 + item: ExploreScreenItems & {type: 'loadMore'} 97 + moderationOpts?: ModerationOpts 98 + }) { 99 + const t = useTheme() 100 + const {_} = useLingui() 101 + const items = React.useMemo(() => { 102 + return item.items 103 + .map(_item => { 104 + if (_item.type === 'profile') { 105 + return { 106 + type: 'profile', 107 + key: _item.profile.did, 108 + avatar: _item.profile.avatar, 109 + moderation: moderateProfile(_item.profile, moderationOpts!), 110 + } 111 + } else if (_item.type === 'feed') { 112 + return { 113 + type: 'feed', 114 + key: _item.feed.uri, 115 + avatar: _item.feed.avatar, 116 + moderation: undefined, 117 + } 118 + } 119 + return undefined 120 + }) 121 + .filter(Boolean) as LoadMoreItems[] 122 + }, [item.items, moderationOpts]) 123 + const type = items[0].type 124 + 125 + return ( 126 + <View style={[]}> 127 + <Button 128 + label={_(msg`Load more`)} 129 + onPress={item.onLoadMore} 130 + style={[a.relative, a.w_full]}> 131 + {({hovered, pressed}) => ( 132 + <View 133 + style={[ 134 + a.flex_1, 135 + a.flex_row, 136 + a.align_center, 137 + a.px_lg, 138 + a.py_md, 139 + (hovered || pressed) && t.atoms.bg_contrast_25, 140 + ]}> 141 + <View 142 + style={[ 143 + a.relative, 144 + { 145 + height: 32, 146 + width: 32 + 15 * 3, 147 + }, 148 + ]}> 149 + <View 150 + style={[ 151 + a.align_center, 152 + a.justify_center, 153 + a.border, 154 + t.atoms.bg_contrast_25, 155 + a.absolute, 156 + { 157 + width: 30, 158 + height: 30, 159 + left: 0, 160 + backgroundColor: t.palette.primary_500, 161 + borderColor: t.atoms.bg.backgroundColor, 162 + borderRadius: type === 'profile' ? 999 : 4, 163 + zIndex: 4, 164 + }, 165 + ]}> 166 + <ArrowBottom fill={t.palette.white} /> 167 + </View> 168 + {items.map((_item, i) => { 169 + return ( 170 + <View 171 + key={_item.key} 172 + style={[ 173 + a.border, 174 + t.atoms.bg_contrast_25, 175 + a.absolute, 176 + { 177 + width: 30, 178 + height: 30, 179 + left: (i + 1) * 15, 180 + borderColor: t.atoms.bg.backgroundColor, 181 + borderRadius: _item.type === 'profile' ? 999 : 4, 182 + zIndex: 3 - i, 183 + }, 184 + ]}> 185 + {moderationOpts && ( 186 + <> 187 + {_item.type === 'profile' ? ( 188 + <UserAvatar 189 + size={28} 190 + avatar={_item.avatar} 191 + moderation={_item.moderation.ui('avatar')} 192 + /> 193 + ) : _item.type === 'feed' ? ( 194 + <UserAvatar 195 + size={28} 196 + avatar={_item.avatar} 197 + type="algo" 198 + /> 199 + ) : null} 200 + </> 201 + )} 202 + </View> 203 + ) 204 + })} 205 + </View> 206 + 207 + <Text 208 + style={[ 209 + a.pl_sm, 210 + a.leading_snug, 211 + hovered ? t.atoms.text : t.atoms.text_contrast_medium, 212 + ]}> 213 + {type === 'profile' ? ( 214 + <Trans>Load more suggested follows</Trans> 215 + ) : ( 216 + <Trans>Load more suggested feeds</Trans> 217 + )} 218 + </Text> 219 + 220 + <View style={[a.flex_1, a.align_end]}> 221 + {item.isLoadingMore && <Loader size="lg" />} 222 + </View> 223 + </View> 224 + )} 225 + </Button> 226 + </View> 227 + ) 228 + } 229 + 230 + type ExploreScreenItems = 231 + | { 232 + type: 'header' 233 + key: string 234 + title: string 235 + description: string 236 + style?: ViewStyleProp['style'] 237 + icon: React.ComponentType<SVGIconProps> 238 + } 239 + | { 240 + type: 'profile' 241 + key: string 242 + profile: AppBskyActorDefs.ProfileViewBasic 243 + } 244 + | { 245 + type: 'feed' 246 + key: string 247 + feed: AppBskyFeedDefs.GeneratorView 248 + } 249 + | { 250 + type: 'loadMore' 251 + key: string 252 + isLoadingMore: boolean 253 + onLoadMore: () => void 254 + items: ExploreScreenItems[] 255 + } 256 + | { 257 + type: 'profilePlaceholder' 258 + key: string 259 + } 260 + | { 261 + type: 'feedPlaceholder' 262 + key: string 263 + } 264 + | { 265 + type: 'error' 266 + key: string 267 + message: string 268 + error: string 269 + } 270 + 271 + export function Explore() { 272 + const {_} = useLingui() 273 + const t = useTheme() 274 + const {hasSession} = useSession() 275 + const {data: preferences, error: preferencesError} = usePreferencesQuery() 276 + const moderationOpts = useModerationOpts() 277 + const { 278 + data: profiles, 279 + hasNextPage: hasNextProfilesPage, 280 + isLoading: isLoadingProfiles, 281 + isFetchingNextPage: isFetchingNextProfilesPage, 282 + error: profilesError, 283 + fetchNextPage: fetchNextProfilesPage, 284 + } = useSuggestedFollowsQuery({limit: 3}) 285 + const { 286 + data: feeds, 287 + hasNextPage: hasNextFeedsPage, 288 + isLoading: isLoadingFeeds, 289 + isFetchingNextPage: isFetchingNextFeedsPage, 290 + error: feedsError, 291 + fetchNextPage: fetchNextFeedsPage, 292 + } = useGetPopularFeedsQuery({limit: 3}) 293 + 294 + const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles 295 + const onLoadMoreProfiles = React.useCallback(async () => { 296 + if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) 297 + return 298 + try { 299 + await fetchNextProfilesPage() 300 + } catch (err) { 301 + logger.error('Failed to load more suggested follows', {message: err}) 302 + } 303 + }, [ 304 + isFetchingNextProfilesPage, 305 + hasNextProfilesPage, 306 + profilesError, 307 + fetchNextProfilesPage, 308 + ]) 309 + 310 + const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds 311 + const onLoadMoreFeeds = React.useCallback(async () => { 312 + if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return 313 + try { 314 + await fetchNextFeedsPage() 315 + } catch (err) { 316 + logger.error('Failed to load more suggested follows', {message: err}) 317 + } 318 + }, [ 319 + isFetchingNextFeedsPage, 320 + hasNextFeedsPage, 321 + feedsError, 322 + fetchNextFeedsPage, 323 + ]) 324 + 325 + const items = React.useMemo<ExploreScreenItems[]>(() => { 326 + const i: ExploreScreenItems[] = [ 327 + { 328 + type: 'header', 329 + key: 'suggested-follows-header', 330 + title: _(msg`Suggested accounts`), 331 + description: _( 332 + msg`Follow more accounts to get connected to your interests and build your network.`, 333 + ), 334 + icon: Person, 335 + }, 336 + ] 337 + 338 + if (profiles) { 339 + // Currently the responses contain duplicate items. 340 + // Needs to be fixed on backend, but let's dedupe to be safe. 341 + let seen = new Set() 342 + for (const page of profiles.pages) { 343 + for (const actor of page.actors) { 344 + if (!seen.has(actor.did)) { 345 + seen.add(actor.did) 346 + i.push({ 347 + type: 'profile', 348 + key: actor.did, 349 + profile: actor, 350 + }) 351 + } 352 + } 353 + } 354 + 355 + i.push({ 356 + type: 'loadMore', 357 + key: 'loadMoreProfiles', 358 + isLoadingMore: isLoadingMoreProfiles, 359 + onLoadMore: onLoadMoreProfiles, 360 + items: i.filter(item => item.type === 'profile').slice(-3), 361 + }) 362 + } else { 363 + if (profilesError) { 364 + i.push({ 365 + type: 'error', 366 + key: 'profilesError', 367 + message: _(msg`Failed to load suggested follows`), 368 + error: cleanError(profilesError), 369 + }) 370 + } else { 371 + i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'}) 372 + } 373 + } 374 + 375 + i.push({ 376 + type: 'header', 377 + key: 'suggested-feeds-header', 378 + title: _(msg`Discover new feeds`), 379 + description: _( 380 + msg`Custom feeds built by the community bring you new experiences and help you find the content you love.`, 381 + ), 382 + style: [a.pt_5xl], 383 + icon: ListSparkle, 384 + }) 385 + 386 + if (feeds && preferences) { 387 + // Currently the responses contain duplicate items. 388 + // Needs to be fixed on backend, but let's dedupe to be safe. 389 + let seen = new Set() 390 + for (const page of feeds.pages) { 391 + for (const feed of page.feeds) { 392 + if (!seen.has(feed.uri)) { 393 + seen.add(feed.uri) 394 + i.push({ 395 + type: 'feed', 396 + key: feed.uri, 397 + feed, 398 + }) 399 + } 400 + } 401 + } 402 + 403 + if (feedsError) { 404 + i.push({ 405 + type: 'error', 406 + key: 'feedsError', 407 + message: _(msg`Failed to load suggested feeds`), 408 + error: cleanError(feedsError), 409 + }) 410 + } else if (preferencesError) { 411 + i.push({ 412 + type: 'error', 413 + key: 'preferencesError', 414 + message: _(msg`Failed to load feeds preferences`), 415 + error: cleanError(preferencesError), 416 + }) 417 + } else { 418 + i.push({ 419 + type: 'loadMore', 420 + key: 'loadMoreFeeds', 421 + isLoadingMore: isLoadingMoreFeeds, 422 + onLoadMore: onLoadMoreFeeds, 423 + items: i.filter(item => item.type === 'feed').slice(-3), 424 + }) 425 + } 426 + } else { 427 + if (feedsError) { 428 + i.push({ 429 + type: 'error', 430 + key: 'feedsError', 431 + message: _(msg`Failed to load suggested feeds`), 432 + error: cleanError(feedsError), 433 + }) 434 + } else if (preferencesError) { 435 + i.push({ 436 + type: 'error', 437 + key: 'preferencesError', 438 + message: _(msg`Failed to load feeds preferences`), 439 + error: cleanError(preferencesError), 440 + }) 441 + } else { 442 + i.push({type: 'feedPlaceholder', key: 'feedPlaceholder'}) 443 + } 444 + } 445 + 446 + return i 447 + }, [ 448 + _, 449 + profiles, 450 + feeds, 451 + preferences, 452 + onLoadMoreFeeds, 453 + onLoadMoreProfiles, 454 + isLoadingMoreProfiles, 455 + isLoadingMoreFeeds, 456 + profilesError, 457 + feedsError, 458 + preferencesError, 459 + ]) 460 + 461 + const renderItem = React.useCallback( 462 + ({item}: {item: ExploreScreenItems}) => { 463 + switch (item.type) { 464 + case 'header': { 465 + return ( 466 + <SuggestedItemsHeader 467 + title={item.title} 468 + description={item.description} 469 + style={item.style} 470 + icon={item.icon} 471 + /> 472 + ) 473 + } 474 + case 'profile': { 475 + return ( 476 + <View style={[a.border_b, t.atoms.border_contrast_low]}> 477 + <ProfileCardWithFollowBtn profile={item.profile} noBg noBorder /> 478 + </View> 479 + ) 480 + } 481 + case 'feed': { 482 + return ( 483 + <View style={[a.border_b, t.atoms.border_contrast_low]}> 484 + <FeedSourceCard 485 + feedUri={item.feed.uri} 486 + showSaveBtn={hasSession} 487 + showDescription 488 + showLikes 489 + pinOnSave 490 + hideTopBorder 491 + /> 492 + </View> 493 + ) 494 + } 495 + case 'loadMore': { 496 + return <LoadMore item={item} moderationOpts={moderationOpts} /> 497 + } 498 + case 'profilePlaceholder': { 499 + return <ProfileCardFeedLoadingPlaceholder /> 500 + } 501 + case 'feedPlaceholder': { 502 + return <FeedFeedLoadingPlaceholder /> 503 + } 504 + case 'error': { 505 + return ( 506 + <View 507 + style={[ 508 + a.border_t, 509 + a.pt_md, 510 + a.px_md, 511 + t.atoms.border_contrast_low, 512 + ]}> 513 + <View 514 + style={[ 515 + a.flex_row, 516 + a.gap_md, 517 + a.p_lg, 518 + a.rounded_sm, 519 + t.atoms.bg_contrast_25, 520 + ]}> 521 + <CircleInfo size="md" fill={t.palette.negative_400} /> 522 + <View style={[a.flex_1, a.gap_sm]}> 523 + <Text style={[a.font_bold, a.leading_snug]}> 524 + {item.message} 525 + </Text> 526 + <Text 527 + style={[ 528 + a.italic, 529 + a.leading_snug, 530 + t.atoms.text_contrast_medium, 531 + ]}> 532 + {item.error} 533 + </Text> 534 + </View> 535 + </View> 536 + </View> 537 + ) 538 + } 539 + } 540 + }, 541 + [t, hasSession, moderationOpts], 542 + ) 543 + 544 + return ( 545 + <List 546 + data={items} 547 + renderItem={renderItem} 548 + keyExtractor={item => item.key} 549 + // @ts-ignore web only -prf 550 + desktopFixedHeight 551 + contentContainerStyle={{paddingBottom: 200}} 552 + keyboardShouldPersistTaps="handled" 553 + keyboardDismissMode="on-drag" 554 + /> 555 + ) 556 + }
+54 -87
src/view/screens/Search/Search.tsx
··· 29 29 import {makeProfileLink} from '#/lib/routes/links' 30 30 import {NavigationProp} from '#/lib/routes/types' 31 31 import {augmentSearchQuery} from '#/lib/strings/helpers' 32 - import {s} from '#/lib/styles' 33 32 import {logger} from '#/logger' 34 33 import {isIOS, isNative, isWeb} from '#/platform/detection' 35 34 import {listenSoftReset} from '#/state/events' 36 35 import {useModerationOpts} from '#/state/preferences/moderation-opts' 37 36 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 38 37 import {useActorSearch} from '#/state/queries/actor-search' 38 + import {usePopularFeedsSearch} from '#/state/queries/feed' 39 39 import {useSearchPostsQuery} from '#/state/queries/search-posts' 40 - import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' 41 40 import {useSession} from '#/state/session' 42 41 import {useSetDrawerOpen} from '#/state/shell' 43 42 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' ··· 56 55 import {List} from '#/view/com/util/List' 57 56 import {Text} from '#/view/com/util/text/Text' 58 57 import {CenteredView, ScrollView} from '#/view/com/util/Views' 58 + import {Explore} from '#/view/screens/Search/Explore' 59 59 import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 60 - import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' 60 + import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' 61 61 import {atoms as a} from '#/alf' 62 62 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' 63 63 ··· 121 121 </CenteredView> 122 122 ) 123 123 } 124 - 125 - function useSuggestedFollows(): [ 126 - AppBskyActorDefs.ProfileViewBasic[], 127 - () => void, 128 - ] { 129 - const { 130 - data: suggestions, 131 - hasNextPage, 132 - isFetchingNextPage, 133 - isError, 134 - fetchNextPage, 135 - } = useSuggestedFollowsQuery() 136 - 137 - const onEndReached = React.useCallback(async () => { 138 - if (isFetchingNextPage || !hasNextPage || isError) return 139 - try { 140 - await fetchNextPage() 141 - } catch (err) { 142 - logger.error('Failed to load more suggested follows', {message: err}) 143 - } 144 - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 145 - 146 - const items: AppBskyActorDefs.ProfileViewBasic[] = [] 147 - if (suggestions) { 148 - // Currently the responses contain duplicate items. 149 - // Needs to be fixed on backend, but let's dedupe to be safe. 150 - let seen = new Set() 151 - for (const page of suggestions.pages) { 152 - for (const actor of page.actors) { 153 - if (!seen.has(actor.did)) { 154 - seen.add(actor.did) 155 - items.push(actor) 156 - } 157 - } 158 - } 159 - } 160 - return [items, onEndReached] 161 - } 162 - 163 - let SearchScreenSuggestedFollows = (_props: {}): React.ReactNode => { 164 - const pal = usePalette('default') 165 - const [suggestions, onEndReached] = useSuggestedFollows() 166 - 167 - return suggestions.length ? ( 168 - <List 169 - data={suggestions} 170 - renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />} 171 - keyExtractor={item => item.did} 172 - // @ts-ignore web only -prf 173 - desktopFixedHeight 174 - contentContainerStyle={{paddingBottom: 200}} 175 - keyboardShouldPersistTaps="handled" 176 - keyboardDismissMode="on-drag" 177 - onEndReached={onEndReached} 178 - onEndReachedThreshold={2} 179 - /> 180 - ) : ( 181 - <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> 182 - <ProfileCardFeedLoadingPlaceholder /> 183 - <ProfileCardFeedLoadingPlaceholder /> 184 - </CenteredView> 185 - ) 186 - } 187 - SearchScreenSuggestedFollows = React.memo(SearchScreenSuggestedFollows) 188 124 189 125 type SearchResultSlice = 190 126 | { ··· 342 278 } 343 279 SearchScreenUserResults = React.memo(SearchScreenUserResults) 344 280 281 + let SearchScreenFeedsResults = ({ 282 + query, 283 + active, 284 + }: { 285 + query: string 286 + active: boolean 287 + }): React.ReactNode => { 288 + const {_} = useLingui() 289 + const {hasSession} = useSession() 290 + 291 + const {data: results, isFetched} = usePopularFeedsSearch({ 292 + query, 293 + enabled: active, 294 + }) 295 + 296 + return isFetched && results ? ( 297 + <> 298 + {results.length ? ( 299 + <List 300 + data={results} 301 + renderItem={({item}) => ( 302 + <FeedSourceCard 303 + feedUri={item.uri} 304 + showSaveBtn={hasSession} 305 + showDescription 306 + showLikes 307 + pinOnSave 308 + /> 309 + )} 310 + keyExtractor={item => item.uri} 311 + // @ts-ignore web only -prf 312 + desktopFixedHeight 313 + contentContainerStyle={{paddingBottom: 100}} 314 + /> 315 + ) : ( 316 + <EmptyState message={_(msg`No results found for ${query}`)} /> 317 + )} 318 + </> 319 + ) : ( 320 + <Loader /> 321 + ) 322 + } 323 + SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) 324 + 345 325 let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { 346 326 const pal = usePalette('default') 347 327 const setMinimalShellMode = useSetMinimalShellMode() ··· 389 369 <SearchScreenUserResults query={query} active={activeTab === 2} /> 390 370 ), 391 371 }, 372 + { 373 + title: _(msg`Feeds`), 374 + component: ( 375 + <SearchScreenFeedsResults query={query} active={activeTab === 3} /> 376 + ), 377 + }, 392 378 ] 393 379 }, [_, query, activeTab]) 394 380 ··· 408 394 ))} 409 395 </Pager> 410 396 ) : hasSession ? ( 411 - <View> 412 - <CenteredView sideBorders style={pal.border}> 413 - <Text 414 - type="title" 415 - style={[ 416 - pal.text, 417 - pal.border, 418 - { 419 - display: 'flex', 420 - paddingVertical: 12, 421 - paddingHorizontal: 18, 422 - fontWeight: 'bold', 423 - }, 424 - ]}> 425 - <Trans>Suggested Follows</Trans> 426 - </Text> 427 - </CenteredView> 428 - 429 - <SearchScreenSuggestedFollows /> 430 - </View> 397 + <Explore /> 431 398 ) : ( 432 399 <CenteredView sideBorders style={pal.border}> 433 400 <View