Skip to content

Commit 46dbad9

Browse files
fix(home): pin compact wallet row only on camera-style top insets
TMA keeps the logo bar aligned with Telegram chrome. The first header row scrolls away unless a notch/camera safe area is present; Feed/Messages still locks after the header above it. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5186893 commit 46dbad9

6 files changed

Lines changed: 115 additions & 14 deletions

File tree

‎ui/components/AuthenticatedHomeLeftNavStrip.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export function AuthenticatedHomeLeftNavStrip({
144144
feedUnreadCount = 0,
145145
marginTopPx,
146146
passVerticalScroll = false,
147+
tone = "background",
147148
}: {
148149
colors: ThemeColors;
149150
/** Controlled mode: parent owns which tab is highlighted. */
@@ -155,6 +156,8 @@ export function AuthenticatedHomeLeftNavStrip({
155156
marginTopPx?: number;
156157
/** Compact sticky strip: allow vertical pan so the parent list can collapse/expand the header. */
157158
passVerticalScroll?: boolean;
159+
/** Compact sticky last header band uses undercover so list rows do not show through. */
160+
tone?: "background" | "undercover";
158161
}) {
159162
const { t, locale } = useAppStrings();
160163
const { width: windowWidth } = useWindowDimensions();
@@ -726,7 +729,7 @@ export function AuthenticatedHomeLeftNavStrip({
726729
marginBottom: 0,
727730
position: "relative",
728731
overflow: "visible",
729-
backgroundColor: colors.background,
732+
backgroundColor: tone === "undercover" ? colors.undercover : colors.background,
730733
...(Platform.OS === "web"
731734
? ({
732735
cursor: grabbing ? "grabbing" : scrollEnabled ? "grab" : "default",

‎ui/components/GlobalLogoBar.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,12 @@ export function GlobalLogoBar() {
297297
<View
298298
style={[
299299
styles.container,
300-
{ height: blockHeight, backgroundColor },
300+
{ height: blockHeight, backgroundColor, zIndex: 6 },
301+
isInTelegram
302+
? Platform.OS === "web"
303+
? ({ position: "sticky", top: 0 } as object)
304+
: { zIndex: 6 }
305+
: null,
301306
logoBarBottomSeparator,
302307
]}
303308
>

‎ui/components/HomeAuthenticatedHeaderRow.tsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ type Props = {
373373
compactStickyTopInsetPx?: number;
374374
/** Compact: live scroll offset (native sticky fallback via translate). */
375375
compactScrollYPx?: number;
376+
/** Compact: pin the wallet row (camera / notch band only). */
377+
compactStickFirstRow?: boolean;
376378
/** Compact: vertical scroll on the sticky first row (wheel / drag). */
377379
compactStickyScrollBridge?: {
378380
onTouchStart?: (event: { nativeEvent: { pageY: number } }) => void;
@@ -401,6 +403,7 @@ export function HomeAuthenticatedHeaderRow({
401403
onCompactStickyLayout,
402404
compactStickyTopInsetPx = 0,
403405
compactScrollYPx = 0,
406+
compactStickFirstRow = false,
404407
compactStickyScrollBridge,
405408
}: Props) {
406409
const router = useRouter();
@@ -1010,14 +1013,18 @@ export function HomeAuthenticatedHeaderRow({
10101013
: {})}
10111014
style={{
10121015
width: "100%",
1013-
backgroundColor: colors.background,
1014-
zIndex: 4,
1016+
marginHorizontal: -layout.contentSideInsetPx,
1017+
paddingHorizontal: layout.contentSideInsetPx,
1018+
backgroundColor: colors.undercover,
1019+
zIndex: compactStickFirstRow ? 4 : 1,
10151020
paddingTop: compactStickyTopInsetPx,
10161021
overflow: "visible",
1017-
transform: [{ translateY: compactScrollYPx }],
1022+
...(compactStickFirstRow
1023+
? { transform: [{ translateY: compactScrollYPx }] }
1024+
: null),
10181025
...(Platform.OS === "web"
10191026
? ({
1020-
willChange: "transform",
1027+
...(compactStickFirstRow ? { willChange: "transform" } : null),
10211028
touchAction: "pan-y",
10221029
} as object)
10231030
: null),

‎ui/screens/HomeAuthenticatedScreen.tsx‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ import {
3131
readImportedWallets,
3232
subscribeImportedWallets,
3333
} from "../wallet/importedWalletsStore";
34+
import {
35+
compactNavStickAfterPx,
36+
shouldStickCompactFirstHeaderRow,
37+
} from "../wallet/headerRowFit";
3438
import { useTonConnectSession } from "../ton/TonConnectProvider";
3539
import { AuthenticatedHomeLeftNavStrip } from "../components/AuthenticatedHomeLeftNavStrip";
3640
import { AuthenticatedHomeFeedPanel } from "../components/AuthenticatedHomeFeedPanel";
@@ -673,6 +677,17 @@ function HomeAuthenticatedScreenMain() {
673677
layout.authenticatedHome.headerDividerHeight,
674678
);
675679
const [compactChromeHeightPx, setCompactChromeHeightPx] = useState(0);
680+
const [webSafeAreaInsetTopPx, setWebSafeAreaInsetTopPx] = useState(0);
681+
useEffect(() => {
682+
if (Platform.OS !== "web" || typeof document === "undefined") return;
683+
const probe = document.createElement("div");
684+
probe.style.cssText =
685+
"position:absolute;visibility:hidden;pointer-events:none;padding-top:env(safe-area-inset-top, 0px)";
686+
document.body.appendChild(probe);
687+
const px = Number.parseFloat(window.getComputedStyle(probe).paddingTop || "0") || 0;
688+
probe.remove();
689+
setWebSafeAreaInsetTopPx(px);
690+
}, []);
676691
const handleHomeLeftScrollPositionChange = useCallback(
677692
(metrics: { scrollY: number; layoutH: number; contentH: number }) => {
678693
setCompactHeaderScrollY(metrics.scrollY);
@@ -701,6 +716,7 @@ function HomeAuthenticatedScreenMain() {
701716
debug,
702717
applyServerWalletAfterRegister,
703718
browserSessionChecked,
719+
safeAreaInsetTop,
704720
} = useTelegram();
705721
const pathname = useResolvedPathname();
706722
const { width: windowWidth } = useWindowDimensions();
@@ -720,6 +736,14 @@ function HomeAuthenticatedScreenMain() {
720736
? Math.min(splitLayoutMetrics.effectiveSplitWidthPx, fallbackLayoutWidthPx)
721737
: fallbackLayoutWidthPx;
722738
const isWideHome = isAuthenticatedHomeWideLayoutWidthPx(liveLayoutWidthPx);
739+
const stickCompactFirstHeaderRow =
740+
!isWideHome &&
741+
shouldStickCompactFirstHeaderRow(Math.max(safeAreaInsetTop, webSafeAreaInsetTopPx));
742+
const compactNavLockAfterPx = compactNavStickAfterPx({
743+
stickFirstRow: stickCompactFirstHeaderRow,
744+
firstRowHeightPx: compactStickyHeightPx,
745+
collapsibleHeightPx: compactCollapsibleHeightPx,
746+
});
723747
const compactHeaderCollapsePx = !isWideHome
724748
? Math.min(Math.max(0, compactHeaderScrollY), Math.max(0, compactCollapsibleHeightPx))
725749
: 0;
@@ -1786,9 +1810,16 @@ function HomeAuthenticatedScreenMain() {
17861810
compactCollapsePx={compactHeaderCollapsePx}
17871811
onCompactCollapsibleLayout={setCompactCollapsibleHeightPx}
17881812
onCompactStickyLayout={setCompactStickyHeightPx}
1789-
compactStickyTopInsetPx={layout.authenticatedHome.contentInsetTop}
1790-
compactScrollYPx={isWideHome ? 0 : compactHeaderScrollY}
1791-
compactStickyScrollBridge={compactStickyScrollBridge}
1813+
compactStickyTopInsetPx={
1814+
stickCompactFirstHeaderRow ? layout.authenticatedHome.contentInsetTop : 0
1815+
}
1816+
compactScrollYPx={
1817+
!isWideHome && stickCompactFirstHeaderRow ? compactHeaderScrollY : 0
1818+
}
1819+
compactStickFirstRow={stickCompactFirstHeaderRow}
1820+
compactStickyScrollBridge={
1821+
stickCompactFirstHeaderRow ? compactStickyScrollBridge : undefined
1822+
}
17921823
activeHeaderMenuKey={
17931824
messagesChatOpen
17941825
? null
@@ -1875,14 +1906,11 @@ function HomeAuthenticatedScreenMain() {
18751906
style={{
18761907
zIndex: 3,
18771908
width: "100%",
1878-
backgroundColor: colors.background,
1909+
backgroundColor: colors.undercover,
18791910
overflow: "visible",
18801911
transform: [
18811912
{
1882-
translateY: Math.max(
1883-
0,
1884-
compactHeaderScrollY - compactCollapsibleHeightPx,
1885-
),
1913+
translateY: Math.max(0, compactHeaderScrollY - compactNavLockAfterPx),
18861914
},
18871915
],
18881916
...(Platform.OS === "web"
@@ -1901,6 +1929,7 @@ function HomeAuthenticatedScreenMain() {
19011929
feedUnreadCount={feedUnreadCount}
19021930
marginTopPx={0}
19031931
passVerticalScroll
1932+
tone="undercover"
19041933
/>
19051934
</View>
19061935
</View>

‎ui/wallet/__tests__/headerRowFit.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import assert from "node:assert/strict";
22
import { describe, it } from "node:test";
33
import {
4+
compactNavStickAfterPx,
45
fitHeaderAmountFontSize,
56
pickHeaderDisplayName,
7+
shouldStickCompactFirstHeaderRow,
68
} from "../headerRowFit";
79
import { formatHeaderWalletBalanceLabel } from "../formatHeaderWalletBalanceLabel";
810

@@ -75,3 +77,33 @@ describe("fitHeaderAmountFontSize", () => {
7577
assert.equal(fitHeaderAmountFontSize(120, 60), 15);
7678
});
7779
});
80+
81+
describe("shouldStickCompactFirstHeaderRow", () => {
82+
it("pins only when the top inset is a camera-sized band", () => {
83+
assert.equal(shouldStickCompactFirstHeaderRow(0), false);
84+
assert.equal(shouldStickCompactFirstHeaderRow(22), false);
85+
assert.equal(shouldStickCompactFirstHeaderRow(40), true);
86+
assert.equal(shouldStickCompactFirstHeaderRow(59), true);
87+
});
88+
});
89+
90+
describe("compactNavStickAfterPx", () => {
91+
it("locks Feed/Messages after the first row when that row is not pinned", () => {
92+
assert.equal(
93+
compactNavStickAfterPx({
94+
stickFirstRow: false,
95+
firstRowHeightPx: 50,
96+
collapsibleHeightPx: 80,
97+
}),
98+
130,
99+
);
100+
assert.equal(
101+
compactNavStickAfterPx({
102+
stickFirstRow: true,
103+
firstRowHeightPx: 50,
104+
collapsibleHeightPx: 80,
105+
}),
106+
80,
107+
);
108+
});
109+
});

‎ui/wallet/headerRowFit.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,31 @@ export function pickHeaderDisplayName(options: {
2222
export const HEADER_AMOUNT_FONT_MAX_PX = 30;
2323
export const HEADER_AMOUNT_FONT_MIN_PX = 12;
2424

25+
/**
26+
* Top system inset large enough to be a camera / Dynamic Island / punch-hole band
27+
* (not Telegram’s built-in close-button row alone).
28+
*/
29+
export const COMPACT_CAMERA_BAND_MIN_PX = 40;
30+
31+
/** Pin the compact wallet row only when a camera-style top inset is reserved. */
32+
export function shouldStickCompactFirstHeaderRow(safeAreaInsetTopPx: number): boolean {
33+
return Math.max(0, safeAreaInsetTopPx) >= COMPACT_CAMERA_BAND_MIN_PX;
34+
}
35+
36+
/**
37+
* Scroll distance before Feed/Messages locks. When the first row is not pinned,
38+
* it must scroll away with the collapsing bands first.
39+
*/
40+
export function compactNavStickAfterPx(options: {
41+
stickFirstRow: boolean;
42+
firstRowHeightPx: number;
43+
collapsibleHeightPx: number;
44+
}): number {
45+
const collapsible = Math.max(0, options.collapsibleHeightPx);
46+
if (options.stickFirstRow) return collapsible;
47+
return Math.max(0, options.firstRowHeightPx) + collapsible;
48+
}
49+
2550
/** Shrink amount digits to fit the slot; never clip/abbreviate the numeric string. */
2651
export function fitHeaderAmountFontSize(
2752
naturalWidthPx: number,

0 commit comments

Comments
 (0)