@www.hyperlinks.space/program-kit 1.2.181818 → 7.8.3
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.
- package/.eas/workflows/create-development-builds.yml +21 -0
- package/.eas/workflows/create-draft.yml +15 -0
- package/.eas/workflows/deploy-to-production.yml +68 -0
- package/.env.example +19 -0
- package/.gitattributes +48 -0
- package/.gitignore +52 -0
- package/.nvmrc +1 -0
- package/.vercelignore +6 -0
- package/README.md +7 -2
- package/ai/openai.ts +202 -0
- package/ai/transmitter.ts +367 -0
- package/api/{base.ts → _base.ts} +1 -1
- package/api/wallet/_auth.ts +143 -0
- package/api/wallet/register.ts +151 -0
- package/api/wallet/status.ts +89 -0
- package/app/index.tsx +319 -5
- package/assets/images/PreviewImage.png +0 -0
- package/backlogs/medium_term_backlog.md +26 -0
- package/backlogs/short_term_backlog.md +42 -0
- package/database/start.ts +0 -1
- package/database/wallets.ts +266 -0
- package/eslint.config.cjs +10 -0
- package/fullREADME.md +142 -71
- package/index.js +3 -0
- package/npmReadMe.md +7 -2
- package/npmrc.example +1 -0
- package/package.json +7 -27
- package/polyfills/buffer.ts +9 -0
- package/research & docs/auth-and-centralized-encrypted-keys-plan.md +440 -0
- package/research & docs/github-gitlab-bidirectional-mirroring.md +154 -0
- package/research & docs/keys-retrieval-console-scripts.js +131 -0
- package/{docs/security_plan_raw.md → research & docs/security_plan_raw.md } +1 -1
- package/{docs/security_raw.md → research & docs/security_raw.md } +22 -13
- package/research & docs/storage-availability-console-script.js +152 -0
- package/research & docs/storage-lifetime.md +33 -0
- package/research & docs/telegram-raw-keys-cloud-storage-risks.md +31 -0
- package/{docs/wallets_hosting_architecture.md → research & docs/wallets_hosting_architecture.md } +147 -1
- package/scripts/test-api-base.ts +2 -2
- package/services/wallet/tonWallet.ts +73 -0
- package/telegram/post.ts +44 -8
- package/ui/components/GlobalBottomBar.tsx +447 -0
- package/ui/components/GlobalBottomBarWeb.tsx +362 -0
- package/ui/components/GlobalLogoBar.tsx +108 -0
- package/ui/components/GlobalLogoBarFallback.tsx +66 -0
- package/ui/components/GlobalLogoBarWithFallback.tsx +24 -0
- package/ui/components/HyperlinksSpaceLogo.tsx +29 -0
- package/ui/components/Telegram.tsx +677 -0
- package/ui/components/telegramWebApp.ts +359 -0
- package/ui/fonts.ts +12 -0
- package/ui/theme.ts +117 -0
- /package/{docs → research & docs}/ai_and_search_bar_input.md +0 -0
- /package/{docs → research & docs}/ai_bot_messages.md +0 -0
- /package/{docs → research & docs}/blue_bar_tackling.md +0 -0
- /package/{docs → research & docs}/bot_async_streaming.md +0 -0
- /package/{docs → research & docs}/build_and_install.md +0 -0
- /package/{docs → research & docs}/database_messages.md +0 -0
- /package/{docs → research & docs}/fonts.md +0 -0
- /package/{docs → research & docs}/npm-release.md +0 -0
- /package/{docs → research & docs}/releases.md +0 -0
- /package/{docs → research & docs}/releases_github_actions.md +0 -0
- /package/{docs → research & docs}/scalability.md +0 -0
- /package/{docs → research & docs}/timing_raw.md +0 -0
- /package/{docs → research & docs}/tma_logo_bar_jump_investigation.md +0 -0
- /package/{docs → research & docs}/update.md +0 -0
- /package/{docs → research & docs}/wallet_telegram_standalone_multichain_proposal.md +0 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { init, on, viewport } from "@tma.js/sdk-react";
|
|
3
|
+
import { on as onBridge } from "@tma.js/bridge";
|
|
4
|
+
import {
|
|
5
|
+
ensureTelegramScript,
|
|
6
|
+
getInitDataString,
|
|
7
|
+
getStartParam,
|
|
8
|
+
getInitialThemeParams,
|
|
9
|
+
getPlatformFromHash,
|
|
10
|
+
getThemeParamsFromLaunch,
|
|
11
|
+
getWebAppVersionFromHash,
|
|
12
|
+
isAvailable,
|
|
13
|
+
readyAndExpand,
|
|
14
|
+
resetTelegramLaunchCache,
|
|
15
|
+
triggerHaptic as triggerHapticImpl,
|
|
16
|
+
} from "./telegramWebApp";
|
|
17
|
+
import { buildApiUrl } from "../../api/_base";
|
|
18
|
+
|
|
19
|
+
let sdkInitialized = false;
|
|
20
|
+
function ensureSdkInitialized() {
|
|
21
|
+
if (sdkInitialized) return;
|
|
22
|
+
if (typeof window === "undefined") return;
|
|
23
|
+
try {
|
|
24
|
+
init();
|
|
25
|
+
sdkInitialized = true;
|
|
26
|
+
} catch {
|
|
27
|
+
// ignore (e.g. outside Mini App when running locally)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof window !== "undefined") {
|
|
32
|
+
ensureSdkInitialized();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True if we're likely inside Telegram Mini App (avoid tma.js viewport calls when false). */
|
|
36
|
+
function isLikelyInTma(): boolean {
|
|
37
|
+
if (typeof window === "undefined") return false;
|
|
38
|
+
try {
|
|
39
|
+
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sync signals only (hash / UA / WebApp presence). Do not use for API calls. */
|
|
46
|
+
function isTelegramLikelyAtStartup(): boolean {
|
|
47
|
+
if (typeof window === "undefined") return false;
|
|
48
|
+
try {
|
|
49
|
+
if (getThemeParamsFromLaunch() != null) return true;
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const hash = window.location.hash ?? "";
|
|
55
|
+
if (hash.includes("tgWebApp")) return true;
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (getPlatformFromHash() != null || getWebAppVersionFromHash() != null) return true;
|
|
61
|
+
} catch {
|
|
62
|
+
// ignore
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const ua = (window.navigator?.userAgent ?? "").toLowerCase();
|
|
66
|
+
if (ua.includes("telegram")) return true;
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore
|
|
69
|
+
}
|
|
70
|
+
return isAvailable();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeHexBg(raw: unknown): string | null {
|
|
74
|
+
if (typeof raw !== "string") return null;
|
|
75
|
+
const s = raw.trim();
|
|
76
|
+
if (/^#([0-9a-fA-F]{6})$/.test(s)) return s;
|
|
77
|
+
const m3 = /^#([0-9a-fA-F]{3})$/.exec(s);
|
|
78
|
+
if (m3) {
|
|
79
|
+
const x = m3[1];
|
|
80
|
+
return `#${x[0]}${x[0]}${x[1]}${x[1]}${x[2]}${x[2]}`.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Only `bg_color` defines the main chat background in Telegram theme_params.
|
|
87
|
+
* Do NOT fall back to secondary_bg_color / section_bg_color for light/dark app scheme:
|
|
88
|
+
* those can be dark panels while the client is in light mode → false "dark" + flash.
|
|
89
|
+
*/
|
|
90
|
+
function getBgColorForScheme(tp: Record<string, string> | null | undefined): string | null {
|
|
91
|
+
if (!tp) return null;
|
|
92
|
+
return normalizeHexBg(tp.bg_color);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function classifyThemeFromBgColor(bgColor: string | undefined | null): "dark" | "light" {
|
|
96
|
+
if (!bgColor || typeof bgColor !== "string") return "dark";
|
|
97
|
+
const m = bgColor.trim().match(/^#([0-9a-fA-F]{6})$/);
|
|
98
|
+
if (!m) return "dark";
|
|
99
|
+
const hex = m[1];
|
|
100
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
101
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
102
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
103
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
104
|
+
const scheme = luminance < 128 ? "dark" : "light";
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log("[TMA theme] classify", { bgColor: bgColor.trim(), luminance, scheme });
|
|
107
|
+
return scheme;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Mini App when hash/UA says so OR init data / WebApp is present (isAvailable). */
|
|
111
|
+
function isMiniAppContext(): boolean {
|
|
112
|
+
return isTelegramLikelyAtStartup() || isAvailable();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function initialColorSchemeFromBootstrap(): "dark" | "light" {
|
|
116
|
+
// Real scheme comes from Telegram.WebApp in runTmaFlow — never from launch hash (hash bg_color
|
|
117
|
+
// can disagree with WebApp and flash dark before "initial themeParams bg: #ffffff").
|
|
118
|
+
return "dark";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function initialThemeBgReadyFromBootstrap(): boolean {
|
|
122
|
+
// Must match server + client. SSR returned true here while client used false → React #418 + wrong tree.
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type TelegramStatus = "idle" | "loading" | "ok" | "error" | "dev";
|
|
127
|
+
|
|
128
|
+
export type TelegramDebugInfo = {
|
|
129
|
+
hasWebApp: boolean;
|
|
130
|
+
webAppPollCount: number;
|
|
131
|
+
initDataLength: number | null;
|
|
132
|
+
pollCount: number;
|
|
133
|
+
apiStatus: number | null;
|
|
134
|
+
apiMessage: string | null;
|
|
135
|
+
/** URL we POST to (to verify origin/routing). */
|
|
136
|
+
apiUrl: string | null;
|
|
137
|
+
/** Ms from fetch start to response or timeout. */
|
|
138
|
+
fetchDurationMs: number | null;
|
|
139
|
+
/** Last client log line for investigation. */
|
|
140
|
+
lastLog: string | null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type TelegramContextValue = {
|
|
144
|
+
status: TelegramStatus;
|
|
145
|
+
telegramUsername: string | null;
|
|
146
|
+
hasWallet: boolean | null;
|
|
147
|
+
walletRequired: boolean;
|
|
148
|
+
wallet: {
|
|
149
|
+
id: number;
|
|
150
|
+
wallet_address: string;
|
|
151
|
+
wallet_blockchain: string;
|
|
152
|
+
wallet_net: string;
|
|
153
|
+
type: string;
|
|
154
|
+
label: string | null;
|
|
155
|
+
is_default: boolean;
|
|
156
|
+
source: string | null;
|
|
157
|
+
} | null;
|
|
158
|
+
initData: string | null;
|
|
159
|
+
error: string | null;
|
|
160
|
+
isInTelegram: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* Use Telegram palette (launch + colorScheme) — true when in Mini App context OR status is not dev.
|
|
163
|
+
* Differs from isInTelegram when status is "dev" but tgWebApp hash/init data exists (theme.ts must not force dark).
|
|
164
|
+
*/
|
|
165
|
+
useTelegramTheme: boolean;
|
|
166
|
+
/** "dark" | "light" per Telegram theme; dark is default/fallback. */
|
|
167
|
+
colorScheme: "dark" | "light";
|
|
168
|
+
/** True once we have a valid Telegram theme bg_color and can safely paint our custom palette. */
|
|
169
|
+
themeBgReady: boolean;
|
|
170
|
+
/** False on SSR/first paint, true after client mount — keeps useColors in sync with server HTML (hydration). */
|
|
171
|
+
clientHydrated: boolean;
|
|
172
|
+
triggerHaptic: (style: string) => void;
|
|
173
|
+
safeAreaInsetTop: number;
|
|
174
|
+
contentSafeAreaInsetTop: number;
|
|
175
|
+
isFullscreen: boolean;
|
|
176
|
+
/** Start param from launch (query or hash). Valid per Telegram: A-Za-z0-9_- up to 512 chars. */
|
|
177
|
+
startParam: string | null;
|
|
178
|
+
/** On-screen debug (no console needed in TMA). */
|
|
179
|
+
debug: TelegramDebugInfo;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const defaultDebug: TelegramDebugInfo = {
|
|
183
|
+
hasWebApp: false,
|
|
184
|
+
webAppPollCount: 0,
|
|
185
|
+
initDataLength: null,
|
|
186
|
+
pollCount: 0,
|
|
187
|
+
apiStatus: null,
|
|
188
|
+
apiMessage: null,
|
|
189
|
+
apiUrl: null,
|
|
190
|
+
fetchDurationMs: null,
|
|
191
|
+
lastLog: null,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const WEBAPP_POLL_MS = 100;
|
|
195
|
+
const WEBAPP_POLL_MAX = 50; // 5s wait for Telegram to inject WebApp
|
|
196
|
+
|
|
197
|
+
const defaultContext: TelegramContextValue = {
|
|
198
|
+
status: "idle",
|
|
199
|
+
telegramUsername: null,
|
|
200
|
+
hasWallet: null,
|
|
201
|
+
walletRequired: false,
|
|
202
|
+
wallet: null,
|
|
203
|
+
initData: null,
|
|
204
|
+
error: null,
|
|
205
|
+
isInTelegram: false,
|
|
206
|
+
useTelegramTheme: false,
|
|
207
|
+
colorScheme: "dark",
|
|
208
|
+
themeBgReady: false,
|
|
209
|
+
clientHydrated: false,
|
|
210
|
+
triggerHaptic: () => {},
|
|
211
|
+
safeAreaInsetTop: 0,
|
|
212
|
+
contentSafeAreaInsetTop: 0,
|
|
213
|
+
isFullscreen: true,
|
|
214
|
+
startParam: null,
|
|
215
|
+
debug: defaultDebug,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const TelegramContext = createContext<TelegramContextValue>(defaultContext);
|
|
219
|
+
|
|
220
|
+
export function useTelegram() {
|
|
221
|
+
return useContext(TelegramContext);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function TelegramProvider({ children }: { children: React.ReactNode }) {
|
|
225
|
+
const [status, setStatus] = useState<TelegramStatus>("idle");
|
|
226
|
+
const [telegramUsername, setTelegramUsername] = useState<string | null>(null);
|
|
227
|
+
const [hasWallet, setHasWallet] = useState<boolean | null>(null);
|
|
228
|
+
const [walletRequired, setWalletRequired] = useState(false);
|
|
229
|
+
const [wallet, setWallet] = useState<TelegramContextValue["wallet"]>(null);
|
|
230
|
+
const [initData, setInitData] = useState<string | null>(null);
|
|
231
|
+
const [error, setError] = useState<string | null>(null);
|
|
232
|
+
const [debug, setDebug] = useState<TelegramDebugInfo>(defaultDebug);
|
|
233
|
+
const hasRegisteredRef = useRef(false);
|
|
234
|
+
const initPollCleanupRef = useRef<(() => void) | null>(null);
|
|
235
|
+
/** Block SDK/bridge theme events until runTmaFlow has applied WebApp theme (avoids stale dark WebApp). */
|
|
236
|
+
const tmaInitialThemeResolvedRef = useRef(false);
|
|
237
|
+
|
|
238
|
+
const [safeAreaInsetTop, setSafeAreaInsetTop] = useState(0);
|
|
239
|
+
const [contentSafeAreaInsetTop, setContentSafeAreaInsetTop] = useState(0);
|
|
240
|
+
const [isFullscreen, setIsFullscreen] = useState(true);
|
|
241
|
+
const [colorScheme, setColorScheme] = useState<"dark" | "light">(initialColorSchemeFromBootstrap);
|
|
242
|
+
|
|
243
|
+
// Client starts hidden (themeBgReady false) until plain-web unlock (useLayoutEffect) or TMA runTmaFlow.
|
|
244
|
+
const [themeBgReady, setThemeBgReady] = useState<boolean>(initialThemeBgReadyFromBootstrap);
|
|
245
|
+
const [clientHydrated, setClientHydrated] = useState(false);
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
setClientHydrated(true);
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
// Plain web: unlock immediately. TMA: do NOT paint from launch hash — it can mismatch WebApp
|
|
251
|
+
// (dark classify in hash vs bg #ffffff in WebApp); only runTmaFlow uses WebApp.themeParams.
|
|
252
|
+
useLayoutEffect(() => {
|
|
253
|
+
if (typeof window === "undefined") return;
|
|
254
|
+
function unlockPlainWebIfNeeded(): void {
|
|
255
|
+
resetTelegramLaunchCache();
|
|
256
|
+
if (isMiniAppContext()) return;
|
|
257
|
+
setThemeBgReady(true);
|
|
258
|
+
}
|
|
259
|
+
unlockPlainWebIfNeeded();
|
|
260
|
+
const raf = requestAnimationFrame(() => unlockPlainWebIfNeeded());
|
|
261
|
+
return () => cancelAnimationFrame(raf);
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
// Hash changes: re-read WebApp (authoritative), not tgWebAppThemeParams from hash alone.
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (typeof window === "undefined") return;
|
|
267
|
+
if (!isMiniAppContext()) return;
|
|
268
|
+
const onHashChange = () => {
|
|
269
|
+
resetTelegramLaunchCache();
|
|
270
|
+
const bg = getBgColorForScheme(getInitialThemeParams());
|
|
271
|
+
if (bg) {
|
|
272
|
+
setColorScheme(classifyThemeFromBgColor(bg));
|
|
273
|
+
setThemeBgReady(true);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
window.addEventListener("hashchange", onHashChange);
|
|
277
|
+
return () => window.removeEventListener("hashchange", onHashChange);
|
|
278
|
+
}, []);
|
|
279
|
+
|
|
280
|
+
// Live theme updates: SDK + bridge + native WebApp (no polling).
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (typeof window === "undefined") return;
|
|
283
|
+
|
|
284
|
+
let cleanupSdk: (() => void) | undefined;
|
|
285
|
+
let cleanupBridge: (() => void) | undefined;
|
|
286
|
+
let cleanupNative: (() => void) | undefined;
|
|
287
|
+
let nativeAttached = false;
|
|
288
|
+
|
|
289
|
+
function updateScheme(next: "dark" | "light") {
|
|
290
|
+
setColorScheme((prev) => {
|
|
291
|
+
if (prev === next) return prev;
|
|
292
|
+
// eslint-disable-next-line no-console
|
|
293
|
+
console.log("[TMA theme] update colorScheme", { from: prev, to: next });
|
|
294
|
+
return next;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function markThemeBgReady(): void {
|
|
299
|
+
setThemeBgReady((prev) => {
|
|
300
|
+
if (prev) return prev;
|
|
301
|
+
// eslint-disable-next-line no-console
|
|
302
|
+
console.log("[TMA theme] themeBgReady=true");
|
|
303
|
+
return true;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function applyFromWebApp(): void {
|
|
308
|
+
if (!tmaInitialThemeResolvedRef.current) return;
|
|
309
|
+
const tp = getInitialThemeParams();
|
|
310
|
+
const bg = getBgColorForScheme(tp);
|
|
311
|
+
if (!bg) return;
|
|
312
|
+
const scheme = classifyThemeFromBgColor(bg);
|
|
313
|
+
updateScheme(scheme);
|
|
314
|
+
markThemeBgReady();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function computeSchemeFromPayload(payload: unknown): void {
|
|
318
|
+
if (!tmaInitialThemeResolvedRef.current) return;
|
|
319
|
+
const anyPayload = payload as unknown as {
|
|
320
|
+
color_scheme?: string;
|
|
321
|
+
theme_params?: Record<string, string>;
|
|
322
|
+
} | null;
|
|
323
|
+
|
|
324
|
+
const explicit = anyPayload?.color_scheme;
|
|
325
|
+
if (explicit === "dark" || explicit === "light") {
|
|
326
|
+
updateScheme(explicit);
|
|
327
|
+
markThemeBgReady();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const tp = anyPayload?.theme_params;
|
|
332
|
+
const bg = getBgColorForScheme(tp);
|
|
333
|
+
if (!bg) return;
|
|
334
|
+
const scheme = classifyThemeFromBgColor(bg);
|
|
335
|
+
updateScheme(scheme);
|
|
336
|
+
markThemeBgReady();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function tryAttachNativeThemeOnce(): void {
|
|
340
|
+
if (nativeAttached) return;
|
|
341
|
+
try {
|
|
342
|
+
const app = (window as Window).Telegram?.WebApp as unknown as {
|
|
343
|
+
onEvent?: (eventType: string, cb: () => void) => void;
|
|
344
|
+
offEvent?: (eventType: string, cb: () => void) => void;
|
|
345
|
+
} | null;
|
|
346
|
+
if (!app || typeof app.onEvent !== "function") return;
|
|
347
|
+
|
|
348
|
+
const handler = () => applyFromWebApp();
|
|
349
|
+
app.onEvent("themeChanged", handler);
|
|
350
|
+
nativeAttached = true;
|
|
351
|
+
cleanupNative = () => {
|
|
352
|
+
try {
|
|
353
|
+
if (typeof app.offEvent === "function") {
|
|
354
|
+
app.offEvent("themeChanged", handler);
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// ignore
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
} catch {
|
|
361
|
+
// ignore
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
ensureSdkInitialized();
|
|
367
|
+
cleanupSdk = on("theme_changed", (payload) => computeSchemeFromPayload(payload));
|
|
368
|
+
} catch {
|
|
369
|
+
// ignore
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
cleanupBridge = onBridge("theme_changed", (payload) =>
|
|
374
|
+
computeSchemeFromPayload(payload),
|
|
375
|
+
);
|
|
376
|
+
} catch {
|
|
377
|
+
// ignore
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (isTelegramLikelyAtStartup()) {
|
|
381
|
+
tryAttachNativeThemeOnce();
|
|
382
|
+
ensureTelegramScript(() => tryAttachNativeThemeOnce());
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return () => {
|
|
386
|
+
try {
|
|
387
|
+
cleanupSdk?.();
|
|
388
|
+
} catch {
|
|
389
|
+
// ignore
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
cleanupBridge?.();
|
|
393
|
+
} catch {
|
|
394
|
+
// ignore
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
cleanupNative?.();
|
|
398
|
+
} catch {
|
|
399
|
+
// ignore
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}, []);
|
|
403
|
+
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!isLikelyInTma()) return;
|
|
406
|
+
try {
|
|
407
|
+
ensureSdkInitialized();
|
|
408
|
+
viewport.mount?.();
|
|
409
|
+
setSafeAreaInsetTop(viewport.safeAreaInsetTop ?? 0);
|
|
410
|
+
setContentSafeAreaInsetTop(viewport.contentSafeAreaInsetTop ?? 0);
|
|
411
|
+
setIsFullscreen(viewport.isFullscreen ?? true);
|
|
412
|
+
} catch {
|
|
413
|
+
// outside Mini App (e.g. local dev) — leave defaults
|
|
414
|
+
}
|
|
415
|
+
}, []);
|
|
416
|
+
|
|
417
|
+
// TMA-only: layout height and scroll come from TMA. When keyboard opens,
|
|
418
|
+
// nothing changes until TMA sends viewport_changed; theme updates are
|
|
419
|
+
// handled via useThemeParams above.
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
if (typeof window === "undefined" || !isLikelyInTma()) return;
|
|
422
|
+
|
|
423
|
+
// iOS: viewport-fit=cover avoids white gap at bottom when keyboard opens
|
|
424
|
+
const meta = document.querySelector('meta[name="viewport"]');
|
|
425
|
+
if (meta) {
|
|
426
|
+
const c = meta.getAttribute("content") ?? "";
|
|
427
|
+
if (!c.includes("viewport-fit=cover")) {
|
|
428
|
+
meta.setAttribute("content", [c, "viewport-fit=cover"].filter(Boolean).join(", "));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function lockScroll() {
|
|
433
|
+
if (window.scrollY > 0) window.scrollTo(0, 0);
|
|
434
|
+
}
|
|
435
|
+
window.addEventListener("scroll", lockScroll, { passive: false });
|
|
436
|
+
|
|
437
|
+
let tmaCleanup: (() => void) | null = null;
|
|
438
|
+
viewport.mount?.().then(() => {
|
|
439
|
+
try {
|
|
440
|
+
const unbindCss = viewport.bindCssVars?.();
|
|
441
|
+
// viewport_changed (height, width?, is_expanded, is_state_stable). Only reset scroll when state is stable.
|
|
442
|
+
const removeViewportListener = on(
|
|
443
|
+
"viewport_changed",
|
|
444
|
+
(payload: {
|
|
445
|
+
height: number;
|
|
446
|
+
width?: number;
|
|
447
|
+
is_expanded?: boolean;
|
|
448
|
+
is_state_stable?: boolean;
|
|
449
|
+
isExpanded?: boolean;
|
|
450
|
+
isStateStable?: boolean;
|
|
451
|
+
}) => {
|
|
452
|
+
const stable = payload.is_state_stable ?? payload.isStateStable ?? false;
|
|
453
|
+
if (stable) window.scrollTo(0, 0);
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
tmaCleanup = () => {
|
|
458
|
+
unbindCss?.();
|
|
459
|
+
removeViewportListener?.();
|
|
460
|
+
};
|
|
461
|
+
} catch {
|
|
462
|
+
// ignore
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return () => {
|
|
467
|
+
window.removeEventListener("scroll", lockScroll);
|
|
468
|
+
tmaCleanup?.();
|
|
469
|
+
};
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
useEffect(() => {
|
|
473
|
+
if (typeof window === "undefined") {
|
|
474
|
+
setDebug((d) => ({ ...d, hasWebApp: false, apiMessage: "no window" }));
|
|
475
|
+
setStatus("dev");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
setStatus("loading");
|
|
480
|
+
ensureTelegramScript();
|
|
481
|
+
|
|
482
|
+
const API_TIMEOUT_MS = 15000;
|
|
483
|
+
const LOG_PREFIX = "[TMA register]";
|
|
484
|
+
|
|
485
|
+
function registerWithBackend(initData: string) {
|
|
486
|
+
if (hasRegisteredRef.current) return;
|
|
487
|
+
hasRegisteredRef.current = true;
|
|
488
|
+
setInitData(initData);
|
|
489
|
+
|
|
490
|
+
const url = buildApiUrl("/api/telegram");
|
|
491
|
+
const fetchStartedAt = Date.now();
|
|
492
|
+
|
|
493
|
+
setDebug((d) => ({
|
|
494
|
+
...d,
|
|
495
|
+
initDataLength: initData.length,
|
|
496
|
+
apiUrl: url,
|
|
497
|
+
fetchDurationMs: null,
|
|
498
|
+
lastLog: "fetch start",
|
|
499
|
+
}));
|
|
500
|
+
console.log(`${LOG_PREFIX} fetch start url=${url} initDataLength=${initData.length}`);
|
|
501
|
+
|
|
502
|
+
const controller = new AbortController();
|
|
503
|
+
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
|
504
|
+
|
|
505
|
+
fetch(url, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: { "Content-Type": "application/json" },
|
|
508
|
+
body: JSON.stringify({ initData }),
|
|
509
|
+
signal: controller.signal,
|
|
510
|
+
})
|
|
511
|
+
.then(async (res) => {
|
|
512
|
+
clearTimeout(timeoutId);
|
|
513
|
+
const durationMs = Date.now() - fetchStartedAt;
|
|
514
|
+
const json = await res.json().catch(() => ({}));
|
|
515
|
+
const apiMsg = json?.error ?? (json?.ok ? "ok" : String(res.status));
|
|
516
|
+
|
|
517
|
+
setDebug((d) => ({
|
|
518
|
+
...d,
|
|
519
|
+
apiStatus: res.status,
|
|
520
|
+
apiMessage: apiMsg,
|
|
521
|
+
fetchDurationMs: durationMs,
|
|
522
|
+
lastLog: `status ${res.status} ${durationMs}ms`,
|
|
523
|
+
}));
|
|
524
|
+
console.log(`${LOG_PREFIX} response status=${res.status} durationMs=${durationMs} body=${apiMsg}`);
|
|
525
|
+
|
|
526
|
+
if (!res.ok || !json?.ok) {
|
|
527
|
+
throw new Error(json?.error || `HTTP ${res.status}`);
|
|
528
|
+
}
|
|
529
|
+
setTelegramUsername(json.telegram_username ?? null);
|
|
530
|
+
setHasWallet(typeof json.has_wallet === "boolean" ? json.has_wallet : null);
|
|
531
|
+
setWalletRequired(Boolean(json.wallet_required));
|
|
532
|
+
setWallet(json?.wallet ?? null);
|
|
533
|
+
setStatus("ok");
|
|
534
|
+
})
|
|
535
|
+
.catch((e) => {
|
|
536
|
+
clearTimeout(timeoutId);
|
|
537
|
+
const durationMs = Date.now() - fetchStartedAt;
|
|
538
|
+
const isTimeout = e?.name === "AbortError";
|
|
539
|
+
const msg = isTimeout ? "timeout" : e?.message ?? "fetch error";
|
|
540
|
+
const lastLog = isTimeout
|
|
541
|
+
? `timeout after ${durationMs}ms`
|
|
542
|
+
: `error ${durationMs}ms: ${msg}`;
|
|
543
|
+
|
|
544
|
+
setDebug((d) => ({
|
|
545
|
+
...d,
|
|
546
|
+
apiStatus: null,
|
|
547
|
+
apiMessage: msg,
|
|
548
|
+
fetchDurationMs: durationMs,
|
|
549
|
+
lastLog,
|
|
550
|
+
}));
|
|
551
|
+
console.error(`${LOG_PREFIX} failed ${lastLog}`, e);
|
|
552
|
+
|
|
553
|
+
setError(isTimeout ? "Request timed out" : (e?.message ?? "Failed to register Telegram user"));
|
|
554
|
+
setStatus("error");
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function runTmaFlow(): () => void {
|
|
559
|
+
readyAndExpand();
|
|
560
|
+
|
|
561
|
+
// Initial theme: WebApp first (matches Telegram UI). Launch hash can disagree → dark flash.
|
|
562
|
+
try {
|
|
563
|
+
const launchTp = getThemeParamsFromLaunch();
|
|
564
|
+
const webTp = getInitialThemeParams();
|
|
565
|
+
const bg = getBgColorForScheme(webTp) ?? getBgColorForScheme(launchTp);
|
|
566
|
+
// eslint-disable-next-line no-console
|
|
567
|
+
console.log("[TMA theme] initial themeParams", { launch: launchTp, web: webTp }, "bg:", bg);
|
|
568
|
+
if (bg) {
|
|
569
|
+
setColorScheme(classifyThemeFromBgColor(bg));
|
|
570
|
+
setThemeBgReady((prev) => {
|
|
571
|
+
if (prev) return prev;
|
|
572
|
+
// eslint-disable-next-line no-console
|
|
573
|
+
console.log("[TMA theme] themeBgReady=true");
|
|
574
|
+
return true;
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
// ignore; keep default "dark"
|
|
579
|
+
} finally {
|
|
580
|
+
tmaInitialThemeResolvedRef.current = true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let initDataStr = getInitDataString();
|
|
584
|
+
if (initDataStr) {
|
|
585
|
+
registerWithBackend(initDataStr);
|
|
586
|
+
return () => {};
|
|
587
|
+
}
|
|
588
|
+
let pollCount = 0;
|
|
589
|
+
const initInterval = setInterval(() => {
|
|
590
|
+
pollCount += 1;
|
|
591
|
+
setDebug((d) => ({ ...d, pollCount }));
|
|
592
|
+
initDataStr = getInitDataString();
|
|
593
|
+
if (initDataStr) {
|
|
594
|
+
clearInterval(initInterval);
|
|
595
|
+
registerWithBackend(initDataStr);
|
|
596
|
+
}
|
|
597
|
+
}, WEBAPP_POLL_MS);
|
|
598
|
+
return () => clearInterval(initInterval);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let webAppPollCount = 0;
|
|
602
|
+
let webAppInterval: ReturnType<typeof setInterval> | undefined;
|
|
603
|
+
|
|
604
|
+
function tryAttachWebApp(): boolean {
|
|
605
|
+
if (!isAvailable()) return false;
|
|
606
|
+
if (webAppInterval != null) {
|
|
607
|
+
clearInterval(webAppInterval);
|
|
608
|
+
webAppInterval = undefined;
|
|
609
|
+
}
|
|
610
|
+
setDebug((d) => ({ ...d, hasWebApp: true }));
|
|
611
|
+
initPollCleanupRef.current = runTmaFlow();
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Run once immediately — avoids extra 100ms dark frame while waiting for first interval tick.
|
|
616
|
+
if (!tryAttachWebApp()) {
|
|
617
|
+
webAppInterval = setInterval(() => {
|
|
618
|
+
webAppPollCount += 1;
|
|
619
|
+
setDebug((d) => ({ ...d, webAppPollCount }));
|
|
620
|
+
|
|
621
|
+
if (tryAttachWebApp()) return;
|
|
622
|
+
|
|
623
|
+
if (webAppPollCount >= WEBAPP_POLL_MAX) {
|
|
624
|
+
if (webAppInterval != null) clearInterval(webAppInterval);
|
|
625
|
+
webAppInterval = undefined;
|
|
626
|
+
setDebug((d) => ({ ...d, apiMessage: "no WebApp (timeout)" }));
|
|
627
|
+
setStatus("dev");
|
|
628
|
+
}
|
|
629
|
+
}, WEBAPP_POLL_MS);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return () => {
|
|
633
|
+
if (webAppInterval != null) clearInterval(webAppInterval);
|
|
634
|
+
initPollCleanupRef.current?.();
|
|
635
|
+
};
|
|
636
|
+
}, []);
|
|
637
|
+
|
|
638
|
+
// Plain web only: after WebApp poll times out we set status "dev" — ensure UI is visible.
|
|
639
|
+
// Do not force themeBgReady in Mini App (would show dark before runTmaFlow applies launch theme).
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
if (status !== "dev") return;
|
|
642
|
+
if (isMiniAppContext()) return;
|
|
643
|
+
setThemeBgReady(true);
|
|
644
|
+
}, [status]);
|
|
645
|
+
|
|
646
|
+
const isInTelegram = status !== "dev";
|
|
647
|
+
const useTelegramTheme =
|
|
648
|
+
status !== "dev" ||
|
|
649
|
+
(typeof window !== "undefined" && (isTelegramLikelyAtStartup() || isAvailable()));
|
|
650
|
+
|
|
651
|
+
const value: TelegramContextValue = {
|
|
652
|
+
status,
|
|
653
|
+
telegramUsername,
|
|
654
|
+
hasWallet,
|
|
655
|
+
walletRequired,
|
|
656
|
+
wallet,
|
|
657
|
+
initData,
|
|
658
|
+
error,
|
|
659
|
+
isInTelegram,
|
|
660
|
+
useTelegramTheme,
|
|
661
|
+
colorScheme,
|
|
662
|
+
themeBgReady,
|
|
663
|
+
clientHydrated,
|
|
664
|
+
triggerHaptic: triggerHapticImpl,
|
|
665
|
+
safeAreaInsetTop,
|
|
666
|
+
contentSafeAreaInsetTop,
|
|
667
|
+
isFullscreen,
|
|
668
|
+
startParam: getStartParam(),
|
|
669
|
+
debug,
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
return (
|
|
673
|
+
<TelegramContext.Provider value={value}>
|
|
674
|
+
{children}
|
|
675
|
+
</TelegramContext.Provider>
|
|
676
|
+
);
|
|
677
|
+
}
|