@www.hyperlinks.space/program-kit 1.2.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/README.md +53 -0
- package/api/ai.ts +111 -0
- package/api/base.ts +117 -0
- package/api/blockchain.ts +58 -0
- package/api/bot.ts +19 -0
- package/api/ping.ts +41 -0
- package/api/releases.ts +162 -0
- package/api/telegram.ts +65 -0
- package/api/tsconfig.json +17 -0
- package/app/_layout.tsx +135 -0
- package/app/ai.tsx +39 -0
- package/app/components/GlobalBottomBar.tsx +447 -0
- package/app/components/GlobalBottomBarWeb.tsx +362 -0
- package/app/components/GlobalLogoBar.tsx +108 -0
- package/app/components/GlobalLogoBarFallback.tsx +66 -0
- package/app/components/GlobalLogoBarWithFallback.tsx +24 -0
- package/app/components/HyperlinksSpaceLogo.tsx +29 -0
- package/app/components/Telegram.tsx +648 -0
- package/app/components/telegramWebApp.ts +359 -0
- package/app/fonts.ts +12 -0
- package/app/index.tsx +102 -0
- package/app/theme.ts +117 -0
- package/app.json +60 -0
- package/assets/icon.ico +0 -0
- package/assets/images/favicon.png +0 -0
- package/blockchain/coffee.ts +217 -0
- package/blockchain/router.ts +44 -0
- package/bot/format.ts +143 -0
- package/bot/grammy.ts +52 -0
- package/bot/responder.ts +620 -0
- package/bot/webhook.ts +262 -0
- package/database/messages.ts +128 -0
- package/database/start.ts +133 -0
- package/database/users.ts +46 -0
- package/docs/ai_and_search_bar_input.md +94 -0
- package/docs/ai_bot_messages.md +124 -0
- package/docs/backlogs/medium_term_backlog.md +26 -0
- package/docs/backlogs/short_term_backlog.md +39 -0
- package/docs/blue_bar_tackling.md +143 -0
- package/docs/bot_async_streaming.md +174 -0
- package/docs/build_and_install.md +129 -0
- package/docs/database_messages.md +34 -0
- package/docs/fonts.md +18 -0
- package/docs/releases.md +201 -0
- package/docs/releases_github_actions.md +188 -0
- package/docs/scalability.md +34 -0
- package/docs/security_plan_raw.md +244 -0
- package/docs/security_raw.md +345 -0
- package/docs/timing_raw.md +63 -0
- package/docs/tma_logo_bar_jump_investigation.md +69 -0
- package/docs/update.md +205 -0
- package/docs/wallets_hosting_architecture.md +257 -0
- package/eas.json +47 -0
- package/eslint.config.js +10 -0
- package/fullREADME.md +159 -0
- package/global.css +67 -0
- package/npmReadMe.md +53 -0
- package/package.json +214 -0
- package/scripts/load-env.ts +17 -0
- package/scripts/migrate-db.ts +16 -0
- package/scripts/program-kit-init.cjs +58 -0
- package/scripts/run-bot-local.ts +30 -0
- package/scripts/set-webhook.ts +67 -0
- package/scripts/test-api-base.ts +12 -0
- package/telegram/post.ts +328 -0
- package/tsconfig.json +17 -0
- package/vercel.json +7 -0
- package/windows/after-sign-windows-icon.cjs +13 -0
- package/windows/build-layout.cjs +72 -0
- package/windows/build-with-progress.cjs +88 -0
- package/windows/build.cjs +2247 -0
- package/windows/cleanup-legacy-appdata-installs.ps1 +91 -0
- package/windows/cleanup-legacy-windows-shortcuts.ps1 +46 -0
- package/windows/cleanup.cjs +200 -0
- package/windows/embed-windows-exe-icon.cjs +55 -0
- package/windows/extractAppPackage.nsh +150 -0
- package/windows/forge/README.md +41 -0
- package/windows/forge/forge.config.js +138 -0
- package/windows/forge/make-with-stamp.cjs +65 -0
- package/windows/forge-cleanup.cjs +255 -0
- package/windows/hsp-app-process.ps1 +63 -0
- package/windows/installer-hooks.nsi +373 -0
- package/windows/product-brand.cjs +42 -0
- package/windows/remove-orphan-uninstall-registry.ps1 +67 -0
- package/windows/run-installed-with-icon-debug.cmd +20 -0
- package/windows/run-win-electron-builder.cjs +46 -0
- package/windows/updater-dialog.html +143 -0
package/app/_layout.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import "../global.css";
|
|
2
|
+
import { View, StyleSheet, Platform, KeyboardAvoidingView, AppState, Alert } from "react-native";
|
|
3
|
+
import { Stack } from "expo-router";
|
|
4
|
+
import * as Updates from "expo-updates";
|
|
5
|
+
import { TelegramProvider } from "./components/Telegram";
|
|
6
|
+
import { GlobalLogoBarWithFallback } from "./components/GlobalLogoBarWithFallback";
|
|
7
|
+
import { GlobalBottomBar } from "./components/GlobalBottomBar";
|
|
8
|
+
import { GlobalBottomBarWeb } from "./components/GlobalBottomBarWeb";
|
|
9
|
+
import { useColors } from "./theme";
|
|
10
|
+
import { useTelegram } from "./components/Telegram";
|
|
11
|
+
import { useEffect, useRef } from "react";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Three-block column layout (same as Flutter):
|
|
15
|
+
* 1. Logo bar (optional in TMA when not fullscreen)
|
|
16
|
+
* 2. Main area (flex, scrollable per screen) – Stack updates on route change
|
|
17
|
+
* 3. [Web only] Raw HTML textarea test (compare with GlobalBottomBar in TMA)
|
|
18
|
+
* 4. AI & Search bar (fixed at bottom)
|
|
19
|
+
*/
|
|
20
|
+
export default function RootLayout() {
|
|
21
|
+
useOtaUpdateChecks();
|
|
22
|
+
return (
|
|
23
|
+
<TelegramProvider>
|
|
24
|
+
{Platform.OS === "ios" ? (
|
|
25
|
+
<KeyboardAvoidingView
|
|
26
|
+
style={styles.keyboardAvoid}
|
|
27
|
+
behavior="padding"
|
|
28
|
+
keyboardVerticalOffset={0}
|
|
29
|
+
>
|
|
30
|
+
<RootContent />
|
|
31
|
+
</KeyboardAvoidingView>
|
|
32
|
+
) : (
|
|
33
|
+
<RootContent />
|
|
34
|
+
)}
|
|
35
|
+
</TelegramProvider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function useOtaUpdateChecks() {
|
|
40
|
+
const lastCheckAtRef = useRef(0);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (Platform.OS === "web") return;
|
|
44
|
+
|
|
45
|
+
const checkForOtaUpdate = async () => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
// Throttle checks to avoid noisy network calls while app toggles foreground quickly.
|
|
48
|
+
if (now - lastCheckAtRef.current < 10 * 60 * 1000) return;
|
|
49
|
+
lastCheckAtRef.current = now;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const result = await Updates.checkForUpdateAsync();
|
|
53
|
+
if (!result.isAvailable) return;
|
|
54
|
+
|
|
55
|
+
await Updates.fetchUpdateAsync();
|
|
56
|
+
Alert.alert(
|
|
57
|
+
"Update ready",
|
|
58
|
+
"A new version has been downloaded. Restart now to apply it?",
|
|
59
|
+
[
|
|
60
|
+
{ text: "Later", style: "cancel" },
|
|
61
|
+
{
|
|
62
|
+
text: "Restart",
|
|
63
|
+
onPress: () => {
|
|
64
|
+
void Updates.reloadAsync();
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.warn("[updates] OTA check failed", error);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
void checkForOtaUpdate();
|
|
75
|
+
const sub = AppState.addEventListener("change", (nextState) => {
|
|
76
|
+
if (nextState === "active") {
|
|
77
|
+
void checkForOtaUpdate();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return () => sub.remove();
|
|
81
|
+
}, []);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function RootContent() {
|
|
85
|
+
const colors = useColors();
|
|
86
|
+
const { themeBgReady, useTelegramTheme } = useTelegram();
|
|
87
|
+
const backgroundColor = themeBgReady ? colors.background : "transparent";
|
|
88
|
+
// Stronger than opacity:0 — avoids one frame of dark RN-web compositing before themeBgReady.
|
|
89
|
+
const hideWebUntilTheme =
|
|
90
|
+
Platform.OS === "web" && useTelegramTheme && !themeBgReady;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<View
|
|
94
|
+
style={[
|
|
95
|
+
styles.root,
|
|
96
|
+
{
|
|
97
|
+
backgroundColor,
|
|
98
|
+
opacity: themeBgReady ? 1 : 0,
|
|
99
|
+
pointerEvents: themeBgReady ? "auto" : "none",
|
|
100
|
+
...(Platform.OS === "web"
|
|
101
|
+
? { display: hideWebUntilTheme ? "none" : "flex" }
|
|
102
|
+
: {}),
|
|
103
|
+
},
|
|
104
|
+
]}
|
|
105
|
+
>
|
|
106
|
+
<GlobalLogoBarWithFallback />
|
|
107
|
+
<View style={styles.main}>
|
|
108
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
109
|
+
</View>
|
|
110
|
+
{Platform.OS === "web" ? (
|
|
111
|
+
// Avoid mounting textarea/DOM mirror before theme — kills dark flash from RN-web inputs.
|
|
112
|
+
!useTelegramTheme || themeBgReady ? (
|
|
113
|
+
<GlobalBottomBarWeb />
|
|
114
|
+
) : null
|
|
115
|
+
) : (
|
|
116
|
+
<GlobalBottomBar />
|
|
117
|
+
)}
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const styles = StyleSheet.create({
|
|
123
|
+
keyboardAvoid: {
|
|
124
|
+
flex: 1,
|
|
125
|
+
},
|
|
126
|
+
root: {
|
|
127
|
+
flex: 1,
|
|
128
|
+
flexDirection: "column",
|
|
129
|
+
overflow: "hidden",
|
|
130
|
+
},
|
|
131
|
+
main: {
|
|
132
|
+
flex: 1,
|
|
133
|
+
minHeight: 0,
|
|
134
|
+
},
|
|
135
|
+
});
|
package/app/ai.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, Text, StyleSheet } from "react-native";
|
|
3
|
+
import { useLocalSearchParams } from "expo-router";
|
|
4
|
+
|
|
5
|
+
export default function AiScreen() {
|
|
6
|
+
const { prompt } = useLocalSearchParams<{ prompt?: string }>();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<View style={styles.container}>
|
|
10
|
+
<Text style={styles.title}>AI</Text>
|
|
11
|
+
{prompt ? (
|
|
12
|
+
<Text style={styles.prompt}>Prompt: {prompt}</Text>
|
|
13
|
+
) : (
|
|
14
|
+
<Text style={styles.hint}>No prompt</Text>
|
|
15
|
+
)}
|
|
16
|
+
</View>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const styles = StyleSheet.create({
|
|
21
|
+
container: {
|
|
22
|
+
flex: 1,
|
|
23
|
+
padding: 16,
|
|
24
|
+
justifyContent: "flex-start",
|
|
25
|
+
},
|
|
26
|
+
title: {
|
|
27
|
+
fontSize: 18,
|
|
28
|
+
fontWeight: "600",
|
|
29
|
+
marginBottom: 12,
|
|
30
|
+
},
|
|
31
|
+
prompt: {
|
|
32
|
+
fontSize: 14,
|
|
33
|
+
color: "#333",
|
|
34
|
+
},
|
|
35
|
+
hint: {
|
|
36
|
+
fontSize: 14,
|
|
37
|
+
color: "#888",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -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
|
+
});
|