@www.hyperlinks.space/program-kit 1.2.91881 → 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.
Files changed (65) 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/.env.example +19 -0
  5. package/.gitattributes +48 -0
  6. package/.gitignore +52 -0
  7. package/.nvmrc +1 -0
  8. package/.vercelignore +6 -0
  9. package/README.md +10 -5
  10. package/ai/openai.ts +202 -0
  11. package/ai/transmitter.ts +367 -0
  12. package/api/{base.ts → _base.ts} +1 -1
  13. package/api/wallet/_auth.ts +143 -0
  14. package/api/wallet/register.ts +151 -0
  15. package/api/wallet/status.ts +89 -0
  16. package/app/index.tsx +319 -5
  17. package/assets/images/PreviewImage.png +0 -0
  18. package/backlogs/medium_term_backlog.md +26 -0
  19. package/backlogs/short_term_backlog.md +42 -0
  20. package/database/start.ts +0 -1
  21. package/database/wallets.ts +266 -0
  22. package/eslint.config.cjs +10 -0
  23. package/fullREADME.md +142 -71
  24. package/index.js +3 -0
  25. package/npmReadMe.md +10 -5
  26. package/npmrc.example +1 -0
  27. package/package.json +7 -27
  28. package/polyfills/buffer.ts +9 -0
  29. package/research & docs/auth-and-centralized-encrypted-keys-plan.md +440 -0
  30. package/research & docs/github-gitlab-bidirectional-mirroring.md +154 -0
  31. package/research & docs/keys-retrieval-console-scripts.js +131 -0
  32. package/{docs/security_plan_raw.md → research & docs/security_plan_raw.md } +1 -1
  33. package/{docs/security_raw.md → research & docs/security_raw.md } +22 -13
  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/{docs/wallets_hosting_architecture.md → research & docs/wallets_hosting_architecture.md } +147 -1
  38. package/scripts/test-api-base.ts +2 -2
  39. package/services/wallet/tonWallet.ts +73 -0
  40. package/telegram/post.ts +44 -8
  41. package/ui/components/GlobalBottomBar.tsx +447 -0
  42. package/ui/components/GlobalBottomBarWeb.tsx +362 -0
  43. package/ui/components/GlobalLogoBar.tsx +108 -0
  44. package/ui/components/GlobalLogoBarFallback.tsx +66 -0
  45. package/ui/components/GlobalLogoBarWithFallback.tsx +24 -0
  46. package/ui/components/HyperlinksSpaceLogo.tsx +29 -0
  47. package/ui/components/Telegram.tsx +677 -0
  48. package/ui/components/telegramWebApp.ts +359 -0
  49. package/ui/fonts.ts +12 -0
  50. package/ui/theme.ts +117 -0
  51. /package/{docs → research & docs}/ai_and_search_bar_input.md +0 -0
  52. /package/{docs → research & docs}/ai_bot_messages.md +0 -0
  53. /package/{docs → research & docs}/blue_bar_tackling.md +0 -0
  54. /package/{docs → research & docs}/bot_async_streaming.md +0 -0
  55. /package/{docs → research & docs}/build_and_install.md +0 -0
  56. /package/{docs → research & docs}/database_messages.md +0 -0
  57. /package/{docs → research & docs}/fonts.md +0 -0
  58. /package/{docs → research & docs}/npm-release.md +0 -0
  59. /package/{docs → research & docs}/releases.md +0 -0
  60. /package/{docs → research & docs}/releases_github_actions.md +0 -0
  61. /package/{docs → research & docs}/scalability.md +0 -0
  62. /package/{docs → research & docs}/timing_raw.md +0 -0
  63. /package/{docs → research & docs}/tma_logo_bar_jump_investigation.md +0 -0
  64. /package/{docs → research & docs}/update.md +0 -0
  65. /package/{docs → research & docs}/wallet_telegram_standalone_multichain_proposal.md +0 -0
