diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 0ed7201180..c3160df3bc 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -105,3 +105,19 @@ jest.mock('expo-modules-core', () => ({ return () => null }), })) + +jest.mock('expo-localization', () => ({ + getLocales: () => [], +})) + +jest.mock('statsig-react-native-expo', () => ({ + Statsig: { + initialize() {}, + initializeCalled() { + return false + }, + }, +})) + +jest.mock('../src/lib/bitdrift', () => ({})) +jest.mock('../src/lib/statsig/statsig', () => ({})) diff --git a/package.json b/package.json index 2891923b6a..ff2223b489 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "emoji-mart": "^5.5.2", "emoji-regex": "^10.4.0", "eventemitter3": "^5.0.1", - "expo": "~52.0.17", + "expo": "~52.0.19", "expo-application": "^6.0.1", "expo-blur": "^14.0.1", "expo-build-properties": "^0.13.1", @@ -128,7 +128,7 @@ "expo-clipboard": "^7.0.0", "expo-dev-client": "^5.0.4", "expo-device": "~7.0.1", - "expo-file-system": "^18.0.4", + "expo-file-system": "^18.0.6", "expo-font": "~13.0.1", "expo-haptics": "^14.0.0", "expo-image": "~2.0.3", @@ -141,7 +141,7 @@ "expo-navigation-bar": "~4.0.4", "expo-notifications": "~0.29.11", "expo-sharing": "^13.0.0", - "expo-splash-screen": "~0.29.16", + "expo-splash-screen": "~0.29.18", "expo-status-bar": "~2.0.0", "expo-system-ui": "^4.0.4", "expo-task-manager": "~12.0.3", @@ -213,7 +213,6 @@ "@babel/runtime": "^7.26.0", "@did-plc/server": "^0.0.1", "@expo/config-plugins": "9.0.10", - "@expo/prebuild-config": "8.0.22", "@lingui/cli": "^4.14.1", "@lingui/macro": "^4.14.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", diff --git a/patches/expo-modules-core+2.1.1.patch b/patches/expo-modules-core+2.1.1.patch deleted file mode 100644 index 5695b8f44a..0000000000 --- a/patches/expo-modules-core+2.1.1.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt -index 47c4d15..afe138d 100644 ---- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt -+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt -@@ -125,6 +125,10 @@ internal fun peekResponseBody( - } - - internal fun shouldParseBody(response: Response): Boolean { -+ if (response.request.url.encodedPath == "/bitdrift_public.protobuf.client.v1.ApiService/Mux") { -+ return false -+ } -+ - // Check for Content-Type - val skipContentTypes = listOf( - "text/event-stream", // Server Sent Events -diff --git a/node_modules/expo-modules-core/src/uuid/uuid.ts b/node_modules/expo-modules-core/src/uuid/uuid.ts -index 148beac..fabdff5 100644 ---- a/node_modules/expo-modules-core/src/uuid/uuid.ts -+++ b/node_modules/expo-modules-core/src/uuid/uuid.ts -@@ -5,6 +5,7 @@ const nativeUuidv4 = globalThis?.expo?.uuidv4; - const nativeUuidv5 = globalThis?.expo?.uuidv5; - - function uuidv4(): string { -+ const nativeUuidv4 = globalThis?.expo?.uuidv4; - if (!nativeUuidv4) { - throw Error( - "Native UUID version 4 generator implementation wasn't found in `expo-modules-core`" -@@ -23,6 +24,7 @@ function uuidv5(name: string, namespace: string | number[]) { - throw new Error('`namespace` must be a valid UUID string or an Array of 16 byte values'); - } - -+ const nativeUuidv5 = globalThis?.expo?.uuidv5; - if (!nativeUuidv5) { - throw Error("Native UUID type 5 generator implementation wasn't found in `expo-modules-core`"); - } diff --git a/patches/expo-modules-core+2.1.1.md b/patches/expo-modules-core+2.1.2.md similarity index 100% rename from patches/expo-modules-core+2.1.1.md rename to patches/expo-modules-core+2.1.2.md diff --git a/patches/expo-modules-core+2.1.2.patch b/patches/expo-modules-core+2.1.2.patch new file mode 100644 index 0000000000..f3d9bfd149 --- /dev/null +++ b/patches/expo-modules-core+2.1.2.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt +index 47c4d15..afe138d 100644 +--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt ++++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt +@@ -125,6 +125,10 @@ internal fun peekResponseBody( + } + + internal fun shouldParseBody(response: Response): Boolean { ++ if (response.request.url.encodedPath == "/bitdrift_public.protobuf.client.v1.ApiService/Mux") { ++ return false ++ } ++ + // Check for Content-Type + val skipContentTypes = listOf( + "text/event-stream", // Server Sent Events diff --git a/patches/react-native+0.76.3.patch b/patches/react-native+0.76.3.patch index 6f71097a18..5af24a372b 100644 --- a/patches/react-native+0.76.3.patch +++ b/patches/react-native+0.76.3.patch @@ -222,7 +222,7 @@ index 40aaf9c..1c60164 100644 { [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary *viewRegistry) { diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m -index e9ce48c..ccd9ad6 100644 +index e9ce48c..84a6fca 100644 --- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m @@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view @@ -329,3 +329,38 @@ index e9ce48c..ccd9ad6 100644 } } +@@ -1055,6 +1082,22 @@ -(type)getter \ + RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL) + RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat); + ++- (void)setScrollIndicatorInsets:(UIEdgeInsets)value ++{ ++ [_scrollView setScrollIndicatorInsets:value]; ++} ++ ++- (UIEdgeInsets)scrollIndicatorInsets ++{ ++ UIEdgeInsets verticalScrollIndicatorInsets = [_scrollView verticalScrollIndicatorInsets]; ++ UIEdgeInsets horizontalScrollIndicatorInsets = [_scrollView horizontalScrollIndicatorInsets]; ++ return UIEdgeInsetsMake( ++ verticalScrollIndicatorInsets.top, ++ horizontalScrollIndicatorInsets.left, ++ verticalScrollIndicatorInsets.bottom, ++ horizontalScrollIndicatorInsets.right); ++} ++ + - (void)setAutomaticallyAdjustsScrollIndicatorInsets:(BOOL)automaticallyAdjusts API_AVAILABLE(ios(13.0)) + { + // `automaticallyAdjustsScrollIndicatorInsets` is available since iOS 13. +diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m +index cd1e7eb..c1d0172 100644 +--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m ++++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m +@@ -83,6 +83,7 @@ - (UIView *)view + RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval) + RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat) + RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets) ++RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets) + RCT_EXPORT_VIEW_PROPERTY(verticalScrollIndicatorInsets, UIEdgeInsets) + RCT_EXPORT_VIEW_PROPERTY(scrollToOverflowEnabled, BOOL) + RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int) diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index ad4929ec85..df2b29d8ac 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -302,6 +302,18 @@ export const atoms = { border_0: { borderWidth: 0, }, + border_t_0: { + borderTopWidth: 0, + }, + border_b_0: { + borderBottomWidth: 0, + }, + border_l_0: { + borderLeftWidth: 0, + }, + border_r_0: { + borderRightWidth: 0, + }, border: { borderWidth: StyleSheet.hairlineWidth, }, diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 85ed58280d..ec224eeae0 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -280,8 +280,8 @@ export function ProfileGrid({ profile={profile} moderationOpts={moderationOpts} logContext="FeedInterstitial" - color="secondary_inverted" shape="round" + colorInverted /> diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 668bd0f3c7..7bec14b9cc 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -285,6 +285,7 @@ export type FollowButtonProps = { moderationOpts: ModerationOpts logContext: LogEvents['profile:follow']['logContext'] & LogEvents['profile:unfollow']['logContext'] + colorInverted?: boolean } & Partial export function FollowButton(props: FollowButtonProps) { @@ -297,6 +298,8 @@ export function FollowButtonInner({ profile: profileUnshadowed, moderationOpts, logContext, + onPress: onPressProp, + colorInverted, ...rest }: FollowButtonProps) { const {_} = useLingui() @@ -321,6 +324,7 @@ export function FollowButtonInner({ )}`, ), ) + onPressProp?.(e) } catch (err: any) { if (err?.name !== 'AbortError') { Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') @@ -341,6 +345,7 @@ export function FollowButtonInner({ )}`, ), ) + onPressProp?.(e) } catch (err: any) { if (err?.name !== 'AbortError') { Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') @@ -387,7 +392,7 @@ export function FollowButtonInner({ label={followLabel} size="small" variant="solid" - color="primary" + color={colorInverted ? 'secondary_inverted' : 'primary'} {...rest} onPress={onPressFollow}> diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx new file mode 100644 index 0000000000..6ac3200df1 --- /dev/null +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -0,0 +1,829 @@ +import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native' +import Animated, { + LayoutAnimationConfig, + LinearTransition, + ZoomInEasyDown, +} from 'react-native-reanimated' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {useSession} from '#/state/session' +import {Follow10ProgressGuide} from '#/state/shell/progress-guide' +import {ListMethods} from '#/view/com/util/List' +import { + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import { + atoms as a, + native, + tokens, + useBreakpoints, + useTheme, + ViewStyleProp, + web, +} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' +import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import {ListFooter} from '../Lists' +import {ProgressGuideTask} from './Task' + +type Item = + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileView + isSuggestion: boolean + } + | { + type: 'empty' + key: string + message: string + } + | { + type: 'placeholder' + key: string + } + | { + type: 'error' + key: string + } + +export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const control = Dialog.useDialogControl() + const {gtMobile} = useBreakpoints() + const {height: minHeight} = useWindowDimensions() + + return ( + <> + + + + + + + ) +} + +// Fine to keep this top-level. +let lastSelectedInterest = '' +let lastSearchText = '' + +function DialogInner({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const {data: preferences} = usePreferencesQuery() + const personalizedInterests = preferences?.interests?.tags + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)) + const [selectedInterest, setSelectedInterest] = useState( + () => + lastSelectedInterest || + (personalizedInterests && interests.includes(personalizedInterests[0]) + ? personalizedInterests[0] + : interests[0]), + ) + const [searchText, setSearchText] = useState(lastSearchText) + const moderationOpts = useModerationOpts() + const listRef = useRef(null) + const inputRef = useRef(null) + const [headerHeight, setHeaderHeight] = useState(0) + const {currentAccount} = useSession() + const [suggestedAccounts, setSuggestedAccounts] = useState< + Map + >(() => new Map()) + + useEffect(() => { + lastSearchText = searchText + lastSelectedInterest = selectedInterest + }, [searchText, selectedInterest]) + + const query = searchText || selectedInterest + const { + data: searchResults, + isFetching, + error, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useActorSearchPaginated({ + query, + }) + + const hasSearchText = !!searchText + + const items = useMemo(() => { + const results = searchResults?.pages.flatMap(r => r.actors) + let _items: Item[] = [] + const seen = new Set() + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: _(msg`We're having network issues, try again`), + }) + } else if (results) { + // First pass: search results + for (const profile of results) { + if (profile.did === currentAccount?.did) continue + if (profile.viewer?.following) continue + // my sincere apologies to Jake Gold - your bio is too keyword-filled and + // your page-rank too high, so you're at the top of half the categories -sfn + if ( + !hasSearchText && + profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' && + // constrain to 'tech' + selectedInterest !== 'tech' + ) { + continue + } + seen.add(profile.did) + _items.push({ + type: 'profile', + // Don't share identity across tabs or typing attempts + key: query + ':' + profile.did, + profile, + isSuggestion: false, + }) + } + // Second pass: suggestions + _items = _items.flatMap(item => { + if (item.type !== 'profile') { + return item + } + const suggestions = suggestedAccounts.get(item.profile.did) + if (!suggestions) { + return item + } + const itemWithSuggestions = [item] + for (const suggested of suggestions) { + if (seen.has(suggested.did)) { + // Skip search results from previous step or already seen suggestions + continue + } + seen.add(suggested.did) + itemWithSuggestions.push({ + type: 'profile', + key: suggested.did, + profile: suggested, + isSuggestion: true, + }) + if (itemWithSuggestions.length === 1 + 3) { + break + } + } + return itemWithSuggestions + }) + } else { + const placeholders: Item[] = Array(10) + .fill(0) + .map((__, i) => ({ + type: 'placeholder', + key: i + '', + })) + + _items.push(...placeholders) + } + + return _items + }, [ + _, + searchResults, + isError, + currentAccount?.did, + hasSearchText, + selectedInterest, + suggestedAccounts, + query, + ]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = useCallback( + ({item, index}: {item: Item; index: number}) => { + switch (item.type) { + case 'profile': { + return ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts], + ) + + const onSelectTab = useCallback( + (interest: string) => { + setSelectedInterest(interest) + inputRef.current?.clear() + setSearchText('') + listRef.current?.scrollToOffset({ + offset: 0, + animated: false, + }) + }, + [setSelectedInterest, setSearchText], + ) + + const listHeader = ( +
+ ) + + const onEndReached = useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more people to follow', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + return ( + item.key} + style={[ + a.px_0, + web([a.py_0, {height: '100vh', maxHeight: 600}]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={a.py_0} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + scrollIndicatorInsets={{top: headerHeight}} + initialNumToRender={8} + maxToRenderPerBatch={8} + onEndReached={onEndReached} + itemLayoutAnimation={LinearTransition} + ListFooterComponent={ + + } + /> + ) +} + +let Header = ({ + guide, + inputRef, + listRef, + searchText, + onSelectTab, + setHeaderHeight, + setSearchText, + interests, + selectedInterest, + interestsDisplayNames, +}: { + guide: Follow10ProgressGuide + inputRef: React.RefObject + listRef: React.RefObject + onSelectTab: (v: string) => void + searchText: string + setHeaderHeight: (v: number) => void + setSearchText: (v: string) => void + interests: string[] + selectedInterest: string + interestsDisplayNames: Record +}): React.ReactNode => { + const t = useTheme() + const control = Dialog.useDialogContext() + return ( + setHeaderHeight(evt.nativeEvent.layout.height)} + style={[ + a.relative, + web(a.pt_lg), + native(a.pt_4xl), + a.pb_xs, + a.border_b, + t.atoms.border_contrast_low, + t.atoms.bg, + ]}> + + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + + ) +} +Header = memo(Header) + +function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const t = useTheme() + const control = Dialog.useDialogContext() + return ( + + + Find people to follow + + + + + {isWeb ? ( + + ) : null} + + ) +} + +let Tabs = ({ + onSelectTab, + interests, + selectedInterest, + hasSearchText, + interestsDisplayNames, +}: { + onSelectTab: (tab: string) => void + interests: string[] + selectedInterest: string + hasSearchText: boolean + interestsDisplayNames: Record +}): React.ReactNode => { + const listRef = useRef(null) + const [scrollX, setScrollX] = useState(0) + const [totalWidth, setTotalWidth] = useState(0) + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) + + const onInitialLayout = useNonReactiveCallback(() => { + const index = interests.indexOf(selectedInterest) + scrollIntoViewIfNeeded(index) + }) + + useEffect(() => { + if (tabOffsets) { + onInitialLayout() + } + }, [tabOffsets, onInitialLayout]) + + function scrollIntoViewIfNeeded(index: number) { + const btnLayout = tabOffsets[index] + if (!btnLayout) return + + const viewportLeftEdge = scrollX + const viewportRightEdge = scrollX + totalWidth + const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x + const shouldScrollToRightEdge = + viewportRightEdge < btnLayout.x + btnLayout.width + + if (shouldScrollToLeftEdge) { + listRef.current?.scrollTo({ + x: btnLayout.x - tokens.space.lg, + animated: true, + }) + } else if (shouldScrollToRightEdge) { + listRef.current?.scrollTo({ + x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg, + animated: true, + }) + } + } + + function handleSelectTab(index: number) { + const tab = interests[index] + onSelectTab(tab) + scrollIntoViewIfNeeded(index) + } + + function handleTabLayout(index: number, x: number, width: number) { + if (!tabOffsets.length) { + pendingTabOffsets.current[index] = {x, width} + if (pendingTabOffsets.current.length === interests.length) { + setTabOffsets(pendingTabOffsets.current) + } + } + } + + return ( + o.x - tokens.space.xl) + : undefined + } + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} + scrollEventThrottle={200} // big throttle + onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}> + {interests.map((interest, i) => { + const active = interest === selectedInterest && !hasSearchText + return ( + + ) + })} + + ) +} +Tabs = memo(Tabs) + +let Tab = ({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}): React.ReactNode => { + const {_} = useLingui() + const activeText = active ? _(msg` (active)`) : '' + return ( + + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + + + ) +} +Tab = memo(Tab) + +let FollowProfileCard = ({ + profile, + moderationOpts, + isSuggestion, + setSuggestedAccounts, + noBorder, +}: { + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + isSuggestion: boolean + setSuggestedAccounts: ( + updater: ( + v: Map, + ) => Map, + ) => void + noBorder?: boolean +}): React.ReactNode => { + const [hasFollowed, setHasFollowed] = useState(false) + const followupSuggestion = useSuggestedFollowsByActorQuery({ + did: profile.did, + enabled: hasFollowed, + }) + const candidates = followupSuggestion.data?.suggestions + + useEffect(() => { + // TODO: Move out of effect. + if (hasFollowed && candidates && candidates.length > 0) { + setSuggestedAccounts(suggestions => { + const newSuggestions = new Map(suggestions) + newSuggestions.set(profile.did, candidates) + return newSuggestions + }) + } + }, [hasFollowed, profile.did, candidates, setSuggestedAccounts]) + + return ( + + + setHasFollowed(true)} + noBorder={noBorder} + /> + + + ) +} +FollowProfileCard = memo(FollowProfileCard) + +function FollowProfileCardInner({ + profile, + moderationOpts, + onFollow, + noBorder, +}: { + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + onFollow?: () => void + noBorder?: boolean +}) { + const control = Dialog.useDialogContext() + const t = useTheme() + return ( + control.close()}> + {({hovered, pressed}) => ( + + + + + + + + + + + )} + + ) +} + +function CardOuter({ + children, + style, +}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { + const t = useTheme() + return ( + + {children} + + ) +} + +function SearchInput({ + onChangeText, + onEscape, + inputRef, + defaultValue, +}: { + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject + defaultValue: string +}) { + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + + + + + + + + + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + (╯°□°)╯︵ ┻━┻ + + ) +} + +function boostInterests(boosts?: string[]) { + return (_a: string, _b: string) => { + const indexA = boosts?.indexOf(_a) ?? -1 + const indexB = boosts?.indexOf(_b) ?? -1 + const rankA = indexA === -1 ? Infinity : indexA + const rankB = indexB === -1 ? Infinity : indexB + return rankA - rankB + } +} diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx index 299d1e69fc..bbc5a0177f 100644 --- a/src/components/ProgressGuide/List.tsx +++ b/src/components/ProgressGuide/List.tsx @@ -10,12 +10,15 @@ import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import {FollowDialog} from './FollowDialog' import {ProgressGuideTask} from './Task' export function ProgressGuideList({style}: {style?: StyleProp}) { const t = useTheme() const {_} = useLingui() - const guide = useProgressGuide('like-10-and-follow-7') + const followProgressGuide = useProgressGuide('follow-10') + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') + const guide = followProgressGuide || followAndLikeProgressGuide const {endProgressGuide} = useProgressGuideControls() if (guide) { @@ -41,18 +44,33 @@ export function ProgressGuideList({style}: {style?: StyleProp}) { - - + {guide.guide === 'follow-10' && ( + <> + + + + )} + {guide.guide === 'like-10-and-follow-7' && ( + <> + + + + )} ) } diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx index 973ee1ac7f..b9ba3fd9ab 100644 --- a/src/components/ProgressGuide/Task.tsx +++ b/src/components/ProgressGuide/Task.tsx @@ -10,11 +10,13 @@ export function ProgressGuideTask({ total, title, subtitle, + tabularNumsTitle, }: { current: number total: number title: string subtitle?: string + tabularNumsTitle?: boolean }) { const t = useTheme() @@ -33,8 +35,16 @@ export function ProgressGuideTask({ /> )} - - {title} + + + {title} + {subtitle && ( diff --git a/src/components/dms/dialogs/SearchablePeopleList.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx index 0946d2a27c..50090cbcbb 100644 --- a/src/components/dms/dialogs/SearchablePeopleList.tsx +++ b/src/components/dms/dialogs/SearchablePeopleList.tsx @@ -63,6 +63,7 @@ export function SearchablePeopleList({ const {_} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) const listRef = useRef(null) const {currentAccount} = useSession() const inputRef = useRef(null) @@ -237,6 +238,7 @@ export function SearchablePeopleList({ const listHeader = useMemo(() => { return ( setHeaderHeight(evt.nativeEvent.layout.height)} style={[ a.relative, web(a.pt_lg), @@ -315,6 +317,7 @@ export function SearchablePeopleList({ ]} webInnerContentContainerStyle={a.py_0} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + scrollIndicatorInsets={{top: headerHeight}} keyboardDismissMode="on-drag" /> ) diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx index cec5e45c6a..3944d92f07 100644 --- a/src/components/interstitials/Trending.tsx +++ b/src/components/interstitials/Trending.tsx @@ -26,16 +26,16 @@ import {Text} from '#/components/Typography' export function TrendingInterstitial() { const {enabled} = useTrendingConfig() - const {trendingDiscoverHidden} = useTrendingSettings() - return !enabled ? null : trendingDiscoverHidden ? null : + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? : null } export function Inner() { const t = useTheme() const {_} = useLingui() - const gutters = useGutters(['base']) + const gutters = useGutters(['wide', 'base']) const trendingPrompt = Prompt.usePromptControl() - const {setTrendingDiscoverHidden} = useTrendingSettingsApi() + const {setTrendingDisabled} = useTrendingSettingsApi() const {data: trending, error, isLoading} = useTrendingTopics() const noTopics = !isLoading && !error && !trending?.topics?.length @@ -63,7 +63,7 @@ export function Inner() { )} - - + + ) } - -const styles = StyleSheet.create({ - outer: { - flex: 1, - paddingVertical: 30, - paddingHorizontal: 14, - }, - title: { - textAlign: 'center', - marginBottom: 10, - }, - message: { - textAlign: 'center', - marginBottom: 20, - }, - details: { - textAlign: 'center', - paddingVertical: 10, - paddingHorizontal: 14, - overflow: 'hidden', - marginBottom: 20, - }, - btnContainer: { - alignItems: 'center', - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 10, - }, - btnText: { - marginLeft: 5, - }, - errorIconContainer: { - alignItems: 'center', - marginBottom: 10, - }, - errorIcon: { - borderRadius: 25, - width: 50, - height: 50, - alignItems: 'center', - justifyContent: 'center', - }, -}) diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 39caaf0987..607a480ff2 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -18,12 +18,13 @@ import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import {IS_INTERNAL} from '#/lib/app-info' -import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants' +import {POST_CTRL_HITSLOP} from '#/lib/constants' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' +import {useGate} from '#/lib/statsig/statsig' import {toShareUrl} from '#/lib/strings/url-helpers' import {Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' @@ -85,8 +86,8 @@ let PostCtrls = ({ const {sendInteraction} = useFeedFeedbackContext() const {captureAction} = useProgressGuideControls() const playHaptic = useHaptics() - const isDiscoverDebugUser = - IS_INTERNAL || DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] + const gate = useGate() + const isDiscoverDebugUser = IS_INTERNAL || gate('debug_show_feedcontext') const isBlocked = Boolean( post.author.viewer?.blocking || post.author.viewer?.blockedBy || diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index ebf1d955d8..24e8719e17 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' +import {SafeAreaView} from 'react-native-safe-area-context' import { AppBskyActorDefs, AppBskyGraphGetActorStarterPacks, @@ -122,13 +123,15 @@ function ProfileScreenInner({route}: Props) { } if (resolveError || profileError) { return ( - + + + ) } if (profile && moderationOpts) { @@ -144,13 +147,15 @@ function ProfileScreenInner({route}: Props) { } // should never happen return ( - + + + ) } diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index c1ebc10146..378ea59a4d 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -24,6 +24,7 @@ import { ProfileCardFeedLoadingPlaceholder, } from '#/view/com/util/LoadingPlaceholder' import {UserAvatar} from '#/view/com/util/UserAvatar' +import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' import {atoms as a, useTheme, ViewStyleProp} from '#/alf' import {Button} from '#/components/Button' @@ -244,6 +245,10 @@ type ExploreScreenItems = type: 'trendingTopics' key: string } + | { + type: 'recommendations' + key: string + } | { type: 'profile' key: string @@ -337,6 +342,11 @@ export function Explore() { key: `trending-topics`, }) + i.push({ + type: 'recommendations', + key: `recommendations`, + }) + i.push({ type: 'header', key: 'suggested-follows-header', @@ -503,6 +513,9 @@ export function Explore() { case 'trendingTopics': { return } + case 'recommendations': { + return + } case 'profile': { return ( diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 3bf9a80e3d..1d515df558 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -14,7 +14,7 @@ import {createStaticClick, InlineLinkText} from '#/components/Link' export function DesktopFeeds() { const t = useTheme() const {_} = useLingui() - const {data: pinnedFeedInfos} = usePinnedFeedsInfos() + const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos() const selectedFeed = useSelectedFeed() const setSelectedFeed = useSetSelectedFeed() const navigation = useNavigation() @@ -25,7 +25,34 @@ export function DesktopFeeds() { return getCurrentRoute(state) }) - if (!pinnedFeedInfos) { + if (isLoading) { + return ( + + {Array(5) + .fill(0) + .map((_, i) => ( + + ))} + + ) + } + + if (error || !pinnedFeedInfos) { return null } diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 1732245e38..f9f1805270 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -1,6 +1,8 @@ +import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/core' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' @@ -15,6 +17,24 @@ import {InlineLinkText} from '#/components/Link' import {ProgressGuideList} from '#/components/ProgressGuide/List' import {Text} from '#/components/Typography' +function useWebQueryParams() { + const navigation = useNavigation() + const [params, setParams] = React.useState>({}) + + React.useEffect(() => { + return navigation.addListener('state', e => { + try { + const {state} = e.data + const lastRoute = state.routes[state.routes.length - 1] + const {params} = lastRoute + setParams(params) + } catch (e) {} + }) + }, [navigation, setParams]) + + return params +} + export function DesktopRightNav({routeName}: {routeName: string}) { const t = useTheme() const {_} = useLingui() @@ -22,6 +42,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) { const kawaii = useKawaiiMode() const gutters = useGutters(['base', 0, 'base', 'wide']) const isSearchScreen = routeName === 'Search' + const {q: searchQuery} = useWebQueryParams() + const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery) const {isTablet} = useWebMediaQueries() if (isTablet) { @@ -57,7 +79,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) { )} - {!isSearchScreen && } + {showTrending && } {hasSession && ( diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index 55324687f9..e22fad54d7 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -6,10 +6,7 @@ import { useTrendingSettings, useTrendingSettingsApi, } from '#/state/preferences/trending' -import { - DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, - useTrendingTopics, -} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' import {useTrendingConfig} from '#/state/trending-config' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' @@ -24,23 +21,25 @@ import { } from '#/components/TrendingTopics' import {Text} from '#/components/Typography' +const TRENDING_LIMIT = 6 + export function SidebarTrendingTopics() { const {enabled} = useTrendingConfig() - const {trendingSidebarHidden} = useTrendingSettings() - return !enabled ? null : trendingSidebarHidden ? null : + const {trendingDisabled} = useTrendingSettings() + return !enabled ? null : trendingDisabled ? null : } function Inner() { const t = useTheme() const {_} = useLingui() const trendingPrompt = Prompt.usePromptControl() - const {setTrendingSidebarHidden} = useTrendingSettingsApi() + const {setTrendingDisabled} = useTrendingSettingsApi() const {data: trending, error, isLoading} = useTrendingTopics() const noTopics = !isLoading && !error && !trending?.topics?.length return error || noTopics ? null : ( <> - + Trending