Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/kit/src/components/HyperlinkText/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,37 @@ export function HyperlinkText({
{string}
</SizableText>
),
// Semantic color tags. Deliberately no per-color
// `*TextProps` escape hatch: five more ISizableTextProps would
// bloat an already-wide prop type and drag five more entries
// into the memo deps, while `<text>` + textProps already covers
// a caller that needs an arbitrary color. The tokens are
// theme-aware, so these follow light/dark on their own.
red: ([string]) => (
<SizableText {...basicTextProps} color="$textCritical">
{string}
</SizableText>
),
green: ([string]) => (
<SizableText {...basicTextProps} color="$textSuccess">
{string}
</SizableText>
),
yellow: ([string]) => (
<SizableText {...basicTextProps} color="$textCaution">
{string}
</SizableText>
),
blue: ([string]) => (
<SizableText {...basicTextProps} color="$textInfo">
{string}
</SizableText>
),
grey: ([string]) => (
<SizableText {...basicTextProps} color="$textSubdued">
{string}
</SizableText>
),
text: (chunks) => (
<>
{chunks.map((chunk, index) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ interface IOuterTabPagerViewProps {
earnTabsRef?: React.RefObject<ITabContainerRef | null>;
earnBorrowPagerRef?: React.RefObject<IEarnBorrowPagerViewRef | null>;
pageScrollPosition?: SharedValue<number>;
/** Pages currently rendered (active plus whatever a drag has revealed), so a
host can show a page's content the moment it becomes visible rather than
waiting for the swipe to commit (OK-60300) */
onVisiblePagesChange?: (pages: number[]) => void;
}

// --- Component ---
Expand All @@ -114,6 +118,7 @@ function OuterTabPagerViewComponent({
earnTabsRef,
earnBorrowPagerRef,
pageScrollPosition,
onVisiblePagesChange,
}: IOuterTabPagerViewProps) {
const initialPage = TAB_TO_INDEX[selectedHeaderTab] ?? 0;
const outerPagerRef = useAnimatedRef<PagerView>();
Expand Down Expand Up @@ -197,6 +202,23 @@ function OuterTabPagerViewComponent({
setVisitedPages(nextVisited);
}, []);

// Publishing visible pages from the effect below alone is one commit too
// late: the commit that unfreezes a page still carries the host's previous
// visibility, so the page mounts while its body is still display:none —
// exactly the blank first frame OK-60300 is about, just narrower. Calling
// this from the same handler that unfreezes puts both state updates in one
// React batch, so the page appears and paints together. The effect stays as
// the backstop for paths that do not go through a handler; the host setter
// dedupes by content, so the second call is a no-op.
const publishVisiblePages = useCallback(
(indexes: number[]) => {
onVisiblePagesChange?.(
Array.from(new Set(indexes)).toSorted((a, b) => a - b),
);
},
[onVisiblePagesChange],
);

// --- Atom -> PagerView sync (programmatic switching) ---
useEffect(() => {
const index = TAB_TO_INDEX[selectedHeaderTab];
Expand Down Expand Up @@ -229,15 +251,27 @@ function OuterTabPagerViewComponent({
if (state === 'dragging') {
wasUserDragRef.current = true;
setTransitioning(true);
// 'dragging' arrives before the first onPageScroll offset, so the
// direction is still unknown. Mount both neighbors now; the pager has
// already started revealing one of them, and waiting for the
// runOnJS(onPageScroll) round trip is what left a blank page on screen
// for the first frames of the swipe (OK-60300).
const active = currentOuterIndexRef.current;
const neighborhood = [active - 1, active, active + 1].filter(
(index) => index >= 0 && index < INDEX_TO_TAB.length,
);
markPagesVisited(neighborhood);
Comment thread
sidmorizon marked this conversation as resolved.
Outdated
publishVisiblePages(neighborhood);
} else if (state === 'settling') {
setTransitioning(true);
} else if (state === 'idle') {
wasUserDragRef.current = false;
setTransitioning(false);
setVisiblePair(null);
publishVisiblePages([currentOuterIndexRef.current]);
}
},
[setTransitioning, setVisiblePair],
[markPagesVisited, publishVisiblePages, setTransitioning, setVisiblePair],
);

// JS-thread handler for freeze/unfreeze logic during user-gesture swipes.
Expand All @@ -261,8 +295,9 @@ function OuterTabPagerViewComponent({
}
setVisiblePair([position, nextPosition]);
markPagesVisited([position, nextPosition]);
publishVisiblePages([position, nextPosition]);
},
[markPagesVisited, setVisiblePair],
[markPagesVisited, publishVisiblePages, setVisiblePair],
);

// Worklet-based onPageScroll: updates pageScrollPosition on the UI thread
Expand Down Expand Up @@ -318,13 +353,34 @@ function OuterTabPagerViewComponent({
visiblePagePair[0] !== pageIndex && visiblePagePair[1] !== pageIndex
);
}
return activePageIndex !== pageIndex;
// Direction not resolved yet (the 'dragging' window): keep both
// neighbors alive so whichever one the finger reveals already has
// native views. Only lasts for the drag — idle falls back to the
// single active page below.
return Math.abs(activePageIndex - pageIndex) > 1;
}
return activePageIndex !== pageIndex;
},
[activePageIndex, isOuterPageTransitioning, visiblePagePair],
);

// Mirror of shouldFreezePage: the set of pages whose content is actually on
// screen. Hosts use it to reveal a page's body in step with the swipe.
const visiblePagesKey = INDEX_TO_TAB.map((_, index) =>
shouldFreezePage(index) ? '0' : '1',
).join('');
useEffect(() => {
Comment thread
ezailWang marked this conversation as resolved.
if (!onVisiblePagesChange) {
return;
}
onVisiblePagesChange(
visiblePagesKey
.split('')
.map((flag, index) => (flag === '1' ? index : -1))
.filter((index) => index >= 0),
);
}, [onVisiblePagesChange, visiblePagesKey]);

// --- Freeze/unfreeze resync & programmatic page scroll ---
//
// This effect runs AFTER the render commit that unfreezes the target page.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ function MobileBrowser() {
const [settings] = useSettingsPersistAtom();
const selectedHeaderTab =
settings.selectedBrowserTab || ETranslations.global_browser;
// Pages the outer pager is actually painting. Tracked separately from
// selectedHeaderTab, which only commits after the swipe finishes — the
// gap between the two is what showed a blank page mid-swipe (OK-60300).
const [visibleOuterPages, setVisibleOuterPages] = useState<number[]>([]);
const handleVisiblePagesChange = useCallback((pages: number[]) => {
setVisibleOuterPages((prev) =>
prev.length === pages.length && prev.every((v, i) => v === pages[i])
? prev
: pages,
);
}, []);
const isEarnPageVisible =
selectedHeaderTab === ETranslations.global_earn ||
visibleOuterPages.includes(1);
const exploreTabSwitchTypeRef = useRef<IExploreTabSwitchType>('default');
const hasLoggedExploreTabViewRef = useRef(false);

Expand Down Expand Up @@ -569,6 +583,7 @@ function MobileBrowser() {
marketTabsRef={marketTabsRef}
earnTabsRef={earnTabsRef}
earnBorrowPagerRef={earnBorrowPagerRef}
onVisiblePagesChange={handleVisiblePagesChange}
marketContent={
<MarketHomeWithProvider
isFocused={selectedHeaderTab === ETranslations.global_market}
Expand All @@ -580,6 +595,7 @@ function MobileBrowser() {
<EarnHomeWithProvider
showHeader={false}
showContent={selectedHeaderTab === ETranslations.global_earn}
isVisible={isEarnPageVisible}
defaultTab={earnTab}
tabsRef={earnTabsRef}
useSwipePager={useOuterPager}
Expand Down
58 changes: 51 additions & 7 deletions packages/kit/src/views/Earn/EarnHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
} from '@onekeyhq/shared/src/routes';
import timerUtils from '@onekeyhq/shared/src/utils/timerUtils';
import { EAccountSelectorSceneName } from '@onekeyhq/shared/types';
import type { IEarnAvailableAsset } from '@onekeyhq/shared/types/earn';
import type {
IEarnAvailableAsset,
IEarnPageBannerListItem,
} from '@onekeyhq/shared/types/earn';
import { EAvailableAssetsTypeEnum } from '@onekeyhq/shared/types/earn';
import { EEarnLabels } from '@onekeyhq/shared/types/staking';

Expand Down Expand Up @@ -64,12 +67,22 @@ type IEarnModeSwitchType = 'default' | 'tap' | 'swipe';
function BasicEarnHome({
showHeader,
showContent,
isVisible,
overrideDefaultTab,
tabsRef,
useSwipePager,
}: {
showHeader?: boolean;
/** Owns data fetching: only the committed tab requests. */
showContent?: boolean;
/**
* Owns painting. The outer pager reveals the neighboring page as soon as the
* finger moves, but showContent only flips once the swipe commits, so the
* body stayed display:none for the whole gesture and the user swiped onto a
* blank page (OK-60300). Deliberately separate from showContent so following
* the swipe never triggers a request for a tab the user is only passing over.
*/
isVisible?: boolean;
overrideDefaultTab?: 'assets' | 'portfolio' | 'faqs';
tabsRef?: React.RefObject<ITabContainerRef | null>;
useSwipePager?: boolean;
Expand All @@ -88,19 +101,45 @@ function BasicEarnHome({
const wasFocusedRef = useRef(false);
const wasHiddenByModalRef = useRef(false);
const shouldLogEnterEarnRef = useRef(false);
// showContent is in the dependency list, so every switch onto the DeFi tab
// re-runs this. Returning [] on the way out used to wipe the loaded banners,
// and usePromiseResult reports isLoading === undefined until its effect
// fires — so coming back rendered "no banner" (0pt), then the skeleton
// (248pt), then "no banner" again once the empty response landed. On an
// account with no banners that whole cycle is one or two frames, which is
// the jump QA sees on every switch (OK-60299).
//
// Keeping the last result means a re-entry starts from what was already on
// screen instead of from empty.
//
// That alone is not enough for an account that genuinely has no banners.
// usePromiseResult raises isLoading on every re-run, including
// revalidateOnFocus, and the cached list is legitimately empty — so the
// skeleton branch kept firing and the 248pt jump came back on each re-entry.
// Once a request has produced a result, "no banners" is a known answer and
// later revalidation must not fall back to the loading presentation.
const earnPageBannerListRef = useRef<IEarnPageBannerListItem[]>([]);
const hasResolvedEarnPageBannerRef = useRef(false);
const {
result: earnPageBannerList,
isLoading: isEarnPageBannerLoading,
isLoading: isEarnPageBannerLoading = true,
run: refetchEarnPageBannerList,
} = usePromiseResult(
async () => {
if (!platformEnv.isNative || showContent === false) {
return [];
// Nothing will ever be fetched here, so this counts as resolved too —
// otherwise desktop and web flash the skeleton for a frame.
hasResolvedEarnPageBannerRef.current = true;
Comment thread
ezailWang marked this conversation as resolved.
Outdated
return earnPageBannerListRef.current;
}
try {
return await backgroundApiProxy.serviceStaking.getEarnPageBannerList();
const list =
await backgroundApiProxy.serviceStaking.getEarnPageBannerList();
earnPageBannerListRef.current = list;
hasResolvedEarnPageBannerRef.current = true;
return list;
} catch {
return [];
return earnPageBannerListRef.current;
}
},
[showContent],
Expand Down Expand Up @@ -544,11 +583,13 @@ function BasicEarnHome({
<YStack flex={1}>
<EarnMobileHomeContent
bannerList={earnPageBannerList}
isBannerLoading={!!isEarnPageBannerLoading}
isBannerLoading={
isEarnPageBannerLoading && !hasResolvedEarnPageBannerRef.current
}
faqList={faqList || []}
isFaqLoading={isFaqLoading}
isActive={isEarnContentActive}
showContent={showContent !== false}
showContent={(isVisible ?? showContent) !== false}
Comment thread
ezailWang marked this conversation as resolved.
isRefreshing={isOverviewRefreshing}
isPullRefreshing={isManualRefreshing}
displayTotalFiatValue={displayTotalFiatValue}
Expand Down Expand Up @@ -640,13 +681,15 @@ function BasicEarnHome({
export function EarnHomeWithProvider({
showHeader = true,
showContent = true,
isVisible,
defaultTab,
tabsRef,
useSwipePager,
earnBorrowPagerRef,
}: {
showHeader?: boolean;
showContent?: boolean;
isVisible?: boolean;
defaultTab?: 'assets' | 'portfolio' | 'faqs';
tabsRef?: React.RefObject<ITabContainerRef | null>;
useSwipePager?: boolean;
Expand All @@ -664,6 +707,7 @@ export function EarnHomeWithProvider({
<BasicEarnHome
showHeader={showHeader}
showContent={showContent}
isVisible={isVisible}
overrideDefaultTab={defaultTab}
tabsRef={tabsRef}
useSwipePager={useSwipePager}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
useEarnAtom,
useEarnLoadingStatesAtom,
} from '@onekeyhq/kit/src/states/jotai/contexts/earn';
import { ETranslations } from '@onekeyhq/shared/src/locale';
import type { IEarnAvailableAsset } from '@onekeyhq/shared/types/earn';
import { EAvailableAssetsTypeEnum } from '@onekeyhq/shared/types/earn';

Expand Down Expand Up @@ -56,15 +55,13 @@ function AvailableAssetsSectionSkeleton() {
export function AvailableAssetItem({
asset,
categoryType,
totalLiquidityLabel,
tvlValue,
tvlLabel,
testID,
onPress,
}: {
asset: IEarnAvailableAsset;
categoryType: EAvailableAssetsTypeEnum;
totalLiquidityLabel: string;
/** Walkthrough r3: summed provider TVL rendered under APY (Tokens home) */
tvlValue?: number;
tvlLabel?: string;
Expand Down Expand Up @@ -106,15 +103,18 @@ export function AvailableAssetItem({
</XStack>
}
/>
{/* Fixed rate: right side uses APY/APR as title and Liquidity as subtitle (OK-58879) */}
{/* Fixed rate: right side uses APY/APR as title and the liquidity amount
as subtitle (OK-58879). The "Total liquidity" caption was dropped:
the label wrapped in longer locales and the column reads fine as a
bare amount. */}
{/* No flex here (OK-59904): ListItem.Text above already claims flex={1},
so a second flex={1} splits the row in half and wraps long APR ranges
such as "14.33% - 17.38% APR". The column sizes to its content. */}
<YStack ai="flex-end" jc="center" gap="$0.5">
<AprText asset={asset} />
{showLiquidity ? (
<SizableText size="$bodySm" color="$textSubdued" numberOfLines={1}>
{`${totalLiquidityLabel} ${asset.liquidity ?? ''}`}
{asset.liquidity}
</SizableText>
) : null}
{showTvl ? (
Expand Down Expand Up @@ -169,14 +169,6 @@ function AvailableAssetsFlatListComponent() {
};
return merged;
}, [availableAssetsByType]);
const totalLiquidityLabel = useMemo(
() =>
intl.formatMessage({
id: ETranslations.dexmarket_details_liquidity_change_total,
}),
[intl],
);

const handleAssetPress = useCallback(
(asset: IEarnAvailableAsset, categoryType: EAvailableAssetsTypeEnum) => {
void navigateToAsset(asset, categoryType);
Expand Down Expand Up @@ -250,7 +242,6 @@ function AvailableAssetsFlatListComponent() {
key={`${type}-${asset.symbol}`}
asset={asset}
categoryType={type}
totalLiquidityLabel={totalLiquidityLabel}
testID={EarnTestIDs.flatAssetItem(type, asset.symbol)}
onPress={() => handleAssetPress(asset, type)}
/>
Expand Down
Loading
Loading