@@ -0,0 +1,73 @@
1
+ import { mnemonicNew, mnemonicToPrivateKey } from "@ton/crypto";
2
+ import { WalletContractV4 } from "@ton/ton";
3
+ import { Buffer as BufferPolyfill } from "buffer";
4
+
5
+ if (typeof globalThis !== "undefined" && !(globalThis as { Buffer?: unknown }).Buffer) {
6
+ (globalThis as { Buffer?: unknown }).Buffer = BufferPolyfill;
7
+ }
8
+
9
+ function bytesToBase64(bytes: Uint8Array): string {
10
+ let binary = "";
11
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
12
+ if (typeof btoa === "function") return btoa(binary);
13
+ return BufferPolyfill.from(bytes).toString("base64");
14
+ }
15
+
16
+ async function sha256Bytes(input: string): Promise<Uint8Array> {
17
+ const digest = await crypto.subtle.digest(
18
+ "SHA-256",
19
+ new TextEncoder().encode(input),
20
+ );
21
+ return new Uint8Array(digest);
22
+ }
23
+
24
+ export async function generateMnemonic(words = 24): Promise<string[]> {
25
+ return mnemonicNew(words);
26
+ }
27
+
28
+ export async function deriveAddressFromMnemonic(opts: {
29
+ mnemonic: string[];
30
+ testnet?: boolean;
31
+ workchain?: number;
32
+ }): Promise<string> {
33
+ const { mnemonic, testnet = false, workchain = 0 } = opts;
34
+ const keyPair = await mnemonicToPrivateKey(mnemonic);
35
+ const wallet = WalletContractV4.create({
36
+ workchain,
37
+ publicKey: keyPair.publicKey,
38
+ });
39
+ return wallet.address.toString({
40
+ bounceable: false,
41
+ urlSafe: true,
42
+ testOnly: testnet,
43
+ });
44
+ }
45
+
46
+ export async function deriveMasterKeyFromMnemonic(mnemonic: string[]): Promise<string> {
47
+ const bytes = await sha256Bytes(mnemonic.join(" "));
48
+ return bytesToBase64(bytes);
49
+ }
50
+
51
+ export async function createSeedCipher(
52
+ masterKey: string,
53
+ seed: string,
54
+ ): Promise<string> {
55
+ const keyMaterial = await crypto.subtle.importKey(
56
+ "raw",
57
+ await sha256Bytes(masterKey),
58
+ { name: "AES-GCM" },
59
+ false,
60
+ ["encrypt"],
61
+ );
62
+
63
+ const iv = crypto.getRandomValues(new Uint8Array(12));
64
+ const encrypted = await crypto.subtle.encrypt(
65
+ { name: "AES-GCM", iv },
66
+ keyMaterial,
67
+ new TextEncoder().encode(seed),
68
+ );
69
+
70
+ const cipherBytes = new Uint8Array(encrypted);
71
+ return `v1.${bytesToBase64(iv)}.${bytesToBase64(cipherBytes)}`;
72
+ }
73
+
package/telegram/post.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  normalizeUsername,
8
8
  upsertUserFromTma,
9
9
  } from '../database/users.js';
10
+ import { getDefaultWalletByUsername } from '../database/wallets.js';
10
11
 
11
12
  const LOG_TAG = '[api/telegram]';
12
13
 
