From 20d4266e2f8ef82b6ed1244fd929dd07c01486dc Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 17 Dec 2024 03:13:51 +0000 Subject: [PATCH 1/9] Read storage on window.onstorage (#7137) --- src/state/persisted/index.web.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index 4cfc87cdb1..f28b197715 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -24,6 +24,7 @@ const _emitter = new EventEmitter() export async function init() { broadcast.onmessage = onBroadcastMessage + window.onstorage = onStorage const stored = readFromStorage() if (stored) { _state = stored @@ -90,6 +91,17 @@ export async function clearStorage() { } clearStorage satisfies PersistedApi['clearStorage'] +function onStorage() { + const next = readFromStorage() + if (next === _state) { + return + } + if (next) { + _state = next + _emitter.emit('update') + } +} + async function onBroadcastMessage({data}: MessageEvent) { if ( typeof data === 'object' && From 31d2aa6716007871d50892be8b866085309ae92c Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 17 Dec 2024 11:09:06 +0000 Subject: [PATCH 2/9] Refetch empty feed on focus (#7139) --- src/view/com/posts/PostFeed.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 6746703379..55d7ba053f 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -187,12 +187,16 @@ let PostFeed = ({ } try { if (await pollLatest(data.pages[0])) { - onHasNew(true) + if (isEmpty) { + refetch() + } else { + onHasNew(true) + } } } catch (e) { logger.error('Poll latest failed', {feed, message: String(e)}) } - }, [feed, data, isFetching, onHasNew, enabled, disablePoll]) + }, [feed, data, isFetching, isEmpty, onHasNew, enabled, disablePoll, refetch]) const myDid = currentAccount?.did || '' const onPostCreated = React.useCallback(() => { @@ -220,20 +224,15 @@ let PostFeed = ({ React.useEffect(() => { if (enabled && !disablePoll) { const timeSinceFirstLoad = Date.now() - lastFetchRef.current - // DISABLED need to check if this is causing random feed refreshes -prf - /*if (timeSinceFirstLoad > REFRESH_AFTER) { - // do a full refresh - scrollElRef?.current?.scrollToOffset({offset: 0, animated: false}) - queryClient.resetQueries({queryKey: RQKEY(feed)}) - } else*/ if ( - timeSinceFirstLoad > CHECK_LATEST_AFTER && + if ( + (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) && checkForNewRef.current ) { // check for new on enable (aka on focus) checkForNewRef.current() } } - }, [enabled, disablePoll, feed, queryClient, scrollElRef]) + }, [enabled, disablePoll, feed, queryClient, scrollElRef, isEmpty]) React.useEffect(() => { let cleanup1: () => void | undefined, cleanup2: () => void | undefined const subscription = AppState.addEventListener('change', nextAppState => { From 07b7250682c2841e9428c509707d08c17a4317df Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 17 Dec 2024 11:22:49 +0000 Subject: [PATCH 3/9] Fix notifications borders (#7140) --- src/view/com/notifications/NotificationFeed.tsx | 2 +- src/view/com/pager/TabBar.web.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index 0b814e68dc..5fa40b30b5 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -134,7 +134,7 @@ export function NotificationFeed({ highlightUnread={filter === 'all'} item={item} moderationOpts={moderationOpts!} - hideTopBorder={index === 0 && item.notification.isRead} + hideTopBorder={index === 0} /> ) }, diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx index cabd955f02..d44b7b60c4 100644 --- a/src/view/com/pager/TabBar.web.tsx +++ b/src/view/com/pager/TabBar.web.tsx @@ -145,7 +145,7 @@ export function TabBar({ const desktopStyles = StyleSheet.create({ outer: { flexDirection: 'row', - width: 598, + width: 600, }, contentContainer: { flexGrow: 1, From 32611391a35cbfe3f4a57882c117d52de022fb89 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 17 Dec 2024 12:22:09 +0000 Subject: [PATCH 4/9] Pipe statsig events to logger (#7141) * Pipe statsig events to logger * Log rich objects to bitdrift * Fix tests * Consolidate mocks, fix tests * Reduce log trash on native --- jest/jestSetup.js | 16 +++++++++++++ src/lib/bitdrift.ts | 1 + src/lib/bitdrift.web.ts | 4 ++++ src/lib/statsig/statsig.tsx | 26 ++++++++++++++++++--- src/logger/bitdriftTransport.ts | 5 ++-- src/logger/bitdriftTransport.web.ts | 7 ------ src/state/session/__tests__/session-test.ts | 13 ----------- 7 files changed, 46 insertions(+), 26 deletions(-) create mode 100644 src/lib/bitdrift.web.ts delete mode 100644 src/logger/bitdriftTransport.web.ts 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/src/lib/bitdrift.ts b/src/lib/bitdrift.ts index 02d074e76c..3f892f6b8c 100644 --- a/src/lib/bitdrift.ts +++ b/src/lib/bitdrift.ts @@ -1,5 +1,6 @@ import {init} from '@bitdrift/react-native' import {Statsig} from 'statsig-react-native-expo' +export {debug, error, info, warn} from '@bitdrift/react-native' import {initPromise} from './statsig/statsig' diff --git a/src/lib/bitdrift.web.ts b/src/lib/bitdrift.web.ts new file mode 100644 index 0000000000..5db69450fa --- /dev/null +++ b/src/lib/bitdrift.web.ts @@ -0,0 +1,4 @@ +export function debug() {} +export function error() {} +export function info() {} +export function warn() {} diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 21fa4bb57c..e0882806d5 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -5,6 +5,7 @@ import {sha256} from 'js-sha256' import {Statsig, StatsigProvider} from 'statsig-react-native-expo' import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info' +import * as bitdrift from '#/lib/bitdrift' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' @@ -97,19 +98,38 @@ export function logEvent( rawMetadata: LogEvents[E] & FlatJSONRecord, ) { try { - const fullMetadata = { - ...rawMetadata, - } as Record // Statsig typings are unnecessarily strict here. + const fullMetadata = toStringRecord(rawMetadata) fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' if (Statsig.initializeCalled()) { Statsig.logEvent(eventName, null, fullMetadata) } + // Intentionally bypass the logger abstraction to log rich objects. + console.groupCollapsed(eventName) + console.log(fullMetadata) + console.groupEnd() + bitdrift.info(eventName, fullMetadata) } catch (e) { // A log should never interrupt the calling code, whatever happens. logger.error('Failed to log an event', {message: e}) } } +function toStringRecord( + metadata: LogEvents[E] & FlatJSONRecord, +): Record { + const record: Record = {} + for (let key in metadata) { + if (metadata.hasOwnProperty(key)) { + if (typeof metadata[key] === 'string') { + record[key] = metadata[key] + } else { + record[key] = JSON.stringify(metadata[key]) + } + } + } + return record +} + // We roll our own cache in front of Statsig because it is a singleton // and it's been difficult to get it to behave in a predictable way. // Our own cache ensures consistent evaluation within a single session. diff --git a/src/logger/bitdriftTransport.ts b/src/logger/bitdriftTransport.ts index c2235e0d4b..159b863004 100644 --- a/src/logger/bitdriftTransport.ts +++ b/src/logger/bitdriftTransport.ts @@ -3,8 +3,7 @@ import { error as bdError, info as bdInfo, warn as bdWarn, -} from '@bitdrift/react-native' - +} from '../lib/bitdrift' import {LogLevel, Transport} from './types' export function createBitdriftTransport(): Transport { @@ -18,6 +17,6 @@ export function createBitdriftTransport(): Transport { return (level, message) => { const log = logFunctions[level] - log(message.toString()) + log('' + message) } } diff --git a/src/logger/bitdriftTransport.web.ts b/src/logger/bitdriftTransport.web.ts deleted file mode 100644 index ecea3f6f3b..0000000000 --- a/src/logger/bitdriftTransport.web.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {Transport} from './index' - -export function createBitdriftTransport(): Transport { - return (_level, _message) => { - // noop - } -} diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 7a5ddfa974..dec8ec48bd 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -4,25 +4,12 @@ import {describe, expect, it, jest} from '@jest/globals' import {agentToSessionAccountOrThrow} from '../agent' import {Action, getInitialState, reducer, State} from '../reducer' -jest.mock('statsig-react-native-expo', () => ({ - Statsig: { - initialize() {}, - initializeCalled() { - return false - }, - }, -})) - jest.mock('jwt-decode', () => ({ jwtDecode(_token: string) { return {} }, })) -jest.mock('expo-localization', () => ({ - getLocales: () => [], -})) - describe('session', () => { it('can log in and out', () => { let state = getInitialState([]) From 0cbb03cd14c226bcbfd146a586d97c62a0fc4c9d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 17 Dec 2024 17:13:18 +0000 Subject: [PATCH 5/9] New progress guide - 10 follows (#7128) * new follow-10 progress guide * find follows dialog * wip tabs * flatlist version with search * hardcode out jake gold * lazy load followup suggestions * Update src/components/ProgressGuide/FollowDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * comment out replacing, enable paging * rm autofocus * find shadow profiles in paginated search * clear search when press tabs * better tab a11y * fix label * adjust scroll indicator insets * do the same scroll indicator adjustment for searchable people list * hardcode jake to just be 'tech' * Retain state on close/reopen * only change follow btn color when not followed * add guide to inside dialog * fix task alignment * Enable contextual suggestions * WIP: show multiple suggestions * Rework so it animates well * Show more items * remove card style * move tabs to own component * split out header top * scroll active tab into view * rm log * Improve perf a bit * boost popular interests over alphabetical ones * scroll active tab into view * revert back to round buttons * Fix overrenders of the tab bar items * Fix unintended animation * Scroll initial into view if needed * Unlift state, the dialog thing breaks lifting * Persist simply * Fix empty state * Fix incorrect gate exposure * Fix another bad useGate * Nit --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Dan Abramov --- src/alf/atoms.ts | 12 + src/components/FeedInterstitials.tsx | 2 +- src/components/ProfileCard.tsx | 7 +- src/components/ProgressGuide/FollowDialog.tsx | 829 ++++++++++++++++++ src/components/ProgressGuide/List.tsx | 44 +- src/components/ProgressGuide/Task.tsx | 14 +- .../dms/dialogs/SearchablePeopleList.tsx | 3 + src/lib/statsig/events.ts | 2 + src/lib/statsig/gates.ts | 5 +- src/screens/Onboarding/StepFinished.tsx | 8 +- src/screens/Onboarding/state.ts | 13 + src/state/queries/actor-search.ts | 26 +- src/state/queries/suggested-follows.ts | 9 +- src/state/shell/progress-guide.tsx | 96 +- src/view/com/posts/PostFeed.tsx | 6 +- src/view/com/util/List.tsx | 6 +- src/view/com/util/Toast.tsx | 4 +- 17 files changed, 1039 insertions(+), 47 deletions(-) create mode 100644 src/components/ProgressGuide/FollowDialog.tsx 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/lib/statsig/events.ts b/src/lib/statsig/events.ts index e6c9c5d135..f1dfb0a947 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -162,6 +162,7 @@ export type LogEvents = { | 'StarterPackProfilesList' | 'FeedInterstitial' | 'ProfileHeaderSuggestedFollows' + | 'PostOnboardingFindFollows' } 'profile:unfollow': { logContext: @@ -177,6 +178,7 @@ export type LogEvents = { | 'StarterPackProfilesList' | 'FeedInterstitial' | 'ProfileHeaderSuggestedFollows' + | 'PostOnboardingFindFollows' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 6876f18c53..a6c2492548 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,3 +1,6 @@ export type Gate = // Keep this alphabetic please. - 'debug_show_feedcontext' | 'debug_subscriptions' | 'remove_show_latest_button' + | 'debug_show_feedcontext' + | 'debug_subscriptions' + | 'new_postonboarding' + | 'remove_show_latest_button' diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 0d8971b6f6..fc0ea6a247 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -14,7 +14,7 @@ import { TIMELINE_SAVED_FEED, } from '#/lib/constants' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' -import {logEvent} from '#/lib/statsig/statsig' +import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {getAllListMembers} from '#/state/queries/list-members' @@ -57,6 +57,7 @@ export function StepFinished() { const setActiveStarterPack = useSetActiveStarterPack() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const {startProgressGuide} = useProgressGuideControls() + const gate = useGate() const finishOnboarding = React.useCallback(async () => { setSaving(true) @@ -190,7 +191,9 @@ export function StepFinished() { setSaving(false) setActiveStarterPack(undefined) setHasCheckedForStarterPack(true) - startProgressGuide('like-10-and-follow-7') + startProgressGuide( + gate('new_postonboarding') ? 'follow-10' : 'like-10-and-follow-7', + ) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) logEvent('onboarding:finished:nextPressed', { @@ -221,6 +224,7 @@ export function StepFinished() { setActiveStarterPack, setHasCheckedForStarterPack, startProgressGuide, + gate, ]) return ( diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index 70fa696408..20d3ef2170 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -72,6 +72,19 @@ export type ApiResponseMap = { } } +// most popular selected interests +export const popularInterests = [ + 'art', + 'gaming', + 'sports', + 'comics', + 'music', + 'politics', + 'photography', + 'science', + 'news', +] + export function useInterestsDisplayNames() { const {_} = useLingui() diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 479fc1a9f0..6d6c46e040 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,6 +1,7 @@ import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' import { InfiniteData, + keepPreviousData, QueryClient, QueryKey, useInfiniteQuery, @@ -13,10 +14,8 @@ import {useAgent} from '#/state/session' const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string) => [RQKEY_ROOT, query] -export const RQKEY_PAGINATED = (query: string) => [ - `${RQKEY_ROOT}_paginated`, - query, -] +const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` +export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query] export function useActorSearch({ query, @@ -42,9 +41,11 @@ export function useActorSearch({ export function useActorSearchPaginated({ query, enabled, + maintainData, }: { query: string enabled?: boolean + maintainData?: boolean }) { const agent = useAgent() return useInfiniteQuery< @@ -67,6 +68,7 @@ export function useActorSearchPaginated({ enabled: enabled && !!query, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + placeholderData: maintainData ? keepPreviousData : undefined, }) } @@ -89,4 +91,20 @@ export function* findAllProfilesInQueryData( } } } + + const queryDatasPaginated = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT_PAGINATED], + }) + for (const [_queryKey, queryData] of queryDatasPaginated) { + if (!queryData) { + continue + } + for (const actor of queryData.pages.flatMap(page => page.actors)) { + if (actor.did === did) { + yield actor + } + } + } } diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 07e16946e7..22033c0a8c 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -103,7 +103,13 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { }) } -export function useSuggestedFollowsByActorQuery({did}: {did: string}) { +export function useSuggestedFollowsByActorQuery({ + did, + enabled, +}: { + did: string + enabled?: boolean +}) { const agent = useAgent() return useQuery({ queryKey: suggestedFollowsByActorQueryKey(did), @@ -116,6 +122,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) { : res.data.suggestions.filter(profile => !profile.viewer?.following) return {suggestions} }, + enabled, }) } diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx index d64e9984f5..af3d60ebbd 100644 --- a/src/state/shell/progress-guide.tsx +++ b/src/state/shell/progress-guide.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useMemo} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -16,20 +16,32 @@ export enum ProgressGuideAction { Follow = 'follow', } -type ProgressGuideName = 'like-10-and-follow-7' +type ProgressGuideName = 'like-10-and-follow-7' | 'follow-10' +/** + * Progress Guides that extend this interface must specify their name in the `guide` field, so it can be used as a discriminated union + */ interface BaseProgressGuide { - guide: string + guide: ProgressGuideName isComplete: boolean [key: string]: any } -interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { +export interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { + guide: 'like-10-and-follow-7' numLikes: number numFollows: number } -type ProgressGuide = Like10AndFollow7ProgressGuide | undefined +export interface Follow10ProgressGuide extends BaseProgressGuide { + guide: 'follow-10' + numFollows: number +} + +export type ProgressGuide = + | Like10AndFollow7ProgressGuide + | Follow10ProgressGuide + | undefined const ProgressGuideContext = React.createContext(undefined) @@ -61,15 +73,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {mutateAsync, variables, isPending} = useSetActiveProgressGuideMutation() - const activeProgressGuide = ( - isPending ? variables : preferences?.bskyAppState?.activeProgressGuide - ) as ProgressGuide + const activeProgressGuide = useMemo(() => { + const rawProgressGuide = ( + isPending ? variables : preferences?.bskyAppState?.activeProgressGuide + ) as ProgressGuide + + if (!rawProgressGuide) return undefined + + // ensure the unspecced attributes have the correct types + // clone then mutate + const {...maybeWronglyTypedProgressGuide} = rawProgressGuide + if (maybeWronglyTypedProgressGuide?.guide === 'like-10-and-follow-7') { + maybeWronglyTypedProgressGuide.numLikes = + Number(maybeWronglyTypedProgressGuide.numLikes) || 0 + maybeWronglyTypedProgressGuide.numFollows = + Number(maybeWronglyTypedProgressGuide.numFollows) || 0 + } else if (maybeWronglyTypedProgressGuide?.guide === 'follow-10') { + maybeWronglyTypedProgressGuide.numFollows = + Number(maybeWronglyTypedProgressGuide.numFollows) || 0 + } - // ensure the unspecced attributes have the correct types - if (activeProgressGuide?.guide === 'like-10-and-follow-7') { - activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0 - activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0 - } + return maybeWronglyTypedProgressGuide + }, [isPending, variables, preferences]) const [localGuideState, setLocalGuideState] = React.useState(undefined) @@ -82,7 +107,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const firstLikeToastRef = React.useRef(null) const fifthLikeToastRef = React.useRef(null) const tenthLikeToastRef = React.useRef(null) - const guideCompleteToastRef = React.useRef(null) + + const fifthFollowToastRef = React.useRef(null) + const tenthFollowToastRef = React.useRef(null) const controls = React.useMemo(() => { return { @@ -93,7 +120,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) { numLikes: 0, numFollows: 0, isComplete: false, - } + } satisfies ProgressGuide + setLocalGuideState(guideObj) + mutateAsync(guideObj) + } else if (guide === 'follow-10') { + const guideObj = { + guide: 'follow-10', + numFollows: 0, + isComplete: false, + } satisfies ProgressGuide setLocalGuideState(guideObj) mutateAsync(guideObj) } @@ -137,6 +172,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) { isComplete: true, } } + } else if (guide?.guide === 'follow-10') { + if (action === ProgressGuideAction.Follow) { + guide = { + ...guide, + numFollows: (Number(guide.numFollows) || 0) + count, + } + + if (guide.numFollows === 5) { + fifthFollowToastRef.current?.open() + } + if (guide.numFollows === 10) { + tenthFollowToastRef.current?.open() + } + } + if (Number(guide.numFollows) >= 10) { + guide = { + ...guide, + isComplete: true, + } + } } setLocalGuideState(guide) @@ -167,9 +222,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { subtitle={_(msg`The Discover feed now knows what you like`)} /> + )} diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 55d7ba053f..10eb47d0a4 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -253,9 +253,11 @@ let PostFeed = ({ } }, [pollInterval]) - const progressGuide = useProgressGuide('like-10-and-follow-7') + const followProgressGuide = useProgressGuide('follow-10') + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') const {isDesktop} = useWebMediaQueries() - const showProgressIntersitial = progressGuide && !isDesktop + const showProgressIntersitial = + (followProgressGuide || followAndLikeProgressGuide) && !isDesktop const feedItems: FeedRow[] = React.useMemo(() => { let feedKind: 'following' | 'discover' | 'profile' | undefined diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 62c91cec66..5084af6129 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -155,7 +155,11 @@ let List = React.forwardRef( automaticallyAdjustsScrollIndicatorInsets={ automaticallyAdjustsScrollIndicatorInsets } - scrollIndicatorInsets={{top: headerOffset, right: 1}} + scrollIndicatorInsets={{ + top: headerOffset, + right: 1, + ...props.scrollIndicatorInsets, + }} contentOffset={contentOffset} refreshControl={refreshControl} onScroll={scrollHandler} diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 7dc6837e23..c602886745 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -196,7 +196,9 @@ function Toast({ /> - {message} + + {message} + From 65d4416f9e30b58c32c02cf65bc84db53c952a87 Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 18 Dec 2024 02:53:42 +0000 Subject: [PATCH 6/9] Tweak Follow dialog Search placeholder (#7147) --- src/components/ProgressGuide/FollowDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 6ac3200df1..fa233bb65a 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -738,7 +738,7 @@ function SearchInput({ Date: Tue, 17 Dec 2024 21:12:53 -0600 Subject: [PATCH 7/9] Fix emoji picker position (#7146) --- .../text-input/web/EmojiPicker.web.tsx | 71 ++++++++++--------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index 1d5dad4861..c721729020 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -10,6 +10,7 @@ import {DismissableLayer} from '@radix-ui/react-dismissable-layer' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a} from '#/alf' +import {Portal} from '#/components/Portal' const HEIGHT_OFFSET = 40 const WIDTH_OFFSET = 100 @@ -125,39 +126,41 @@ export function EmojiPicker({state, close, pinToTop}: IProps) { } return ( - - - {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} - e.stopPropagation()}> - - evt.preventDefault()} - onDismiss={close}> - { - return (await import('./EmojiPickerData.json')).default - }} - onEmojiSelect={onInsert} - autoFocus={true} - /> - - - - - + + + + {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} + e.stopPropagation()}> + + evt.preventDefault()} + onDismiss={close}> + { + return (await import('./EmojiPickerData.json')).default + }} + onEmojiSelect={onInsert} + autoFocus={true} + /> + + + + + + ) } From a2019aceec001e276272832b97ea5e2ec864c8a5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 17 Dec 2024 21:45:39 -0600 Subject: [PATCH 8/9] Trending (Beta) (#7144) * Add WIP UIs for trending topics and suggested starterpacks * Disable SPs for now * Improve explore treatment a bit, add some polish to cards * Add tiny option in RightNav * Add persisted option to hide trending from sidebar * Add to settings, abstract state, not updating in tab * Fix up hide/show toggle state, WITH broadcast hacK * Clean up persisted code, add new setting * Add new interstitial to Discover * Exploration * First hack at mute words * Wire up interstitial and Explore page * Align components * Some skeleton UI * Handle service config, enablement, load states, update lex contract * Centralize mute word handling * Stale time to 30m * Cache enabled value for reloads, use real data for service config * Remove broadcast hack * Remove titleChild * Gate settings too * Update package, rm langs * Add feature gate * Only english during beta period * Hook up real data * Tweak config * Straight passthrough links * Hook up prod agent * Fix no-show logic * Up config query to 5 min * Remove old file * Remove comment * Remove stray flex_1 * Make trending setting global * Quick placeholder state * Limit # in sidebar, tweak spacing * Tweak gaps * Handle hide/show of sidebar * Simplify messages * Remove interstitial * Revert "Remove interstitial" This reverts commit 1358ad47fdf7e633749340c410933b508af46c10. * Only show interstitial on mobile * Fix gap * Add explore page recommendations * [topics] add topic screen (#7149) * add topic screen * decode * fix search query * decode * add server route * Fix potential bad destructure (undefined) --------- Co-authored-by: Paul Frazee Co-authored-by: Dan Abramov Co-authored-by: Hailey --- bskyweb/cmd/bskyweb/server.go | 1 + package.json | 2 +- src/App.native.tsx | 15 +- src/App.web.tsx | 7 +- src/Navigation.tsx | 6 + src/components/GradientFill.tsx | 8 +- src/components/TrendingTopics.tsx | 223 ++++++++++++++++++ src/components/interstitials/Trending.tsx | 111 +++++++++ src/lib/routes/types.ts | 3 + src/lib/statsig/gates.ts | 1 + src/routes.ts | 1 + .../components/ExploreRecommendations.tsx | 95 ++++++++ .../components/ExploreTrendingTopics.tsx | 102 ++++++++ .../Settings/ContentAndMediaSettings.tsx | 27 +++ src/screens/Topic.tsx | 204 ++++++++++++++++ src/state/persisted/schema.ts | 2 + src/state/preferences/index.tsx | 5 +- src/state/preferences/trending.tsx | 69 ++++++ src/state/queries/index.ts | 1 + src/state/queries/service-config.ts | 32 +++ .../queries/trending/useTrendingTopics.ts | 49 ++++ src/state/trending-config.tsx | 70 ++++++ src/storage/schema.ts | 1 + src/view/com/posts/PostFeed.tsx | 25 +- src/view/screens/Search/Explore.tsx | 48 +++- src/view/shell/desktop/Feeds.tsx | 32 ++- src/view/shell/desktop/RightNav.tsx | 44 +++- .../shell/desktop/SidebarTrendingTopics.tsx | 104 ++++++++ yarn.lock | 60 ++--- 29 files changed, 1268 insertions(+), 80 deletions(-) create mode 100644 src/components/TrendingTopics.tsx create mode 100644 src/components/interstitials/Trending.tsx create mode 100644 src/screens/Search/components/ExploreRecommendations.tsx create mode 100644 src/screens/Search/components/ExploreTrendingTopics.tsx create mode 100644 src/screens/Topic.tsx create mode 100644 src/state/preferences/trending.tsx create mode 100644 src/state/queries/service-config.ts create mode 100644 src/state/queries/trending/useTrendingTopics.ts create mode 100644 src/state/trending-config.tsx create mode 100644 src/view/shell/desktop/SidebarTrendingTopics.tsx diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 19252f7654..3ef19fc462 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -235,6 +235,7 @@ func serve(cctx *cli.Context) error { // generic routes e.GET("/hashtag/:tag", server.WebGeneric) + e.GET("/topic/:topic", server.WebGeneric) e.GET("/search", server.WebGeneric) e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) diff --git a/package.json b/package.json index f5f4601d6c..ff2223b489 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.13.20", + "@atproto/api": "^0.13.21", "@bitdrift/react-native": "0.4.0", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/App.native.tsx b/src/App.native.tsx index 39ab7ca92c..780295ddce 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -57,6 +57,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' +import {Provider as TrendingConfigProvider} from '#/state/trending-config' import {TestCtrls} from '#/view/com/testing/TestCtrls' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' @@ -143,12 +144,14 @@ function InnerApp() { - - - - - + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index 8d13a826e7..8a2e13600f 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -47,6 +47,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' +import {Provider as TrendingConfigProvider} from '#/state/trending-config' import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' import * as Toast from '#/view/com/util/Toast' @@ -127,8 +128,10 @@ function InnerApp() { - - + + + + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 7443128d2c..18705c5ffb 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -100,6 +100,7 @@ import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' import {SettingsScreen} from './screens/Settings/Settings' import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' +import TopicScreen from './screens/Topic' const navigationRef = createNavigationContainerRef() @@ -376,6 +377,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => HashtagScreen} options={{title: title(msg`Hashtag`)}} /> + TopicScreen} + options={{title: title(msg`Topic`)}} + /> MessagesConversationScreen} diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx index 3dff404d74..9ad6ed7dc6 100644 --- a/src/components/GradientFill.tsx +++ b/src/components/GradientFill.tsx @@ -1,11 +1,13 @@ import {LinearGradient} from 'expo-linear-gradient' -import {atoms as a, tokens} from '#/alf' +import {atoms as a, tokens, ViewStyleProp} from '#/alf' export function GradientFill({ gradient, -}: { + style, +}: ViewStyleProp & { gradient: + | typeof tokens.gradients.primary | typeof tokens.gradients.sky | typeof tokens.gradients.midnight | typeof tokens.gradients.sunrise @@ -26,7 +28,7 @@ export function GradientFill({ } start={{x: 0, y: 0}} end={{x: 1, y: 1}} - style={[a.absolute, a.inset_0]} + style={[a.absolute, a.inset_0, style]} /> ) } diff --git a/src/components/TrendingTopics.tsx b/src/components/TrendingTopics.tsx new file mode 100644 index 0000000000..6881f24bd5 --- /dev/null +++ b/src/components/TrendingTopics.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import {View} from 'react-native' +import {AtUri} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +// import {makeProfileLink} from '#/lib/routes/links' +// import {feedUriToHref} from '#/lib/strings/url-helpers' +// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote' +// import {UserAvatar} from '#/view/com/util/UserAvatar' +import type {TrendingTopic} from '#/state/queries/trending/useTrendingTopics' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function TrendingTopic({ + topic: raw, + size, + style, +}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) { + const t = useTheme() + const topic = useTopic(raw) + + const isSmall = size === 'small' + // const hasAvi = topic.type === 'feed' || topic.type === 'profile' + // const aviSize = isSmall ? 16 : 20 + // const iconSize = isSmall ? 16 : 20 + + return ( + + {/* + + {topic.type === 'tag' ? ( + + ) : topic.type === 'topic' ? ( + + ) : topic.type === 'feed' ? ( + + ) : ( + + )} + + */} + + + {topic.displayName} + + + ) +} + +export function TrendingTopicSkeleton({ + size = 'large', + index = 0, +}: { + size?: 'large' | 'small' + index?: number +}) { + const t = useTheme() + const isSmall = size === 'small' + return ( + + ) +} + +export function TrendingTopicLink({ + topic: raw, + children, + ...rest +}: { + topic: TrendingTopic +} & Omit) { + const topic = useTopic(raw) + + return ( + + {children} + + ) +} + +type ParsedTrendingTopic = + | { + type: 'topic' | 'tag' | 'unknown' + label: string + displayName: string + url: string + uri: undefined + } + | { + type: 'profile' | 'feed' + label: string + displayName: string + url: string + uri: AtUri + } + +export function useTopic(raw: TrendingTopic): ParsedTrendingTopic { + const {_} = useLingui() + return React.useMemo(() => { + const {topic: displayName, link} = raw + + if (link.startsWith('/search')) { + return { + type: 'topic', + label: _(msg`Browse posts about ${displayName}`), + displayName, + uri: undefined, + url: link, + } + } else if (link.startsWith('/hashtag')) { + return { + type: 'tag', + label: _(msg`Browse posts tagged with ${displayName}`), + displayName, + // displayName: displayName.replace(/^#/, ''), + uri: undefined, + url: link, + } + } + + /* + if (!link.startsWith('at://')) { + // above logic + } else { + const urip = new AtUri(link) + switch (urip.collection) { + case 'app.bsky.actor.profile': { + return { + type: 'profile', + label: _(msg`View ${displayName}'s profile`), + displayName, + uri: urip, + url: makeProfileLink({did: urip.host, handle: urip.host}), + } + } + case 'app.bsky.feed.generator': { + return { + type: 'feed', + label: _(msg`Browse the ${displayName} feed`), + displayName, + uri: urip, + url: feedUriToHref(link), + } + } + } + } + */ + + return { + type: 'unknown', + label: _(msg`Browse topic ${displayName}`), + displayName, + uri: undefined, + url: link, + } + }, [_, raw]) +} diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx new file mode 100644 index 0000000000..3944d92f07 --- /dev/null +++ b/src/components/interstitials/Trending.tsx @@ -0,0 +1,111 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + useTrendingSettings, + useTrendingSettingsApi, +} from '#/state/preferences/trending' +import { + DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, tokens, useGutters, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {GradientFill} from '#/components/GradientFill' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import * as Prompt from '#/components/Prompt' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +export function TrendingInterstitial() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? : null +} + +export function Inner() { + const t = useTheme() + const {_} = useLingui() + const gutters = useGutters(['wide', 'base']) + const trendingPrompt = Prompt.usePromptControl() + const {setTrendingDisabled} = useTrendingSettingsApi() + const {data: trending, error, isLoading} = useTrendingTopics() + const noTopics = !isLoading && !error && !trending?.topics?.length + + return error || noTopics ? null : ( + + + + + + Trending + + + + + BETA + + + + + + + + + {isLoading ? ( + Array(TRENDING_TOPICS_COUNT) + .fill(0) + .map((_n, i) => ) + ) : !trending?.topics ? null : ( + <> + {trending.topics.map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + setTrendingDisabled(true)} + /> + + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 238e4be4c3..d720886e9f 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -47,6 +47,7 @@ export type CommonNavigatorParams = { AppIconSettings: undefined Search: {q?: string} Hashtag: {tag: string; author?: string} + Topic: {topic: string} MessagesConversation: {conversation: string; embed?: string} MessagesSettings: undefined NotificationSettings: undefined @@ -92,6 +93,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Feeds: undefined Notifications: undefined Hashtag: {tag: string; author?: string} + Topic: {topic: string} Messages: {pushToConversation?: string; animation?: 'push' | 'pop'} } @@ -105,6 +107,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { Notifications: undefined MyProfileTab: undefined Hashtag: {tag: string; author?: string} + Topic: {topic: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} Start: {name: string; rkey: string} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index a6c2492548..455a703458 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -4,3 +4,4 @@ export type Gate = | 'debug_subscriptions' | 'new_postonboarding' | 'remove_show_latest_button' + | 'trending_topics_beta' diff --git a/src/routes.ts b/src/routes.ts index 188665d849..7cd7c0880d 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -53,6 +53,7 @@ export const router = new Router({ CopyrightPolicy: '/support/copyright', // hashtags Hashtag: '/hashtag/:tag', + Topic: '/topic/:topic', // DMs Messages: '/messages', MessagesSettings: '/messages/settings', diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx new file mode 100644 index 0000000000..e253cfb5ab --- /dev/null +++ b/src/screens/Search/components/ExploreRecommendations.tsx @@ -0,0 +1,95 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {isWeb} from '#/platform/detection' +import {useTrendingSettings} from '#/state/preferences/trending' +import { + DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, useGutters, useTheme} from '#/alf' +import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +export function ExploreRecommendations() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? : null +} + +function Inner() { + const t = useTheme() + const gutters = useGutters([0, 'compact']) + const {data: trending, error, isLoading} = useTrendingTopics() + const noRecs = !isLoading && !error && !trending?.suggested?.length + + return error || noRecs ? null : ( + <> + + + + + + Recommended + + + + Feeds we think you might like. + + + + + + + {isLoading ? ( + Array(RECOMMENDATIONS_COUNT) + .fill(0) + .map((_, i) => ) + ) : !trending?.suggested ? null : ( + <> + {trending.suggested.map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + + ) +} diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx new file mode 100644 index 0000000000..be347dcd4d --- /dev/null +++ b/src/screens/Search/components/ExploreTrendingTopics.tsx @@ -0,0 +1,102 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {isWeb} from '#/platform/detection' +import {useTrendingSettings} from '#/state/preferences/trending' +import { + DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, tokens, useGutters, useTheme} from '#/alf' +import {GradientFill} from '#/components/GradientFill' +import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +export function ExploreTrendingTopics() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? : null +} + +function Inner() { + const t = useTheme() + const gutters = useGutters([0, 'compact']) + const {data: trending, error, isLoading} = useTrendingTopics() + const noTopics = !isLoading && !error && !trending?.topics?.length + + return error || noTopics ? null : ( + <> + + + + + + Trending + + + + + BETA + + + + + What people are posting about. + + + + + + + {isLoading ? ( + Array(TRENDING_TOPICS_COUNT) + .fill(0) + .map((_, i) => ) + ) : !trending?.topics ? null : ( + <> + {trending.topics.map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + + ) +} diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index 17f8fa5067..bdbe1d191b 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -9,6 +9,11 @@ import { useInAppBrowser, useSetInAppBrowser, } from '#/state/preferences/in-app-browser' +import { + useTrendingSettings, + useTrendingSettingsApi, +} from '#/state/preferences/trending' +import {useTrendingConfig} from '#/state/trending-config' import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as Toggle from '#/components/forms/Toggle' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' @@ -16,6 +21,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' @@ -29,6 +35,9 @@ export function ContentAndMediaSettingsScreen({}: Props) { const setAutoplayDisabledPref = useSetAutoplayDisabled() const inAppBrowserPref = useInAppBrowser() const setUseInAppBrowser = useSetInAppBrowser() + const {enabled: trendingEnabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + const {setTrendingDisabled} = useTrendingSettingsApi() return ( @@ -104,6 +113,24 @@ export function ContentAndMediaSettingsScreen({}: Props) { + {trendingEnabled && ( + <> + + setTrendingDisabled(!value)}> + + + + Enable trending topics + + + + + + )} diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx new file mode 100644 index 0000000000..6cd69f05f2 --- /dev/null +++ b/src/screens/Topic.tsx @@ -0,0 +1,204 @@ +import React from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {HITSLOP_10} from '#/lib/constants' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' +import {cleanError} from '#/lib/strings/errors' +import {enforceLen} from '#/lib/strings/helpers' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useSetMinimalShellMode} from '#/state/shell' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {Post} from '#/view/com/post/Post' +import {List} from '#/view/com/util/List' +import {atoms as a, web} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import * as Layout from '#/components/Layout' +import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' + +const renderItem = ({item}: ListRenderItemInfo) => { + return +} + +const keyExtractor = (item: PostView, index: number) => { + return `${item.uri}-${index}` +} + +export default function TopicScreen({ + route, +}: NativeStackScreenProps) { + const {topic} = route.params + const {_} = useLingui() + + const headerTitle = React.useMemo(() => { + return enforceLen(decodeURIComponent(topic), 24, true, 'middle') + }, [topic]) + + const onShare = React.useCallback(() => { + const url = new URL('https://bsky.app') + url.pathname = `/topic/${topic}` + shareUrl(url.toString()) + }, [topic]) + + const [activeTab, setActiveTab] = React.useState(0) + const setMinimalShellMode = useSetMinimalShellMode() + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setActiveTab(index) + }, + [setMinimalShellMode], + ) + + const sections = React.useMemo(() => { + return [ + { + title: _(msg`Top`), + component: ( + + ), + }, + { + title: _(msg`Latest`), + component: ( + + ), + }, + ] + }, [_, topic, activeTab]) + + return ( + + + + + {headerTitle} + + + + + + ( + + section.title)} {...props} /> + + )} + initialPage={0}> + {sections.map((section, i) => ( + {section.component} + ))} + + + ) +} + +function TopicScreenTab({ + topic, + sort, + active, +}: { + topic: string + sort: 'top' | 'latest' + active: boolean +}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTR, setIsPTR] = React.useState(false) + + const { + data, + isFetched, + isFetchingNextPage, + isLoading, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useSearchPostsQuery({ + query: decodeURIComponent(topic), + sort, + enabled: active, + }) + + const posts = React.useMemo(() => { + return data?.pages.flatMap(page => page.posts) || [] + }, [data]) + + const onRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [refetch]) + + const onEndReached = React.useCallback(() => { + if (isFetchingNextPage || !hasNextPage || error) return + fetchNextPage() + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + + return ( + <> + {posts.length < 1 ? ( + + ) : ( + + } + initialNumToRender={initialNumToRender} + windowSize={11} + /> + )} + + ) +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f70d774630..0a9e5b2c07 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -125,6 +125,7 @@ const schema = z.object({ subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), + trendingDisabled: z.boolean().optional(), }) export type Schema = z.infer @@ -170,6 +171,7 @@ export const defaults: Schema = { kawaii: false, hasCheckedForStarterPack: false, subtitlesEnabled: true, + trendingDisabled: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index c7eaf27261..8530a8d0c8 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -10,6 +10,7 @@ import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' import {Provider as SubtitlesProvider} from './subtitles' +import {Provider as TrendingSettingsProvider} from './trending' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { @@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - {children} + + {children} + diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx new file mode 100644 index 0000000000..bf5d8f13cc --- /dev/null +++ b/src/state/preferences/trending.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = { + trendingDisabled: Exclude +} +type ApiContext = { + setTrendingDisabled( + hidden: Exclude, + ): void +} + +const StateContext = React.createContext({ + trendingDisabled: Boolean(persisted.defaults.trendingDisabled), +}) +const ApiContext = React.createContext({ + setTrendingDisabled() {}, +}) + +function usePersistedBooleanValue(key: T) { + const [value, _set] = React.useState(() => { + return Boolean(persisted.get(key)) + }) + const set = React.useCallback< + (value: Exclude) => void + >( + hidden => { + _set(Boolean(hidden)) + persisted.write(key, hidden) + }, + [key, _set], + ) + React.useEffect(() => { + return persisted.onUpdate(key, hidden => { + _set(Boolean(hidden)) + }) + }, [key, _set]) + + return [value, set] as const +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [trendingDisabled, setTrendingDisabled] = + usePersistedBooleanValue('trendingDisabled') + + /* + * Context + */ + const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled]) + const api = React.useMemo( + () => ({setTrendingDisabled}), + [setTrendingDisabled], + ) + + return ( + + {children} + + ) +} + +export function useTrendingSettings() { + return React.useContext(StateContext) +} + +export function useTrendingSettingsApi() { + return React.useContext(ApiContext) +} diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index 0635bf316b..d4b9d94c45 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -6,6 +6,7 @@ export const STALE = { MINUTES: { ONE: 1e3 * 60, FIVE: 1e3 * 60 * 5, + THIRTY: 1e3 * 60 * 30, }, HOURS: { ONE: 1e3 * 60 * 60, diff --git a/src/state/queries/service-config.ts b/src/state/queries/service-config.ts new file mode 100644 index 0000000000..9a9db78659 --- /dev/null +++ b/src/state/queries/service-config.ts @@ -0,0 +1,32 @@ +import {useQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' + +type ServiceConfig = { + checkEmailConfirmed: boolean + topicsEnabled: boolean +} + +export function useServiceConfigQuery() { + const agent = useAgent() + return useQuery({ + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.FIVE, + queryKey: ['service-config'], + queryFn: async () => { + try { + const {data} = await agent.api.app.bsky.unspecced.getConfig() + return { + checkEmailConfirmed: Boolean(data.checkEmailConfirmed), + topicsEnabled: Boolean(data.topicsEnabled), + } + } catch (e) { + return { + checkEmailConfirmed: false, + topicsEnabled: false, + } + } + }, + }) +} diff --git a/src/state/queries/trending/useTrendingTopics.ts b/src/state/queries/trending/useTrendingTopics.ts new file mode 100644 index 0000000000..310f64e9f2 --- /dev/null +++ b/src/state/queries/trending/useTrendingTopics.ts @@ -0,0 +1,49 @@ +import React from 'react' +import {AppBskyUnspeccedDefs} from '@atproto/api' +import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords' +import {useQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic + +export const DEFAULT_LIMIT = 14 + +export const trendingTopicsQueryKey = ['trending-topics'] + +export function useTrendingTopics() { + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const mutedWords = React.useMemo(() => { + return preferences?.moderationPrefs?.mutedWords || [] + }, [preferences?.moderationPrefs]) + + return useQuery({ + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.THIRTY, + queryKey: trendingTopicsQueryKey, + async queryFn() { + const {data} = await agent.api.app.bsky.unspecced.getTrendingTopics({ + limit: DEFAULT_LIMIT, + }) + + const {topics, suggested} = data + return { + topics: topics.filter(t => { + return !hasMutedWord({ + mutedWords, + text: t.topic + ' ' + t.displayName + ' ' + t.description, + }) + }), + suggested: suggested.filter(t => { + return !hasMutedWord({ + mutedWords, + text: t.topic + ' ' + t.displayName + ' ' + t.description, + }) + }), + } + }, + }) +} diff --git a/src/state/trending-config.tsx b/src/state/trending-config.tsx new file mode 100644 index 0000000000..a7694993fb --- /dev/null +++ b/src/state/trending-config.tsx @@ -0,0 +1,70 @@ +import React from 'react' + +import {useGate} from '#/lib/statsig/statsig' +import {useLanguagePrefs} from '#/state/preferences/languages' +import {useServiceConfigQuery} from '#/state/queries/service-config' +import {device} from '#/storage' + +type Context = { + enabled: boolean +} + +const Context = React.createContext({ + enabled: false, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const gate = useGate() + const langPrefs = useLanguagePrefs() + const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery() + const ctx = React.useMemo(() => { + if (__DEV__) { + return {enabled: true} + } + + /* + * Only English during beta period + */ + if ( + !!langPrefs.contentLanguages.length && + !langPrefs.contentLanguages.includes('en') + ) { + return {enabled: false} + } + + /* + * While loading, use cached value + */ + const cachedEnabled = device.get(['trendingBetaEnabled']) + if (isInitialLoad) { + return {enabled: Boolean(cachedEnabled)} + } + + /* + * Doing an extra check here to reduce hits to statsig. If it's disabled on + * the server, we can exit early. + */ + const enabled = Boolean(config?.topicsEnabled) + if (!enabled) { + // cache for next reload + device.set(['trendingBetaEnabled'], enabled) + return {enabled: false} + } + + /* + * Service is enabled, but also check statsig in case we're rolling back. + */ + const gateEnabled = gate('trending_topics_beta') + const _enabled = enabled && gateEnabled + + // update cache + device.set(['trendingBetaEnabled'], _enabled) + + return {enabled: _enabled} + }, [isInitialLoad, config, gate, langPrefs.contentLanguages]) + return {children} +} + +export function useTrendingConfig() { + return React.useContext(Context) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index cf410c77de..cfca9131c7 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -8,4 +8,5 @@ export type Device = { geolocation?: { countryCode: string | undefined } + trendingBetaEnabled: boolean } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 10eb47d0a4..7860d568d5 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -23,6 +23,7 @@ import {logger} from '#/logger' import {isIOS, isWeb} from '#/platform/detection' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' +import {useTrendingSettings} from '#/state/preferences/trending' import {STALE} from '#/state/queries' import { FeedDescriptor, @@ -34,7 +35,9 @@ import { } from '#/state/queries/post-feed' import {useSession} from '#/state/session' import {useProgressGuide} from '#/state/shell/progress-guide' +import {useBreakpoints} from '#/alf' import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' +import {TrendingInterstitial} from '#/components/interstitials/Trending' import {List, ListRef} from '../util/List' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' @@ -90,6 +93,10 @@ type FeedRow = type: 'interstitialProgressGuide' key: string } + | { + type: 'interstitialTrending' + key: string + } export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null { if (feedRow.type === 'sliceItem') { @@ -156,6 +163,7 @@ let PostFeed = ({ const checkForNewRef = React.useRef<(() => void) | null>(null) const lastFetchRef = React.useRef(Date.now()) const [feedType, feedUri, feedTab] = feed.split('|') + const {gtTablet} = useBreakpoints() const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -259,6 +267,8 @@ let PostFeed = ({ const showProgressIntersitial = (followProgressGuide || followAndLikeProgressGuide) && !isDesktop + const {trendingDisabled} = useTrendingSettings() + const feedItems: FeedRow[] = React.useMemo(() => { let feedKind: 'following' | 'discover' | 'profile' | undefined if (feedType === 'following') { @@ -304,7 +314,16 @@ let PostFeed = ({ type: 'interstitialProgressGuide', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) - } else if (sliceIndex === 20) { + } else if ( + sliceIndex === 15 && + !gtTablet && + !trendingDisabled + ) { + arr.push({ + type: 'interstitialTrending', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } else if (sliceIndex === 30) { arr.push({ type: 'interstitialFollows', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, @@ -390,6 +409,8 @@ let PostFeed = ({ feedTab, hasSession, showProgressIntersitial, + trendingDisabled, + gtTablet, ]) // events @@ -476,6 +497,8 @@ let PostFeed = ({ return } else if (row.type === 'interstitialProgressGuide') { return + } else if (row.type === 'interstitialTrending') { + return } else if (row.type === 'sliceItem') { const slice = row.slice if (slice.isFallbackMarker) { diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index bd2ebe5d5d..378ea59a4d 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/view/screens/Search/Explore.tsx @@ -24,6 +24,8 @@ 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' import * as FeedCard from '#/components/FeedCard' @@ -239,6 +241,14 @@ type ExploreScreenItems = style?: ViewStyleProp['style'] icon: React.ComponentType } + | { + type: 'trendingTopics' + key: string + } + | { + type: 'recommendations' + key: string + } | { type: 'profile' key: string @@ -325,17 +335,27 @@ export function Explore() { ]) const items = React.useMemo(() => { - const i: ExploreScreenItems[] = [ - { - type: 'header', - key: 'suggested-follows-header', - title: _(msg`Suggested accounts`), - description: _( - msg`Follow more accounts to get connected to your interests and build your network.`, - ), - icon: Person, - }, - ] + const i: ExploreScreenItems[] = [] + + i.push({ + type: 'trendingTopics', + key: `trending-topics`, + }) + + i.push({ + type: 'recommendations', + key: `recommendations`, + }) + + i.push({ + type: 'header', + key: 'suggested-follows-header', + title: _(msg`Suggested accounts`), + description: _( + msg`Follow more accounts to get connected to your interests and build your network.`, + ), + icon: Person, + }) if (profiles) { // Currently the responses contain duplicate items. @@ -490,6 +510,12 @@ 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 83b5420ce7..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,14 +25,40 @@ export function DesktopFeeds() { return getCurrentRoute(state) }) - if (!pinnedFeedInfos) { + if (isLoading) { + return ( + + {Array(5) + .fill(0) + .map((_, i) => ( + + ))} + + ) + } + + if (error || !pinnedFeedInfos) { return null } return ( >({}) + + 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() const {hasSession, currentAccount} = useSession() const kawaii = useKawaiiMode() const gutters = useGutters(['base', 0, 'base', 'wide']) + const isSearchScreen = routeName === 'Search' + const webqueryParams = useWebQueryParams() + const searchQuery = webqueryParams?.q + const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery) const {isTablet} = useWebMediaQueries() if (isTablet) { @@ -29,6 +55,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) { - {routeName !== 'Search' && ( - - - - )} + {!isSearchScreen && } + {hasSession && ( <> - - - - + + + )} + {showTrending && } + {hasSession && ( <> diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx new file mode 100644 index 0000000000..e22fad54d7 --- /dev/null +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -0,0 +1,104 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + useTrendingSettings, + useTrendingSettingsApi, +} from '#/state/preferences/trending' +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' +import {Divider} from '#/components/Divider' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' +import * as Prompt from '#/components/Prompt' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +const TRENDING_LIMIT = 6 + +export function SidebarTrendingTopics() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return !enabled ? null : trendingDisabled ? null : +} + +function Inner() { + const t = useTheme() + const {_} = useLingui() + const trendingPrompt = Prompt.usePromptControl() + const {setTrendingDisabled} = useTrendingSettingsApi() + const {data: trending, error, isLoading} = useTrendingTopics() + const noTopics = !isLoading && !error && !trending?.topics?.length + + return error || noTopics ? null : ( + <> + + + + + Trending + + + + + + {isLoading ? ( + Array(TRENDING_LIMIT) + .fill(0) + .map((_n, i) => ( + + )) + ) : !trending?.topics ? null : ( + <> + {trending.topics.slice(0, TRENDING_LIMIT).map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + setTrendingDisabled(true)} + /> + + + ) +} diff --git a/yarn.lock b/yarn.lock index f6598f8b8d..61ed0f66c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -72,6 +72,20 @@ tlds "^1.234.0" zod "^3.23.8" +"@atproto/api@^0.13.21": + version "0.13.21" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc" + integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA== + dependencies: + "@atproto/common-web" "^0.3.1" + "@atproto/lexicon" "^0.4.4" + "@atproto/syntax" "^0.3.1" + "@atproto/xrpc" "^0.6.5" + await-lock "^2.2.2" + multiformats "^9.9.0" + tlds "^1.234.0" + zod "^3.23.8" + "@atproto/aws@^0.2.10": version "0.2.10" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.10.tgz#e0b888fd50308cc24b7086cf3ec209587c13bbe4" @@ -3247,7 +3261,7 @@ "@babel/parser" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== @@ -3308,19 +3322,6 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" - integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== - dependencies: - "@babel/code-frame" "^7.25.9" - "@babel/generator" "^7.25.9" - "@babel/parser" "^7.25.9" - "@babel/template" "^7.25.9" - "@babel/types" "^7.25.9" - debug "^4.3.1" - globals "^11.1.0" - "@babel/types@^7.0.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03" @@ -17456,16 +17457,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -17565,7 +17557,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -17579,13 +17571,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -18860,7 +18845,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -18878,15 +18863,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From ff02868afc959087c2d7dfd1507572db90f08437 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 17 Dec 2024 19:50:59 -0800 Subject: [PATCH 9/9] Layout tweaks (#7150) * Reduce weight of right sidebar active feed * ProfileFeedHeader tweaks --------- Co-authored-by: Eric Bailey --- .../Profile/components/ProfileFeedHeader.tsx | 31 +++++++++++-------- src/view/shell/desktop/Feeds.tsx | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx index 6bfbff3a08..cf305ac4dd 100644 --- a/src/screens/Profile/components/ProfileFeedHeader.tsx +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -30,8 +30,8 @@ import * as Dialog from '#/components/Dialog' import {Divider} from '#/components/Divider' import {useRichText} from '#/components/hooks/useRichText' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import { Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, Heart2_Stroke2_Corner0_Rounded as Heart, @@ -91,7 +91,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { const t = useTheme() const {_, i18n} = useLingui() const {hasSession} = useSession() - const {gtPhone, gtMobile} = useBreakpoints() + const {gtMobile} = useBreakpoints() const infoControl = Dialog.useDialogControl() const playHaptic = useHaptics() @@ -191,9 +191,8 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { style={[ a.justify_start, { - paddingVertical: isWeb ? 4 : 6, - paddingHorizontal: 8, - paddingRight: 12, + paddingVertical: isWeb ? 2 : 4, + paddingRight: 8, }, ]} onPress={() => { @@ -207,10 +206,18 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { a.absolute, a.inset_0, a.rounded_sm, - a.transition_transform, + a.transition_all, t.atoms.bg_contrast_25, - pressed && t.atoms.bg_contrast_50, + { + opacity: 0, + left: isWeb ? -2 : -4, + right: 0, + }, + pressed && { + opacity: 1, + }, hovered && { + opacity: 1, transform: [{scaleX: 1.01}, {scaleY: 1.1}], }, ]} @@ -219,7 +226,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { {info.avatar && ( - + )} @@ -237,10 +244,9 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { {sanitizeHandle(info.creatorHandle, '@')} @@ -256,10 +262,9 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { /> {formatCount(i18n, likeCount)} @@ -268,7 +273,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { - diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 1d515df558..7a56722cc1 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -88,7 +88,7 @@ export function DesktopFeeds() { a.text_md, a.leading_snug, current - ? [a.font_heavy, t.atoms.text] + ? [a.font_bold, t.atoms.text] : [t.atoms.text_contrast_medium], ]} numberOfLines={1}>