@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,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
|
+
});
|