Skip to content

Commit d590e5c

Browse files
committed
feat(app): add global logo bar with TMA SDK and remove default header
- Add GlobalLogoBar matching Dart: safe area padding, fullscreen visibility, haptic on tap, navigate to root - Add TelegramSDKProvider (init + viewport.mount) and GlobalLogoBarWithFallback error boundary - Add HyperlinksSpaceLogo (32x32 inline SVG), GlobalLogoBarFallback for browser - Integrate logo bar above Stack in root layout; hide Stack header (headerShown: false) - Add @tma.js/sdk-react and react-native-svg Made-with: Cursor
1 parent 4516a5c commit d590e5c

9 files changed

Lines changed: 582 additions & 46 deletions

app/app/_layout.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
1+
import { View, StyleSheet } from "react-native";
12
import { Stack } from "expo-router";
3+
import { TelegramSDKProvider } from "./components/TelegramSDKProvider";
4+
import { GlobalLogoBarWithFallback } from "./components/GlobalLogoBarWithFallback";
25

36
export default function RootLayout() {
4-
return <Stack />;
7+
return (
8+
<TelegramSDKProvider>
9+
<View style={styles.root}>
10+
<GlobalLogoBarWithFallback />
11+
<View style={styles.content}>
12+
<Stack screenOptions={{ headerShown: false }} />
13+
</View>
14+
</View>
15+
</TelegramSDKProvider>
16+
);
517
}
18+
19+
const styles = StyleSheet.create({
20+
root: { flex: 1 },
21+
content: { flex: 1 },
22+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Global logo bar matching Dart GlobalLogoBar: same layout, safe area formula,
3+
* fullscreen-based visibility, haptic on tap, navigate to root.
4+
* Uses @tma.js/sdk-react for viewport, isFullscreen, and hapticFeedback.
5+
*/
6+
import React, { useMemo } from "react";
7+
import { View, Pressable, StyleSheet, Platform } from "react-native";
8+
import { useRouter } from "expo-router";
9+
import {
10+
viewport,
11+
hapticFeedback,
12+
useLaunchParams,
13+
useSignal,
14+
} from "@tma.js/sdk-react";
15+
import { HyperlinksSpaceLogo } from "./HyperlinksSpaceLogo";
16+
17+
const LOGO_HEIGHT = 32;
18+
const BOTTOM_PADDING = 10;
19+
const HORIZONTAL_PADDING = 15;
20+
const BROWSER_FALLBACK_TOP_PADDING = 30;
21+
22+
function useLogoTopPadding(): number {
23+
const safeTop = useSignal(viewport.safeAreaInsetTop);
24+
const contentTop = useSignal(viewport.contentSafeAreaInsetTop);
25+
26+
return useMemo(() => {
27+
const safe = Number(safeTop ?? 0);
28+
const content = Number(contentTop ?? 0);
29+
if (safe === 0 && content === 0) return BROWSER_FALLBACK_TOP_PADDING;
30+
const value = safe + content / 2 - 16;
31+
return Number.isFinite(value) ? value : BROWSER_FALLBACK_TOP_PADDING;
32+
}, [safeTop, contentTop]);
33+
}
34+
35+
function useLogoBlockHeight(): number {
36+
const topPadding = useLogoTopPadding();
37+
return topPadding + LOGO_HEIGHT + BOTTOM_PADDING;
38+
}
39+
40+
function useShouldShowLogo(): boolean {
41+
const launchParams = useLaunchParams(false);
42+
const isFullscreen = useSignal(viewport.isFullscreen);
43+
44+
return useMemo(() => {
45+
const lp = launchParams as
46+
| { tgWebAppData?: { user?: unknown }; tg_web_app_data?: { user?: unknown } }
47+
| undefined;
48+
const hasUser =
49+
(lp?.tgWebAppData?.user != null || lp?.tg_web_app_data?.user != null) &&
50+
typeof (lp?.tgWebAppData?.user ?? lp?.tg_web_app_data?.user) === "object";
51+
if (!hasUser) return true;
52+
return isFullscreen ?? true;
53+
}, [launchParams, isFullscreen]);
54+
}
55+
56+
export function GlobalLogoBar() {
57+
const router = useRouter();
58+
const topPadding = useLogoTopPadding();
59+
const blockHeight = useLogoBlockHeight();
60+
const shouldShow = useShouldShowLogo();
61+
62+
const onPress = () => {
63+
try {
64+
hapticFeedback.impactOccurred?.("light");
65+
} catch {
66+
if (Platform.OS === "web" && typeof window !== "undefined") {
67+
try {
68+
const w = window as unknown as { Telegram?: { WebApp?: { HapticFeedback?: { impactOccurred?: (s: string) => void } } } };
69+
w.Telegram?.WebApp?.HapticFeedback?.impactOccurred?.("light");
70+
} catch {
71+
// ignore
72+
}
73+
}
74+
}
75+
router.replace("/");
76+
};
77+
78+
if (!shouldShow) {
79+
return <View style={[styles.container, { height: 0 }]} />;
80+
}
81+
82+
return (
83+
<View style={[styles.container, { height: blockHeight }]}>
84+
<View
85+
style={[
86+
styles.inner,
87+
{
88+
paddingTop: topPadding,
89+
paddingBottom: BOTTOM_PADDING,
90+
paddingHorizontal: HORIZONTAL_PADDING,
91+
},
92+
]}
93+
>
94+
<Pressable
95+
onPress={onPress}
96+
style={styles.logoWrap}
97+
accessibilityRole="button"
98+
accessibilityLabel="Go to home"
99+
>
100+
<View style={styles.logoBox}>
101+
<HyperlinksSpaceLogo width={LOGO_HEIGHT} height={LOGO_HEIGHT} />
102+
</View>
103+
</Pressable>
104+
</View>
105+
</View>
106+
);
107+
}
108+
109+
const styles = StyleSheet.create({
110+
container: {
111+
width: "100%",
112+
backgroundColor: "transparent",
113+
},
114+
inner: {
115+
width: "100%",
116+
alignItems: "center",
117+
justifyContent: "center",
118+
},
119+
logoWrap: {
120+
maxWidth: 600,
121+
alignItems: "center",
122+
justifyContent: "center",
123+
},
124+
logoBox: {
125+
width: LOGO_HEIGHT,
126+
height: LOGO_HEIGHT,
127+
},
128+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Fallback logo bar when TMA SDK is not available (e.g. browser).
3+
* Same layout: 30px top padding, 32px logo, 10px bottom, 15px horizontal.
4+
*/
5+
import React from "react";
6+
import { View, Pressable, StyleSheet } from "react-native";
7+
import { useRouter } from "expo-router";
8+
import { HyperlinksSpaceLogo } from "./HyperlinksSpaceLogo";
9+
10+
const LOGO_HEIGHT = 32;
11+
const BOTTOM_PADDING = 10;
12+
const HORIZONTAL_PADDING = 15;
13+
const BROWSER_FALLBACK_TOP_PADDING = 30;
14+
const BLOCK_HEIGHT =
15+
BROWSER_FALLBACK_TOP_PADDING + LOGO_HEIGHT + BOTTOM_PADDING;
16+
17+
export function GlobalLogoBarFallback() {
18+
const router = useRouter();
19+
20+
return (
21+
<View style={[styles.container, { height: BLOCK_HEIGHT }]}>
22+
<View
23+
style={[
24+
styles.inner,
25+
{
26+
paddingTop: BROWSER_FALLBACK_TOP_PADDING,
27+
paddingBottom: BOTTOM_PADDING,
28+
paddingHorizontal: HORIZONTAL_PADDING,
29+
},
30+
]}
31+
>
32+
<Pressable
33+
onPress={() => router.replace("/")}
34+
style={styles.logoWrap}
35+
accessibilityRole="button"
36+
accessibilityLabel="Go to home"
37+
>
38+
<View style={styles.logoBox}>
39+
<HyperlinksSpaceLogo width={LOGO_HEIGHT} height={LOGO_HEIGHT} />
40+
</View>
41+
</Pressable>
42+
</View>
43+
</View>
44+
);
45+
}
46+
47+
const styles = StyleSheet.create({
48+
container: {
49+
width: "100%",
50+
backgroundColor: "transparent",
51+
},
52+
inner: {
53+
width: "100%",
54+
alignItems: "center",
55+
justifyContent: "center",
56+
},
57+
logoWrap: {
58+
maxWidth: 600,
59+
alignItems: "center",
60+
justifyContent: "center",
61+
},
62+
logoBox: {
63+
width: LOGO_HEIGHT,
64+
height: LOGO_HEIGHT,
65+
},
66+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Renders GlobalLogoBar; on SDK error (e.g. browser) renders GlobalLogoBarFallback.
3+
*/
4+
import React, { Component, type ReactNode } from "react";
5+
import { GlobalLogoBar } from "./GlobalLogoBar";
6+
import { GlobalLogoBarFallback } from "./GlobalLogoBarFallback";
7+
8+
type Props = Record<string, never>;
9+
type State = { hasError: boolean };
10+
11+
export class GlobalLogoBarWithFallback extends Component<Props, State> {
12+
state: State = { hasError: false };
13+
14+
static getDerivedStateFromError(): State {
15+
return { hasError: true };
16+
}
17+
18+
render(): ReactNode {
19+
if (this.state.hasError) {
20+
return <GlobalLogoBarFallback />;
21+
}
22+
return <GlobalLogoBar />;
23+
}
24+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* 32×32 logo matching Dart GlobalLogoBar asset (HyperlinksSpace.svg).
3+
* Inline SVG paths to avoid asset transformer; fill #1AAA11.
4+
*/
5+
import React from "react";
6+
import Svg, { Path } from "react-native-svg";
7+
8+
const LOGO_SIZE = 32;
9+
10+
export function HyperlinksSpaceLogo({
11+
width = LOGO_SIZE,
12+
height = LOGO_SIZE,
13+
}: {
14+
width?: number;
15+
height?: number;
16+
}) {
17+
return (
18+
<Svg width={width} height={height} viewBox="0 0 24 24" fill="none">
19+
<Path
20+
d="M6 24L13.2 19.2L17.28 24H24V0H22.8V22.8H18L6 7.2V24Z"
21+
fill="#1AAA11"
22+
/>
23+
<Path
24+
d="M18 0L10.8 4.8L6.72 0H0V24H1.2V1.2H6L18 16.8V0Z"
25+
fill="#1AAA11"
26+
/>
27+
</Svg>
28+
);
29+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Initializes @tma.js/sdk and mounts viewport + hapticFeedback so GlobalLogoBar
3+
* can use safe area insets, isFullscreen, and haptics. Safe to use in browser
4+
* (init/mount are no-ops or skipped when not in Telegram).
5+
*/
6+
import { useEffect } from "react";
7+
import { init, viewport } from "@tma.js/sdk-react";
8+
9+
export function TelegramSDKProvider({
10+
children,
11+
}: {
12+
children: React.ReactNode;
13+
}) {
14+
useEffect(() => {
15+
try {
16+
init();
17+
viewport.mount?.();
18+
} catch {
19+
// Not in Telegram (e.g. browser) – components will use fallbacks
20+
}
21+
}, []);
22+
23+
return <>{children}</>;
24+
}

app/app/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export default function Index() {
1313
</View>
1414
);
1515
}
16+

0 commit comments

Comments
 (0)