@@ -302,10 +303,53 @@ export async function handlePost(
302
303
  const dbStart = Date.now();
303
304
  try {
304
305
  await upsertUserFromTma({ telegramUsername, locale });
306
+ const wallet = await getDefaultWalletByUsername(telegramUsername);
305
307
  log('db_upsert_done', {
306
308
  dbMs: Date.now() - dbStart,
307
309
  elapsedMs: Date.now() - startMs,
310
+ hasWallet: !!wallet,
308
311
  });
312
+
313
+ if (wallet) {
314
+ log('success', {
315
+ telegramUsername,
316
+ hasWallet: true,
317
+ totalMs: Date.now() - startMs,
318
+ });
319
+ return new Response(
320
+ JSON.stringify({
321
+ ok: true,
322
+ telegram_username: telegramUsername,
323
+ has_wallet: true,
324
+ wallet: {
325
+ id: wallet.id,
326
+ wallet_address: wallet.wallet_address,
327
+ wallet_blockchain: wallet.wallet_blockchain,
328
+ wallet_net: wallet.wallet_net,
329
+ type: wallet.type,
330
+ label: wallet.label,
331
+ is_default: wallet.is_default,
332
+ source: wallet.source,
333
+ },
334
+ }),
335
+ { status: 200, headers: { 'content-type': 'application/json' } },
336
+ );
337
+ }
338
+
339
+ log('success', {
340
+ telegramUsername,
341
+ hasWallet: false,
342
+ totalMs: Date.now() - startMs,
343
+ });
344
+ return new Response(
345
+ JSON.stringify({
346
+ ok: true,
347
+ telegram_username: telegramUsername,
348
+ has_wallet: false,
349
+ wallet_required: true,
350
+ }),
351
+ { status: 200, headers: { 'content-type': 'application/json' } },
352
+ );
309
353
  } catch (e) {
310
354
  logErr('db_upsert_failed', e);
311
355
  return new Response(
@@ -317,12 +361,4 @@ export async function handlePost(
317
361
  );
318
362
  }
319
363
 
320
- log('success', {
321
- telegramUsername,
322
- totalMs: Date.now() - startMs,
323
- });
324
- return new Response(
325
- JSON.stringify({ ok: true, telegram_username: telegramUsername }),
326
- { status: 200, headers: { 'content-type': 'application/json' } },
327
- );
328
364
  }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Global AI & Search bar (bottom block).
3
+ *
4
+ * This mirrors the Flutter GlobalBottomBar behaviour:
5
+ * - 20px line height, 20px top/bottom padding
6
+ * - Bar grows from 1–7 lines, then caps at 180px and enables internal scroll
7
+ * - Last line stays pinned 20px from the bottom while typing
8
+ * - Apply icon is always 25px from the bottom
9
+ */
10
+ import React, { useCallback, useEffect, useRef, useState } from "react";
11
+ import {
12
+ View,
13
+ Text,
14
+ TextInput,
15
+ Pressable,
16
+ StyleSheet,
17
+ Keyboard,
18
+ ScrollView,
19
+ Platform,
20
+ type NativeSyntheticEvent,
21
+ type TextInputSubmitEditingEventData,
22
+ type TextInputContentSizeChangeEventData,
23
+ type NativeScrollEvent,
24
+ type NativeSyntheticEvent as RnNativeEvent,
25
+ } from "react-native";
26
+ import { useRouter } from "expo-router";
27
+ import { useTelegram } from "./Telegram";
28
+ import Svg, { Path } from "react-native-svg";
29
+ import { layout, icons, useColors } from "../theme";
30
+
31
+ const { maxContentWidth } = layout;
32
+ const {
33
+ barMinHeight: BAR_MIN_HEIGHT,
34
+ horizontalPadding: HORIZONTAL_PADDING,
35
+ verticalPadding: VERTICAL_PADDING,
36
+ applyIconBottom: APPLY_ICON_BOTTOM,
37
+ lineHeight: LINE_HEIGHT,
38
+ maxLinesBeforeScroll: MAX_LINES_BEFORE_SCROLL,
39
+ maxBarHeight: MAX_BAR_HEIGHT,
40
+ } = layout.bottomBar;
41
+ const FONT_SIZE = 15;
42
+ // Same as web: 20px gap above first line and below last line inside the input.
43
+ const INNER_PADDING = 20;
44
+ const AUTO_SCROLL_THRESHOLD = 30;
45
+ const PREMADE_PROMPTS = [
46
+ "What is the universe?",
47
+ "Tell me about dogs token",
48
+ ];
49
+
50
+ export function GlobalBottomBar() {
51
+ const router = useRouter();
52
+ const { triggerHaptic, themeBgReady } = useTelegram();
53
+ const colors = useColors();
54
+ const backgroundColor = themeBgReady ? colors.background : "transparent";
55
+ const [value, setValue] = useState("");
56
+ const [isFocused, setIsFocused] = useState(false);
57
+ const inputRef = useRef<TextInput>(null);
58
+ const scrollRef = useRef<ScrollView>(null);
59
+ const [contentHeight, setContentHeight] = useState<number>(LINE_HEIGHT);
60
+ // Height of a hidden mirrored Text used for shrink (web) and grow (native when contentSize is unreliable).
61
+ const [mirrorHeight, setMirrorHeight] = useState<number | null>(null);
62
+ // Width of the input area so the mirror Text can wrap correctly on native (iOS/Android).
63
+ const [inputAreaWidth, setInputAreaWidth] = useState<number | null>(null);
64
+ const [scrollY, setScrollY] = useState(0);
65
+ const scrollYRef = useRef(0);
66
+ const contentHeightWithGapsRef = useRef(LINE_HEIGHT + INNER_PADDING * 2);
67
+ const wasNearBottomBeforeResizeRef = useRef(true);
68
+
69
+ const isTelegramIOSWeb =
70
+ Platform.OS === "web" &&
71
+ typeof window !== "undefined" &&
72
+ !!(window as any).Telegram?.WebApp &&
73
+ (window as any).Telegram.WebApp.platform === "ios";
74
+
75
+ // Web-only: wire up a native scroll listener on the underlying textarea
76
+ // rendered by TextInput so we can track manual scroll that React Native Web
77
+ // may not surface via onScroll.
78
+ useEffect(() => {
79
+ if (Platform.OS !== "web") return;
80
+ if (typeof document === "undefined") return;
81
+
82
+ const el = document.querySelector(
83
+ '[data-ai-input="true"]',
84
+ ) as HTMLElement | null;
85
+ if (!el) return;
86
+
87
+ const handleScroll = () => {
88
+ const scrollTop = (el as HTMLTextAreaElement).scrollTop;
89
+ if (typeof scrollTop !== "number") return;
90
+ setScrollY(scrollTop);
91
+ };
92
+
93
+ el.addEventListener("scroll", handleScroll, { passive: true });
94
+ return () => {
95
+ el.removeEventListener("scroll", handleScroll);
96
+ };
97
+ }, []);
98
+
99
+ const submit = useCallback(() => {
100
+ triggerHaptic("heavy");
101
+ let text = value.trim();
102
+ if (!text && PREMADE_PROMPTS.length > 0) {
103
+ text =
104
+ PREMADE_PROMPTS[
105
+ Math.floor(Math.random() * PREMADE_PROMPTS.length)
106
+ ] ?? "";
107
+ setValue(text);
108
+ }
109
+ if (!text) return;
110
+ Keyboard.dismiss();
111
+ setValue("");
112
+ router.push({ pathname: "/ai" as any, params: { prompt: text } });
113
+ }, [value, router, triggerHaptic]);
114
+
115
+ const onSubmitEditing = useCallback(
116
+ (_e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
117
+ submit();
118
+ },
119
+ [submit]
120
+ );
121
+
122
+ const onContentSizeChange = useCallback(
123
+ (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
124
+ const h = e.nativeEvent.contentSize.height;
125
+ if (!Number.isFinite(h)) return;
126
+ setContentHeight(h);
127
+ },
128
+ []
129
+ );
130
+
131
+ const onChangeText = useCallback((text: string) => {
132
+ setValue(text);
133
+ }, []);
134
+
135
+ const onScroll = useCallback(
136
+ (e: RnNativeEvent<NativeScrollEvent>) => {
137
+ const y = e.nativeEvent.contentOffset.y;
138
+ scrollYRef.current = y;
139
+ setScrollY(y);
140
+ },
141
+ [],
142
+ );
143
+
144
+ // Same formula as GlobalBottomBarWeb: base height includes 20px top + bottom gaps.
145
+ // Mirror is given paddingVertical so mirrorHeight = content with gaps; else contentHeight is text-only from onContentSizeChange.
146
+ const baseHeight =
147
+ mirrorHeight != null
148
+ ? mirrorHeight
149
+ : contentHeight + INNER_PADDING * 2;
150
+ const effectiveTextHeight = Math.max(0, baseHeight - INNER_PADDING * 2);
151
+ const rawLines = Math.max(
152
+ 1,
153
+ Math.floor(
154
+ (effectiveTextHeight + LINE_HEIGHT * 0.2) / LINE_HEIGHT,
155
+ ),
156
+ );
157
+ const visibleLines = Math.min(rawLines, MAX_LINES_BEFORE_SCROLL);
158
+ const dynamicHeight = Math.max(
159
+ 60,
160
+ Math.min(
161
+ MAX_BAR_HEIGHT,
162
+ INNER_PADDING * 2 + visibleLines * LINE_HEIGHT,
163
+ ),
164
+ );
165
+
166
+ const barHeight = dynamicHeight;
167
+ const viewportHeight = barHeight;
168
+ const contentHeightWithGaps = baseHeight;
169
+ const scrollRange = Math.max(contentHeightWithGaps - viewportHeight, 0);
170
+ const isScrollMode =
171
+ contentHeightWithGaps > viewportHeight && scrollRange > 0;
172
+ const showScrollbar = isScrollMode;
173
+
174
+ let indicatorHeight = 0;
175
+ let topPosition = 0;
176
+ if (
177
+ showScrollbar &&
178
+ scrollRange > 0 &&
179
+ contentHeightWithGaps > 0 &&
180
+ barHeight != null
181
+ ) {
182
+ const indicatorHeightRatio = Math.min(
183
+ 1,
184
+ Math.max(0, viewportHeight / contentHeightWithGaps),
185
+ );
186
+ indicatorHeight = Math.min(
187
+ barHeight,
188
+ Math.max(0, barHeight * indicatorHeightRatio),
189
+ );
190
+ const scrollPosition = Math.min(1, Math.max(0, scrollY / scrollRange));
191
+ const availableSpace = Math.min(
192
+ barHeight,
193
+ Math.max(0, barHeight - indicatorHeight),
194
+ );
195
+ topPosition = Math.min(
196
+ barHeight,
197
+ Math.max(0, scrollPosition * availableSpace),
198
+ );
199
+ }
200
+
201
+ // When the 7th line first appears (max bar height, no scroll yet), shift
202
+ // content up by one inner padding so the last visible line aligns with the arrow (same as web).
203
+ useEffect(() => {
204
+ if (
205
+ rawLines === 7 &&
206
+ dynamicHeight >= MAX_BAR_HEIGHT &&
207
+ scrollY === 0 &&
208
+ wasNearBottomBeforeResizeRef.current
209
+ ) {
210
+ scrollRef.current?.scrollTo({ y: INNER_PADDING, animated: false });
211
+ }
212
+ }, [rawLines, dynamicHeight, scrollY]);
213
+
214
+ // Snap the ScrollView to bottom whenever the content becomes taller than
215
+ // the visible viewport. Using onContentSizeChange ensures the scroll
216
+ // happens after iOS has laid out the content, so scrollToEnd is effective.
217
+ const onScrollViewContentSizeChange = useCallback(
218
+ (_w: number, h: number) => {
219
+ const previousScrollRange = Math.max(
220
+ contentHeightWithGapsRef.current - viewportHeight,
221
+ 0,
222
+ );
223
+ const isNearBottomBeforeResize =
224
+ previousScrollRange <= 0 ||
225
+ scrollYRef.current >= previousScrollRange - AUTO_SCROLL_THRESHOLD;
226
+ wasNearBottomBeforeResizeRef.current = isNearBottomBeforeResize;
227
+ contentHeightWithGapsRef.current = h;
228
+
229
+ if (h > viewportHeight && scrollRef.current && isNearBottomBeforeResize) {
230
+ scrollRef.current.scrollToEnd({ animated: false });
231
+ }
232
+ },
233
+ [viewportHeight],
234
+ );
235
+
236
+ return (
237
+ <View style={[styles.wrapper, { height: barHeight, backgroundColor }]}>
238
+ <View style={[styles.container, { height: barHeight, backgroundColor }]}>
239
+ <View style={styles.inner}>
240
+ <View style={styles.row}>
241
+ <View style={{ flex: 1 }}>
242
+ <View
243
+ style={{
244
+ height: viewportHeight,
245
+ justifyContent: "flex-start",
246
+ }}
247
+ >
248
+ <ScrollView
249
+ ref={scrollRef}
250
+ style={{ flex: 1 }}
251
+ contentContainerStyle={{
252
+ paddingRight: 6,
253
+ flexGrow: 1,
254
+ justifyContent: "flex-start",
255
+ }}
256
+ onScroll={onScroll}
257
+ onContentSizeChange={onScrollViewContentSizeChange}
258
+ scrollEventThrottle={16}
259
+ showsVerticalScrollIndicator={false}
260
+ >
261
+ <View
262
+ style={{
263
+ flexGrow: 1,
264
+ justifyContent: "flex-start",
265
+ position: "relative",
266
+ }}
267
+ onLayout={
268
+ Platform.OS !== "web"
269
+ ? (e) => {
270
+ const w = e.nativeEvent.layout.width;
271
+ if (Number.isFinite(w) && w > 0) setInputAreaWidth(w);
272
+ }
273
+ : undefined
274
+ }
275
+ >
276
+ <TextInput
277
+ ref={inputRef}
278
+ style={[styles.input, styles.inputWeb, { color: colors.primary }]}
279
+ placeholder={isFocused ? "" : "AI & Search"}
280
+ placeholderTextColor={colors.primary}
281
+ value={value}
282
+ onChangeText={onChangeText}
283
+ onSubmitEditing={onSubmitEditing}
284
+ returnKeyType="send"
285
+ blurOnSubmit={false}
286
+ multiline
287
+ maxLength={4096}
288
+ onContentSizeChange={onContentSizeChange}
289
+ scrollEnabled={false}
290
+ onFocus={() => setIsFocused(true)}
291
+ onBlur={() => setIsFocused(false)}
292
+ // @ts-expect-error dataSet is a valid prop on web (used for CSS targeting)
293
+ dataSet={{ "ai-input": "true" }}
294
+ />
295
+ {Platform.OS === "web" && (
296
+ <View
297
+ pointerEvents="none"
298
+ style={{
299
+ position: "absolute",
300
+ top: 0,
301
+ bottom: 0,
302
+ right: 0,
303
+ // Wider gutter on Telegram iOS webview so the
304
+ // native blue scroll thumb (if drawn) sits well
305
+ // away from the caret and last characters.
306
+ width: isTelegramIOSWeb ? 24 : 12,
307
+ backgroundColor,
308
+ }}
309
+ />
310
+ )}
311
+ <Text
312
+ style={[
313
+ styles.input,
314
+ styles.inputWeb,
315
+ {
316
+ position: "absolute",
317
+ opacity: 0,
318
+ pointerEvents: "none",
319
+ left: 0,
320
+ right: 0,
321
+ paddingVertical: INNER_PADDING,
322
+ // On native, give mirror explicit width so it wraps like the input and reports correct height.
323
+ ...(Platform.OS !== "web" &&
324
+ inputAreaWidth != null && { width: inputAreaWidth }),
325
+ },
326
+ ]}
327
+ numberOfLines={0}
328
+ onLayout={(e) => {
329
+ const h = e.nativeEvent.layout.height;
330
+ if (Number.isFinite(h) && h > 0) {
331
+ setMirrorHeight(h);
332
+ }
333
+ }}
334
+ >
335
+ {value || " "}
336
+ </Text>
337
+ </View>
338
+ </ScrollView>
339
+ </View>
340
+ </View>
341
+ <Pressable
342
+ style={styles.applyWrap}
343
+ onPress={submit}
344
+ accessibilityRole="button"
345
+ accessibilityLabel="Send"
346
+ >
347
+ <Svg
348
+ width={icons.apply.width}
349
+ height={icons.apply.height}
350
+ viewBox="0 0 15 10"
351
+ >
352
+ <Path
353
+ d="M1 5H10M6 1L10 5L6 9"
354
+ stroke={colors.primary}
355
+ strokeWidth={1.5}
356
+ strokeLinecap="round"
357
+ strokeLinejoin="round"
358
+ />
359
+ </Svg>
360
+ </Pressable>
361
+ </View>
362
+ </View>
363
+ </View>
364
+ {showScrollbar && indicatorHeight > 0 && (
365
+ <View style={[styles.scrollbarContainer, { height: barHeight }]}>
366
+ <View
367
+ style={[
368
+ styles.scrollbarIndicator,
369
+ {
370
+ height: indicatorHeight,
371
+ marginTop: topPosition,
372
+ backgroundColor: colors.secondary,
373
+ },
374
+ ]}
375
+ />
376
+ </View>
377
+ )}
378
+ </View>
379
+ );
380
+ }
381
+
382
+ const SCROLLBAR_INSET = 5;
383
+
384
+ const styles = StyleSheet.create({
385
+ wrapper: {
386
+ width: "100%",
387
+ position: "relative",
388
+ },
389
+ container: {
390
+ width: "100%",
391
+ maxWidth: maxContentWidth,
392
+ alignSelf: "center",
393
+ // backgroundColor is applied dynamically via useColors()
394
+ paddingVertical: 0,
395
+ paddingHorizontal: HORIZONTAL_PADDING,
396
+ },
397
+ inner: {
398
+ width: "100%",
399
+ },
400
+ row: {
401
+ flexDirection: "row",
402
+ alignItems: "flex-end",
403
+ gap: 5,
404
+ },
405
+ input: {
406
+ flex: 1,
407
+ fontSize: FONT_SIZE,
408
+ color: "#000000",
409
+ lineHeight: LINE_HEIGHT,
410
+ paddingVertical: INNER_PADDING,
411
+ paddingHorizontal: 0,
412
+ borderWidth: 0,
413
+ borderColor: "transparent",
414
+ backgroundColor: "transparent",
415
+ },
416
+ // Baseline overrides: relax RN Web default minHeight (40) and rely on our
417
+ // dynamic height logic (inputDynamicStyle) instead.
418
+ inputWeb: {
419
+ minHeight: 0,
420
+ // Base gutter so the caret and last characters never sit directly in the
421
+ // system scrollbar lane. On Telegram iOS we add extra right padding at
422
+ // runtime via the overlay width (see isTelegramIOSWeb logic).
423
+ paddingRight: 12,
424
+ },
425
+ applyWrap: {
426
+ // 25px padding from the bottom edge of the bar.
427
+ paddingBottom: 25,
428
+ justifyContent: "center",
429
+ alignItems: "center",
430
+ },
431
+ applyIcon: {
432
+ width: 15,
433
+ height: 10,
434
+ backgroundColor: "#1a1a1a",
435
+ borderRadius: 1,
436
+ },
437
+ scrollbarContainer: {
438
+ position: "absolute",
439
+ right: SCROLLBAR_INSET,
440
+ top: 0,
441
+ alignItems: "flex-start",
442
+ justifyContent: "flex-start",
443
+ },
444
+ scrollbarIndicator: {
445
+ width: 1,
446
+ },
447
+ });