Создаем Telegram Web App. Часть I: разработка на React Native Web
Установка react-native-web
npx react-native init react-native-web-example (тут может быть ваше название)
- Также мы решили убрать yarn и поставить pnpm (это личный выбор, вы можете использовать любой другой пакетный менеджер).
- Выполните команду git clean -xfd
- Удалите packageManager из package.json
- Установите следующие пакеты – (@react-native-community/cli-platform-android, jsc-android, @react-native/gradle-plugin)
- После это выполните pnpm install cd ios && pod install && cd ..
- Можете запускать
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Test React Native Web</title>
<style>
html, body { height: 100%; }
/* These styles disable body scrolling if you are using <ScrollView> */
body { overflow: hidden; }
/* These styles make the root element full-height */
#app-root {
display: flex;
flex: 1 1 100%;
height: 100vh;
}
input {
outline: none;
}
</style>
</head>
<body>
<div id="app-root">
</div>
<script src="./index.web.js" type="module"></script>
</body>
</html>
import { AppRegistry } from "react-native";
import name from "./app.json";
import App from "./App";
import { enableExperimentalWebImplementation } from "react-native-gesture-handler";
enableExperimentalWebImplementation(true);
AppRegistry.registerComponent(name, () => App);
AppRegistry.runApplication(name, {
initialProps: {},
rootTag: document.getElementById("app-root"),
});
// vite.config.js
import reactNativeWeb from "vite-plugin-react-native-web";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import commonjs from "vite-plugin-commonjs";
export default defineConfig({
commonjsOptions: { transformMixedEsModules: true },
plugins: [
reactNativeWeb(),
react({
babel: {
plugins: [
"react-native-reanimated/plugin",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-export-namespace-from",
],
},
}),
commonjs(),
],
});
Если используете react-native-reanimated, ваш babel.config.js будет выглядеть вот так:
module.exports = {
presets: ["module:@react-native/babel-preset"],
plugins: [
"@babel/plugin-proposal-export-namespace-from",
"react-native-reanimated/plugin",
],
};
{
...
"scripts": {
...
"web:dev": "vite dev",
"web:build": "vite build",
"web:preview": "vite build && vite preview"
...
}
...
}
Написание приложения
- Добавим скрипт в тег head, чтобы подключить наше mini app к Telegram client:
<head>
<!-- paste here -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
...
</head>
...
type TelegramTheme = {
bg_color: string;
text_color: string;
hint_color: string;
link_color: string;
button_color: string;
button_text_color: string;
secondary_bg_color: string;
header_bg_color: string;
accent_text_color: string;
section_bg_color: string;
section_header_text_color: string;
section_separator_color: string;
subtitle_text_color: string;
destructive_text_color: string;
};
type WebAppUser = {
id: number;
is_bot: boolean;
first_name: string;
last_name: string;
username: string;
is_premium: boolean;
photo_url: string;
};
type WebappData = {
user: WebAppUser;
};
type TelegramHapticFeedback = {
impactOccurred: (
style: "light" | "medium" | "rigid" | "heavy" | "soft",
) => void;
notificationOccurred: (type: "error" | "success" | "warning") => void;
};
type TelegramWebapp = {
initData: string;
initDataUnsafe: WebappData;
version: string;
platform: string;
themeParams: TelegramTheme;
headerColor: string;
backgroundColor: string;
expand: () => void;
close: () => void;
HapticFeedback: TelegramHapticFeedback;
};
type Window = {
Telegram?: {
WebApp: TelegramWebapp;
};
};
declare var window: Window;
import { Platform } from "react-native";
export const MockConfig = {
themeParams: {
bg_color: "#000",
secondary_bg_color: "#1f1f1f",
section_bg_color: "#000",
section_separator_color: "#8b8b8b",
header_bg_color: "#2c2c2c",
text_color: "#fff",
hint_color: "#949494",
link_color: "",
button_color: "#358ffe",
button_text_color: "",
accent_text_color: "#0f75f1",
section_header_text_color: "",
subtitle_text_color: "",
destructive_text_color: "",
},
initDataUnsafe: {
user: {
username: "MockUser",
is_premium: false,
photo_url: "",
first_name: "",
last_name: "",
id: 0,
},
},
} as TelegramWebapp;
export const config = () => {
if (Platform.OS !== "web") {
return MockConfig;
}
if (window.Telegram?.WebApp.initData) {
return window.Telegram?.WebApp;
} else {
return MockConfig;
}
};
pnpm add @react-navigation/native-stack @react-navigation/native react-native-screens
pnpm add react-native-reanimated react-native-gesture-handler
module.exports = {
presets: ["module:@react-native/babel-preset"],
// add plugins here
plugins: [
"@babel/plugin-proposal-export-namespace-from",
"react-native-reanimated/plugin",
],
};
cd ios && pod install && cd ..
- src/RootNavigator.tsx
- src/screens/HomeScreen.tsx
- src/utils.ts
- src/components/index.ts
- src/components/Coin.tsx
- src/components/Header.tsx
- src/components/Progress.tsx
- src/components/Screen.tsx
- Screen.tsx
- Coin.tsx
- Progress.tsx
- index.ts
- Header.tsx
- HomeScreen.tsx
import { Image, StyleSheet, Text, View } from "react-native";
import { config } from "../../config";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React from "react";
type HeaderProps = {
amount: number;
};
export const Header: React.FC<HeaderProps> = ({ amount }) => {
const insets = useSafeAreaInsets();
const paddingTop = Math.max(20, insets.top);
const { username, photo_url } = config().initDataUnsafe.user;
return (
<View style={[styles.header, { paddingTop }]}>
<View style={styles.amountRow}>
<Image
source={require("../../assets/icons/coin.png")}
style={{ height: 40, width: 40 }}
/>
<Text style={styles.text}>{amount}</Text>
</View>
<View style={styles.userInfo}>
<Text style={styles.username}>@{username}</Text>
{photo_url ? (
<Image
style={[styles.image, { backgroundColor: "transparent" }]}
source={{
uri: photo_url,
}}></Image>
) : (
<View style={styles.image}>
<Image
style={styles.icon}
source={require("../../assets/icons/profile-placeholder.png")}
/>
</View>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
header: {
backgroundColor: config().themeParams?.header_bg_color,
paddingHorizontal: 20,
flexDirection: "row",
alignItems: "center",
paddingBottom: 20,
justifyContent: "space-between",
},
amountRow: {
flexDirection: "row",
alignItems: "center",
},
text: {
fontSize: 24,
fontWeight: "600",
color: config().themeParams?.text_color,
},
userInfo: {
flexDirection: "row",
alignItems: "center",
gap: 20,
},
username: {
color: config().themeParams.accent_text_color,
fontSize: 18,
},
image: {
backgroundColor: config().themeParams.button_color,
height: 50,
width: 50,
justifyContent: "center",
alignItems: "center",
borderRadius: 50,
},
icon: {
height: 30,
width: 30,
tintColor: config().themeParams.text_color,
},
});
import React, { useState } from "react";
import {
Dimensions,
GestureResponderEvent,
Image,
Platform,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import Animated, {
SlideOutUp,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { generateUuid } from "../utils";
import { config } from "../../config";
import { useHaptics } from "../useHaptics";
import { ImpactFeedbackStyle } from "expo-haptics";
//animated component to have ability use animated style from Reanimated package
const AnimatedButton = Animated.createAnimatedComponent(Pressable);
const sensitivity = Platform.OS == "web" ? 0.1 : 0.2;
const animationConfig = {
duration: 100,
};
/**
* @prop onClick - what happened on click the coin
* @prop disabled - when coin can be clicked or not
*/
type CoinProps = {
onClick: () => void;
disabled?: boolean;
};
export const Coin: React.FC = ({ disabled, onClick }) => {
const [number, setNumber] = useState<
{ id: string; x: number; y: number } | undefined
>(undefined);
const [showNumber, setShowNumber] = useState(false);
const width = Dimensions.get("window").width - 50;
//setting coin size based on window and check web compatibility
const size = width > 1000 ? 1000 : width;
const center = size / 2;
//shared values to use in coin animation
const rotateX = useSharedValue(0);
const rotateY = useSharedValue(0);
const { impactOccurred } = useHaptics();
const handlePressIn = async (e: GestureResponderEvent) => {
await impactOccurred(ImpactFeedbackStyle.Light);
const { locationX, locationY } = e.nativeEvent;
//getting rotate amount by x axis
const deltaX = locationX - center;
//getting rotate amount by y axis
const deltaY = locationY - center;
if (Platform.OS === "web") {
rotateY.value = deltaX * sensitivity;
rotateX.value = -deltaY * sensitivity;
} else {
rotateY.value = withTiming(deltaX * sensitivity, animationConfig);
rotateX.value = withTiming(-deltaY * sensitivity, animationConfig);
}
//set number position && unique id to have no problems with keys
setNumber({ id: generateUuid(), x: locationX, y: locationY });
};
const handlePressOut = (e: GestureResponderEvent) => {
setShowNumber(true);
if (Platform.OS === "web") {
rotateX.value = 0;
rotateY.value = 0;
} else {
rotateX.value = withTiming(0, animationConfig);
rotateY.value = withTiming(0, animationConfig);
}
onClick();
// use timeout to not remove element on render start
setTimeout(() => {
//set values undefined to launch exiting animation
setNumber(undefined);
setShowNumber(false);
}, 10);
};
//style to define coin rotation
const rotateStyle = useAnimatedStyle(
() => ({
position: "relative",
transform: [
{
rotateY: `${rotateY.value}deg`,
},
{
rotateX: `${rotateX.value}deg`,
},
],
}),
[rotateX, rotateY],
);
return (
<View style={styles.container}>
<AnimatedButton
style={[rotateStyle]}
disabled={disabled}
onPressIn={handlePressIn}
onPressOut={handlePressOut}>
<Image
source={require("../../assets/icons/coin.png")}
style={{ height: size, width: size }}></Image>
</AnimatedButton>
{!!number && showNumber && (
<Animated.View
exiting={SlideOutUp.duration(500)}
key={number.id}
style={{
position: "absolute",
top: number.y,
left: number.x,
zIndex: 1000,
}}>
<Text style={[styles.text]}>+1</Text>
</Animated.View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
position: "relative",
},
text: {
fontSize: 26,
fontWeight: "600",
//getting text color from Telegram client
color: config().themeParams?.hint_color,
},
});
import React, { useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { config } from "../../config";
type ProgressProps = {
max?: number;
amount: number;
};
export const Progress: React.FC<ProgressProps> = ({ max = 3500, amount }) => {
const [width, setWidth] = useState(0);
return (
<View
style={styles.container}
onLayout={e => setWidth(e.nativeEvent.layout.width)}>
<Text style={[styles.text, { width }]}>
{amount} / {max}
</Text>
<View style={[styles.progress, { width: (amount / max) * width }]}>
<Text style={[styles.text, styles.progressText, { width }]}>
{amount} / {max}
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
height: 70,
borderColor: config().themeParams.accent_text_color,
backgroundColor: config().themeParams.section_bg_color,
borderWidth: 2,
borderRadius: 70,
overflow: "hidden",
position: "relative",
justifyContent: "center",
},
progress: {
height: "100%",
backgroundColor: config().themeParams.accent_text_color,
width: 200,
borderRadius: 20,
position: "absolute",
justifyContent: "center",
overflow: "hidden",
},
text: {
fontWeight: "700",
fontSize: 24,
color: config().themeParams.accent_text_color,
textAlign: "center",
},
progressText: {
textAlign: "center",
color: config().themeParams.text_color,
},
});
import React from "react";
import { StyleSheet, View, ViewProps } from "react-native";
import { config } from "../../config";
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: config().themeParams?.secondary_bg_color,
},
});
export const Screen: React.FC<ViewProps> = ({ children, style }) => {
return <View style={[styles.screen, style]}>{children}</View>;
};
import { StatusBar, StyleSheet, View } from "react-native";
import { Coin, Header, Progress, Screen } from "../components";
import { config } from "../../config";
import { useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const MAX_CLICK_AMOUNT = 3500;
export const HomeScreen = () => {
//total amount of coins
const [amount, setAmount] = useState(0);
//amount of clicks
const [clickedAmount, setClickedAmount] = useState(0);
const insets = useSafeAreaInsets();
const paddingBottom = Math.max(20, insets.bottom);
//what happened when we press coin
const handleClick = () => {
setAmount(prev => prev + 1);
setClickedAmount(prev => prev + 1);
};
return (
<>
<StatusBar backgroundColor={config().themeParams.header_bg_color} />
<Screen style={{ paddingBottom }}>
<Header amount={amount} />
<View style={styles.screen}>
<View style={styles.coin}>
<Coin
disabled={clickedAmount >= MAX_CLICK_AMOUNT}
onClick={handleClick}></Coin>
</View>
<View style={styles.footer}>
<Progress amount={clickedAmount} />
</View>
</View>
</Screen>
</>
);
};
const styles = StyleSheet.create({
screen: {
flex: 1,
gap: 20,
},
coin: {
flex: 1,
backgroundColor: config().themeParams.bg_color,
alignItems: "center",
justifyContent: "center",
},
footer: {
padding: 20,
},
});
- amount – это количество всех момент пользователя
- clickedAmount – количество раз, которое пользователь нажал на кнопку.
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { HomeScreen } from "./screens/HomeScreen";
const RootStack = createNativeStackNavigator();
export const RootNavigator = () => {
return (
<RootStack.Navigator screenOptions={{ headerShown: false }}>
<RootStack.Screen name="Home" component={HomeScreen}></RootStack.Screen>
</RootStack.Navigator>
);
};
import React, { useEffect } from "react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { NavigationContainer } from "@react-navigation/native";
import { RootNavigator } from "./RootNavigator";
import { config } from "../config";
export default function App() {
useEffect(() => {
config().expand();
}, []);
return (
<SafeAreaProvider>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
</SafeAreaProvider>
);
}
Хочу заметить, что в этом примере мы достаем данные пользователя из initDataUnsafe. И это не лучшее решение, потому что по документации можно провалидировать нашу initData и использовать ApiKey от Telegram-бота. Но наш пример – просто демонстрация, поэтому такого варианта тоже достаточно.
Для мобильного приложения он не подойдет, поэтому поступим по-другому.
- pnpx install-expo-modules@latest
- pnpx expo install expo-haptics
- cd ios && pod install
import { useEffect, useState } from "react";
import { Platform } from "react-native";
import {
impactAsync,
notificationAsync,
NotificationFeedbackType,
ImpactFeedbackStyle,
} from "expo-haptics";
type Haptics = {
impactOccurred: (style: ImpactFeedbackStyle) => Promise;
notificationOccurred: (type: NotificationFeedbackType) => Promise;
};
export const useHaptics = () => {
const [haptics, setHaptics] = useState({
impactOccurred: async _ => {},
notificationOccurred: async _ => {},
});
useEffect(() => {
if (Platform.OS == "web") {
if (window.Telegram?.WebApp.HapticFeedback) {
setHaptics(window.Telegram.WebApp.HapticFeedback);
return;
}
}
const impact = async (style: ImpactFeedbackStyle) =>
await impactAsync(style);
const notification = async (type: NotificationFeedbackType) =>
await notificationAsync(type);
setHaptics({ impactOccurred: impact, notificationOccurred: notification });
}, []);
return haptics;
};