@www.hyperlinks.space/program-kit 18.18.18 → 123.123.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.eas/workflows/create-development-builds.yml +21 -0
  2. package/.eas/workflows/create-draft.yml +15 -0
  3. package/.eas/workflows/deploy-to-production.yml +68 -0
  4. package/.gitattributes +48 -0
  5. package/.gitignore +52 -0
  6. package/.nvmrc +1 -0
  7. package/.vercelignore +6 -0
  8. package/README.md +17 -2
  9. package/ai/openai.ts +202 -0
  10. package/ai/transmitter.ts +367 -0
  11. package/backlogs/medium_term_backlog.md +26 -0
  12. package/backlogs/short_term_backlog.md +42 -0
  13. package/eslint.config.cjs +10 -0
  14. package/npmReadMe.md +17 -2
  15. package/npmrc.example +1 -0
  16. package/package.json +3 -28
  17. package/polyfills/buffer.ts +9 -0
  18. package/research & docs/ai_and_search_bar_input.md +94 -0
  19. package/research & docs/ai_bot_messages.md +124 -0
  20. package/research & docs/auth-and-centralized-encrypted-keys-plan.md +440 -0
  21. package/research & docs/blue_bar_tackling.md +143 -0
  22. package/research & docs/bot_async_streaming.md +174 -0
  23. package/research & docs/build_and_install.md +129 -0
  24. package/research & docs/database_messages.md +34 -0
  25. package/research & docs/fonts.md +18 -0
  26. package/research & docs/github-gitlab-bidirectional-mirroring.md +154 -0
  27. package/research & docs/keys-retrieval-console-scripts.js +131 -0
  28. package/research & docs/npm-release.md +46 -0
  29. package/research & docs/releases.md +201 -0
  30. package/research & docs/releases_github_actions.md +188 -0
  31. package/research & docs/scalability.md +34 -0
  32. package/research & docs/security_plan_raw.md +244 -0
  33. package/research & docs/security_raw.md +354 -0
  34. package/research & docs/storage-availability-console-script.js +152 -0
  35. package/research & docs/storage-lifetime.md +33 -0
  36. package/research & docs/telegram-raw-keys-cloud-storage-risks.md +31 -0
  37. package/research & docs/timing_raw.md +63 -0
  38. package/research & docs/tma_logo_bar_jump_investigation.md +69 -0
  39. package/research & docs/update.md +205 -0
  40. package/research & docs/wallet_telegram_standalone_multichain_proposal.md +192 -0
  41. package/research & docs/wallets_hosting_architecture.md +403 -0
  42. package/services/wallet/tonWallet.ts +73 -0
  43. package/ui/components/GlobalBottomBar.tsx +447 -0
  44. package/ui/components/GlobalBottomBarWeb.tsx +362 -0
  45. package/ui/components/GlobalLogoBar.tsx +108 -0
  46. package/ui/components/GlobalLogoBarFallback.tsx +66 -0
  47. package/ui/components/GlobalLogoBarWithFallback.tsx +24 -0
  48. package/ui/components/HyperlinksSpaceLogo.tsx +29 -0
  49. package/ui/components/Telegram.tsx +677 -0
  50. package/ui/components/telegramWebApp.ts +359 -0
  51. package/ui/fonts.ts +12 -0
  52. package/ui/theme.ts +117 -0
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Web-only GlobalBottomBar implementation using a native <textarea>.
3
+ * Mirrors GlobalBottomBar behaviour (auto-resize 1–8 lines, custom scroll
4
+ * indicator, arrow icon) so we can compare web rendering more directly.
5
+ */
6
+ import React, { useCallback, useEffect, useRef, useState } from "react";
7
+ import { View, Text, StyleSheet, Pressable } from "react-native";
8
+ import Svg, { Path } from "react-native-svg";
9
+ import { useTelegram } from "./Telegram";
10
+ import { getPrimaryTextColorFromLaunch } from "./telegramWebApp";
11
+ import { WEB_UI_SANS_STACK } from "../fonts";
12
+ import { layout, icons, useColors } from "../theme";
13
+
14
+ const { maxContentWidth } = layout;
15
+ const {
16
+ lineHeight: LINE_HEIGHT,
17
+ verticalPadding: VERTICAL_PADDING,
18
+ horizontalPadding: HORIZONTAL_PADDING,
19
+ applyIconBottom: APPLY_ICON_BOTTOM,
20
+ maxLinesBeforeScroll: MAX_LINES_BEFORE_SCROLL,
21
+ maxBarHeight: MAX_BAR_HEIGHT,
22
+ } = layout.bottomBar;
23
+ const INNER_PADDING = 20; // gap above first line and below last line
24
+ const AUTO_SCROLL_THRESHOLD = 30;
25
+ const MAX_INPUT_HEIGHT = (MAX_LINES_BEFORE_SCROLL + 1) * LINE_HEIGHT; // 8 lines = 160 (text-only height)
26
+
27
+ export function GlobalBottomBarWeb() {
28
+ const colors = useColors();
29
+ const { themeBgReady } = useTelegram();
30
+ const backgroundColor = themeBgReady ? colors.background : "transparent";
31
+ const [value, setValue] = useState("");
32
+ const [isFocused, setIsFocused] = useState(false);
33
+ const [scrollY, setScrollY] = useState(0);
34
+ const [domScrollRange, setDomScrollRange] = useState(0);
35
+ const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
36
+ // Height of a DOM-based mirror used to drive both growth and shrink on web.
37
+ const [domMirrorHeight, setDomMirrorHeight] = useState<number | null>(null);
38
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
39
+ const domMirrorRef = useRef<HTMLDivElement | null>(null);
40
+ const wasNearBottomBeforeInputRef = useRef(true);
41
+
42
+ // Until Telegram theme is fully applied in React, use launch hash colors (sync, no grey flash).
43
+ const launchPrimary =
44
+ typeof window !== "undefined" ? getPrimaryTextColorFromLaunch() : null;
45
+ const inputColor = themeBgReady ? colors.primary : (launchPrimary ?? colors.primary);
46
+
47
+ const measureAndResize = useCallback(() => {
48
+ const el = textareaRef.current;
49
+ if (!el) return;
50
+ // Let React control the height via dynamicHeight; we only measure the
51
+ // intrinsic content height (including inner padding).
52
+ el.style.height = "auto";
53
+ const fullScrollHeight = el.scrollHeight;
54
+ setContentHeight(fullScrollHeight);
55
+ }, []);
56
+
57
+ const handleInput = useCallback(
58
+ (e: React.FormEvent<HTMLTextAreaElement>) => {
59
+ const target = e.target as HTMLTextAreaElement;
60
+ const range = Math.max(0, target.scrollHeight - target.clientHeight);
61
+ const isNearBottom =
62
+ range <= 0 || target.scrollTop >= range - AUTO_SCROLL_THRESHOLD;
63
+ wasNearBottomBeforeInputRef.current = isNearBottom;
64
+ setValue(target.value);
65
+ requestAnimationFrame(measureAndResize);
66
+ },
67
+ [measureAndResize]
68
+ );
69
+
70
+ useEffect(() => {
71
+ const el = textareaRef.current;
72
+ if (!el) return;
73
+ const onScroll = () => {
74
+ const scrollTop = el.scrollTop;
75
+ const contentH = el.scrollHeight;
76
+ const clientH = el.clientHeight;
77
+ const range = Math.max(0, contentH - clientH);
78
+ wasNearBottomBeforeInputRef.current =
79
+ range <= 0 || scrollTop >= range - AUTO_SCROLL_THRESHOLD;
80
+ setScrollY(scrollTop);
81
+ setDomScrollRange(range);
82
+ };
83
+ el.addEventListener("scroll", onScroll, { passive: true });
84
+ return () => el.removeEventListener("scroll", onScroll);
85
+ }, [value]);
86
+
87
+ useEffect(() => {
88
+ const id = requestAnimationFrame(() => measureAndResize());
89
+ return () => cancelAnimationFrame(id);
90
+ }, [measureAndResize]);
91
+
92
+ // Web-only DOM mirror: measure real wrapped text height (including INNER_PADDING),
93
+ // using a pixel-identical clone of the textarea's computed styles.
94
+ useEffect(() => {
95
+ if (typeof document === "undefined") return;
96
+
97
+ let el = domMirrorRef.current;
98
+ if (!el) {
99
+ el = document.createElement("div");
100
+ domMirrorRef.current = el;
101
+ el.style.position = "absolute";
102
+ el.style.visibility = "hidden";
103
+ el.style.pointerEvents = "none";
104
+ el.style.whiteSpace = "pre-wrap";
105
+ el.style.wordBreak = "break-word";
106
+ el.style.left = "-9999px";
107
+ el.style.top = "-9999px";
108
+ document.body.appendChild(el);
109
+ }
110
+
111
+ const host = textareaRef.current;
112
+ if (host) {
113
+ const rect = host.getBoundingClientRect();
114
+ const cs = window.getComputedStyle(host);
115
+
116
+ // Match width and all layout‑critical styles.
117
+ el.style.width = `${rect.width}px`;
118
+ el.style.boxSizing = cs.boxSizing;
119
+ el.style.paddingTop = cs.paddingTop;
120
+ el.style.paddingBottom = cs.paddingBottom;
121
+ el.style.paddingLeft = cs.paddingLeft;
122
+ el.style.paddingRight = cs.paddingRight;
123
+ el.style.border = cs.border;
124
+ el.style.outline = cs.outline;
125
+ el.style.fontFamily = cs.fontFamily;
126
+ el.style.fontSize = cs.fontSize;
127
+ el.style.fontWeight = cs.fontWeight as string;
128
+ el.style.lineHeight = cs.lineHeight;
129
+ el.style.letterSpacing = cs.letterSpacing;
130
+ el.style.textTransform = cs.textTransform;
131
+ el.style.direction = cs.direction;
132
+ el.style.textAlign = cs.textAlign;
133
+ }
134
+
135
+ el.textContent = value || " ";
136
+ const h = el.getBoundingClientRect().height;
137
+ setDomMirrorHeight(Number.isFinite(h) && h > 0 ? h : null);
138
+ }, [value]);
139
+
140
+ // Intrinsic text height (without inner gaps). Prefer the DOM mirror
141
+ // measurement when available so shrink-on-erase works reliably.
142
+ const baseHeight =
143
+ domMirrorHeight != null ? domMirrorHeight : contentHeight;
144
+ const effectiveTextHeight = Math.max(0, baseHeight - INNER_PADDING * 2);
145
+ // Use a threshold so we only switch to the next line once most of the
146
+ // next 20px slot is actually used (avoids early jumps on a few pixels).
147
+ const rawLines = Math.max(
148
+ 1,
149
+ Math.floor((effectiveTextHeight + LINE_HEIGHT * 0.2) / LINE_HEIGHT),
150
+ );
151
+ // Visually we allow up to 7 full lines; the 8th+ line uses scroll.
152
+ const visibleLines = Math.min(rawLines, MAX_LINES_BEFORE_SCROLL);
153
+ // Final height: 2 * INNER_PADDING for gaps + visibleLines * lineHeight,
154
+ // clamped between 60 and 180px.
155
+ const dynamicHeight = Math.max(
156
+ 60,
157
+ Math.min(
158
+ MAX_BAR_HEIGHT,
159
+ INNER_PADDING * 2 + visibleLines * LINE_HEIGHT,
160
+ ),
161
+ );
162
+ const rowHeight = dynamicHeight;
163
+ const viewportHeight = rowHeight;
164
+ // Use height that includes 20px top + bottom gaps for scroll math (same as dynamicHeight formula).
165
+ const contentHeightWithGaps = baseHeight;
166
+ const scrollRange = Math.max(contentHeightWithGaps - viewportHeight, 0);
167
+ // Enter scroll mode as soon as content is taller than the visible viewport (i.e. from the 8th line).
168
+ const isScrollMode = contentHeightWithGaps > viewportHeight && scrollRange > 0;
169
+ const showScrollbar = isScrollMode;
170
+ // The scrollbar track always matches the visible input height.
171
+ const barHeight = rowHeight;
172
+
173
+ // Debug: log bar and textarea heights whenever they change so we can
174
+ // verify that they stay perfectly in sync (e.g. 3 lines → 100px).
175
+ useEffect(() => {
176
+ const el = textareaRef.current;
177
+ const domClient = el?.clientHeight ?? null;
178
+ const domScroll = el?.scrollHeight ?? null;
179
+ // eslint-disable-next-line no-console
180
+ console.log("[GlobalBottomBarWeb] heights", {
181
+ lines: rawLines,
182
+ dynamicHeight,
183
+ barHeight,
184
+ viewportHeight,
185
+ contentHeight,
186
+ domMirrorHeight,
187
+ domClient,
188
+ domScroll,
189
+ });
190
+ }, [rawLines, dynamicHeight, barHeight, viewportHeight, contentHeight, domMirrorHeight]);
191
+
192
+
193
+ // When the 7th line first appears (i.e. we reach the max bar height but do
194
+ // not yet need scroll), shift the text up by one inner padding so the last
195
+ // visible line sits perfectly against the arrow baseline.
196
+ useEffect(() => {
197
+ if (typeof document === "undefined") return;
198
+ const el = textareaRef.current;
199
+ if (!el) return;
200
+ const isAtMaxHeight = dynamicHeight >= MAX_BAR_HEIGHT;
201
+ if (
202
+ rawLines === 7 &&
203
+ isAtMaxHeight &&
204
+ el.scrollTop === 0 &&
205
+ wasNearBottomBeforeInputRef.current
206
+ ) {
207
+ el.scrollTop = INNER_PADDING;
208
+ }
209
+ }, [rawLines, dynamicHeight]);
210
+
211
+ // In scroll mode, keep the textarea scrolled to the bottom so the last line
212
+ // and the 20px bottom gap are visible, and the scroll indicator stays at bottom.
213
+ useEffect(() => {
214
+ if (typeof document === "undefined") return;
215
+ const el = textareaRef.current;
216
+ if (!el || !isScrollMode) return;
217
+ if (!wasNearBottomBeforeInputRef.current) return;
218
+ const range = el.scrollHeight - el.clientHeight;
219
+ if (range <= 0) return;
220
+ const raf = requestAnimationFrame(() => {
221
+ el.scrollTop = range;
222
+ setScrollY(range);
223
+ setDomScrollRange(range);
224
+ });
225
+ return () => cancelAnimationFrame(raf);
226
+ }, [value, isScrollMode]);
227
+
228
+ let indicatorHeight = 0;
229
+ let topPosition = 0;
230
+ const effectiveScrollRange = domScrollRange > 0 ? domScrollRange : scrollRange;
231
+ if (showScrollbar && effectiveScrollRange > 0 && contentHeightWithGaps > 0 && barHeight != null) {
232
+ const indicatorHeightRatio = Math.min(1, Math.max(0, viewportHeight / contentHeightWithGaps));
233
+ indicatorHeight = Math.min(barHeight, Math.max(0, barHeight * indicatorHeightRatio));
234
+ const scrollPosition = Math.min(1, Math.max(0, scrollY / effectiveScrollRange));
235
+ const availableSpace = Math.min(barHeight, Math.max(0, barHeight - indicatorHeight));
236
+ topPosition = Math.min(barHeight, Math.max(0, scrollPosition * availableSpace));
237
+ }
238
+
239
+ const handleSend = useCallback(() => {
240
+ const text = value.trim();
241
+ if (!text) return;
242
+ setValue("");
243
+ // Optional: navigate to AI with prompt like GlobalBottomBar
244
+ }, [value]);
245
+
246
+ return (
247
+ <View style={[styles.wrapper, { backgroundColor }]}>
248
+ <View style={[styles.block, { backgroundColor }]}>
249
+ <View style={[styles.row, { height: rowHeight }]}>
250
+ <View style={styles.inputWrap}>
251
+ {/* eslint-disable-next-line jsx-a11y/no-autofocus */}
252
+ <textarea
253
+ ref={textareaRef}
254
+ data-global-bottom-bar-web
255
+ value={value}
256
+ onInput={handleInput}
257
+ onFocus={() => setIsFocused(true)}
258
+ onBlur={() => setIsFocused(false)}
259
+ rows={1}
260
+ style={{
261
+ width: "100%",
262
+ // Keep the DOM min/height/max in sync with our computed
263
+ // dynamicHeight so there is no intermediate smaller box
264
+ // (e.g. 60px when we expect 80px on 3 lines).
265
+ minHeight: dynamicHeight,
266
+ height: dynamicHeight,
267
+ maxHeight: dynamicHeight,
268
+ fontSize: 15,
269
+ lineHeight: `${LINE_HEIGHT}px`,
270
+ paddingTop: INNER_PADDING,
271
+ paddingBottom: INNER_PADDING,
272
+ paddingRight: 36,
273
+ boxSizing: "border-box",
274
+ resize: "none",
275
+ border: "none",
276
+ outline: "none",
277
+ color: inputColor,
278
+ backgroundColor: "transparent",
279
+ caretColor: inputColor,
280
+ ["--ai-placeholder-color" as string]: inputColor,
281
+ fontFamily: WEB_UI_SANS_STACK,
282
+ // Allow scroll exactly when content exceeds the visible viewport height.
283
+ overflow: contentHeightWithGaps > viewportHeight ? "auto" : "hidden",
284
+ }}
285
+ placeholder={isFocused ? "" : "AI and search"}
286
+ />
287
+ </View>
288
+ <Pressable style={styles.arrowWrap} onPress={handleSend} accessibilityRole="button" accessibilityLabel="Send">
289
+ <Svg width={icons.apply.width} height={icons.apply.height} viewBox="0 0 15 10">
290
+ <Path
291
+ d="M1 5H10M6 1L10 5L6 9"
292
+ stroke={inputColor}
293
+ strokeWidth={1.5}
294
+ strokeLinecap="round"
295
+ strokeLinejoin="round"
296
+ />
297
+ </Svg>
298
+ </Pressable>
299
+ </View>
300
+ </View>
301
+ {showScrollbar && indicatorHeight > 0 && barHeight != null && (
302
+ <View style={[styles.scrollbarContainer, { height: barHeight }]}>
303
+ <View
304
+ style={[
305
+ styles.scrollbarIndicator,
306
+ { height: indicatorHeight, marginTop: topPosition, backgroundColor: colors.secondary },
307
+ ]}
308
+ />
309
+ </View>
310
+ )}
311
+ </View>
312
+ );
313
+ }
314
+
315
+ const SCROLLBAR_INSET = 5;
316
+
317
+ const styles = StyleSheet.create({
318
+ wrapper: {
319
+ width: "100%",
320
+ position: "relative",
321
+ },
322
+ block: {
323
+ width: "100%",
324
+ maxWidth: maxContentWidth,
325
+ alignSelf: "center",
326
+ paddingHorizontal: HORIZONTAL_PADDING,
327
+ // No vertical gap outside the input; the textarea occupies the full bar height.
328
+ paddingTop: 0,
329
+ paddingBottom: 0,
330
+ },
331
+ row: {
332
+ flexDirection: "row",
333
+ // Let children fill the full bar height; vertical positioning is handled
334
+ // inside each child (textarea via INNER_PADDING, arrow via paddingBottom).
335
+ alignItems: "stretch",
336
+ gap: 5,
337
+ position: "relative",
338
+ },
339
+ inputWrap: {
340
+ flex: 1,
341
+ position: "relative",
342
+ // Keep the textarea stuck to the top of the bar; vertical gaps are
343
+ // handled via INNER_PADDING inside the textarea itself.
344
+ justifyContent: "flex-start",
345
+ },
346
+ arrowWrap: {
347
+ // Stick the arrow icon to the bottom edge of the bar with 25px padding.
348
+ justifyContent: "flex-end",
349
+ alignItems: "center",
350
+ paddingBottom: 25,
351
+ },
352
+ scrollbarContainer: {
353
+ position: "absolute",
354
+ right: SCROLLBAR_INSET,
355
+ top: 0,
356
+ alignItems: "flex-start",
357
+ justifyContent: "flex-start",
358
+ },
359
+ scrollbarIndicator: {
360
+ width: 1,
361
+ },
362
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Global logo bar: same layout and behaviour as Dart GlobalLogoBar.
3
+ * All Telegram data from useTelegram() (single source: Telegram.ts / Telegram.tsx).
4
+ */
5
+ import React, { useMemo } from "react";
6
+ import { View, Pressable, StyleSheet } from "react-native";
7
+ import { useRouter } from "expo-router";
8
+ import { useTelegram } from "./Telegram";
9
+ import { HyperlinksSpaceLogo } from "./HyperlinksSpaceLogo";
10
+ import { useColors } from "../theme";
11
+
12
+ const LOGO_HEIGHT = 32;
13
+ const BOTTOM_PADDING = 10;
14
+ const HORIZONTAL_PADDING = 15;
15
+ const BROWSER_FALLBACK_TOP_PADDING = 30;
16
+
17
+ function useLogoTopPadding(
18
+ safeAreaInsetTop: number,
19
+ contentSafeAreaInsetTop: number
20
+ ): number {
21
+ return useMemo(() => {
22
+ if (safeAreaInsetTop === 0 && contentSafeAreaInsetTop === 0) {
23
+ return BROWSER_FALLBACK_TOP_PADDING;
24
+ }
25
+ const value = safeAreaInsetTop + contentSafeAreaInsetTop / 2 - 16;
26
+ return Number.isFinite(value) ? value : BROWSER_FALLBACK_TOP_PADDING;
27
+ }, [safeAreaInsetTop, contentSafeAreaInsetTop]);
28
+ }
29
+
30
+ export function GlobalLogoBar() {
31
+ const router = useRouter();
32
+ const colors = useColors();
33
+ const {
34
+ isInTelegram,
35
+ triggerHaptic,
36
+ safeAreaInsetTop,
37
+ contentSafeAreaInsetTop,
38
+ isFullscreen,
39
+ themeBgReady,
40
+ } = useTelegram();
41
+
42
+ const backgroundColor = themeBgReady ? colors.background : "transparent";
43
+
44
+ const topPadding = useLogoTopPadding(safeAreaInsetTop, contentSafeAreaInsetTop);
45
+ const blockHeight = topPadding + LOGO_HEIGHT + BOTTOM_PADDING;
46
+
47
+ const shouldShow = useMemo(() => {
48
+ if (!isInTelegram) return true;
49
+ return isFullscreen;
50
+ }, [isInTelegram, isFullscreen]);
51
+
52
+ const onPress = () => {
53
+ triggerHaptic("light");
54
+ router.replace("/");
55
+ };
56
+
57
+ if (!shouldShow) {
58
+ return <View style={[styles.container, { height: 0, backgroundColor }]} />;
59
+ }
60
+
61
+ return (
62
+ <View style={[styles.container, { height: blockHeight, backgroundColor }]}>
63
+ <View
64
+ style={[
65
+ styles.inner,
66
+ {
67
+ paddingTop: topPadding,
68
+ paddingBottom: BOTTOM_PADDING,
69
+ paddingHorizontal: HORIZONTAL_PADDING,
70
+ },
71
+ ]}
72
+ >
73
+ <Pressable
74
+ onPress={onPress}
75
+ style={styles.logoWrap}
76
+ accessibilityRole="button"
77
+ accessibilityLabel="Go to home"
78
+ >
79
+ <View style={styles.logoBox}>
80
+ <HyperlinksSpaceLogo width={LOGO_HEIGHT} height={LOGO_HEIGHT} />
81
+ </View>
82
+ </Pressable>
83
+ </View>
84
+ </View>
85
+ );
86
+ }
87
+
88
+ const styles = StyleSheet.create({
89
+ container: {
90
+ width: "100%",
91
+ backgroundColor: "transparent",
92
+ flexShrink: 0, /* keep header fixed height when keyboard opens (flex layout, no shift) */
93
+ },
94
+ inner: {
95
+ width: "100%",
96
+ alignItems: "center",
97
+ justifyContent: "center",
98
+ },
99
+ logoWrap: {
100
+ maxWidth: 600,
101
+ alignItems: "center",
102
+ justifyContent: "center",
103
+ },
104
+ logoBox: {
105
+ width: LOGO_HEIGHT,
106
+ height: LOGO_HEIGHT,
107
+ },
108
+ });
@@ -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
+ });
@@ -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
+ }
@@ -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
+ }