Создаем Telegram Web App. Часть I: разработка на React Native Web
Всем привет! На связи команда dev.family, и мы вернулись с новым экспериментом. Хотим поделиться опытом разработки приложения на React Native для нескольких платформ и мессенджера, а именно – iOS, Android, Web и Telegram.
Разработчики давно пишут приложения под Web на React Native. Например, так работают Meta, Twitter (X) и Flipkart. Но для нашего кейса важен контекст, с которым можете столкнуться и вы. К нам пришел клиент, у которого уже было приложение под Android и iOS на React Native. Он захотел еще одну версию продукта – в формате Telegram Web App. Ранее мы работали c таким видом приложений для другого проекта, но так и не зарелизили его. Готовый прототип стал основой для разработки нового кейса.
Установка react-native-web
В документации технология описана так: «React Native for Web – это слой совместимости между React DOM и React Native. Его можно использовать в новых и существующих приложениях, веб-приложениях и многоплатформенных приложениях».
Проще говоря, это библиотека, которая позволяет запускать код на react-native в качестве веб-приложения. Больше информации тут.
Для старта понадобится react-native приложение. Запускаем в нужной вам директории команду:
npx react-native init react-native-web-example (тут может быть ваше название)Также мы решили убрать yarn и поставить pnpm (это личный выбор, вы можете использовать любой другой пакетный менеджер).
Но react-native не может сходу использовать 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 ..
- Можете запускать
Чтобы веб-приложение рендерилось, нужен index.html файл. Создадим его в самом начале - поместим в корень проекта index.html и добавим следующий код:
index.html
<!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>Нам также понадобится и index.web.js (это видно из тега script). Создаем его в корне проекта на уровне index.js и помещаем туда следующий код:
index.js
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"),
});По сути, здесь происходит почти то же самое, что и в index.js. Только кроме registerComponent мы также находим наш div с id=”app-root” и рендерим в нем приложение.
Дополнение enableExperimentalWebImplementation(true) – не обязательная часть кода. Но в ходе разработки мы столкнулись с проблемами при использовании “react-native-gesture-handler”. Поэтому нам оно помогло.
Далее нужен сборщик, и просто Metro в этом случае не поможет. На странице с react-native-web есть пример конфигурации webpack. Также ее можно получить при установке react-native-reanimated (мы ставим его на всех проектах с кодом на React Native). Здесь она не сработала, поэтому в качестве сборщика использовали Vite и плагин для react-native-web –vite-plugin-react-native-web.
Далее создаем vite.config.js в корне проекта и добавляем следующую часть кода:
// 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(),
],
});Забыл упомянуть, что перед этим нужно поставить следующие пакеты: vite, @vitejs/plugin-react, vite-plugin-commonjs, vite-plugin-react-native-web, babel-plugin-react-native-web.
Если вы тоже собираетесь использовать react-native-reanimated, нужно поставить еще и эти пакеты: react-native-reanimated, @babel/plugin-proposal-export-namespace-from
Если используете react-native-reanimated, ваш babel.config.js будет выглядеть вот так:
babel.config.js
module.exports = {
presets: ["module:@react-native/babel-preset"],
plugins: [
"@babel/plugin-proposal-export-namespace-from",
"react-native-reanimated/plugin",
],
};Далее добавьте следующие команды в scripts внутри package.json:
package.json
{
...
"scripts": {
...
"web:dev": "vite dev",
"web:build": "vite build",
"web:preview": "vite build && vite preview"
...
}
...
}Теперь мы запускаем наше приложение и проверяем, все ли работает, как нужно. В нашем App.tsx пока есть только текст. Поэтому получаем следующий результат:

Также запускаем приложение на iOS и Android, чтобы убедиться, что все работает. И дальше переходим к написанию нашего приложения.
Написание приложения
У нас уже есть основа для приложения, но она довольно простая. Хочется разместить в Telegram что-то поинтереснее, потому что в мессенджере есть много возможностей и функций для взаимодействия с клиентом. Но к ним требуется доступ.
Согласно документации, это можно сделать через глобальный объект window и далее window.Telegram.WebApp. В отличие от приложений на React.js, в React Native у нас нет как такого объекта (window). И если мы попробуем получить к нему доступ, Typescript выдаст ошибку.
Но при использовании react-native-web для веб-приложения, доступ к window предусмотрен. Дальше будет не очень красивая часть, но для большего удобства нужно прописать типы и объявить window вручную. Создадим global.d.ts в корне проекта и пропишем следующее:
- Добавим скрипт в тег head, чтобы подключить наше mini app к Telegram client:
index.html
<head>
<!-- paste here -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
...
</head>
...global.d.ts
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;В файле мы прописали необходимые типы данных, которые получим из Telegram.WebApp, и объявили глобально window.
Но помните: мы пишем еще и мобильное приложение. Поэтому не будем использовать объект window напрямую, чтобы не допустить ошибок. Вместо этого, создадим глобальный объект TelegramConfig и запишем туда все данные из Telegram. Для мобильной части создадим MockConfig, куда внесем все самостоятельно. Они будут статичными, так как данные из Telegram мы, естественно, не получим.
Создадим файл src/config.ts и пропишем:
config.ts
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;
}
};
Создаем MockConfig для нашего мобильного или веб-приложения при отсутствии данных из Telegram-клиента. Далее – функцию конфигурации, которая, при наличии данных из Telegram, вернет нам их —} MockConfig. Ее будем использовать для получения данных.
Теперь пропишем небольшой кликер с использованием настроек темы и данных пользователя из нашего конфига/tg.
Пропишем команду, чтобы поставить библиотеки для навигации внутри приложения. Для данного примера это необязательно, но мы все равно сделали это для вас ❤️
pnpm add @react-navigation/native-stack @react-navigation/native react-native-screensТакже добавим пакеты для анимаций:
pnpm add react-native-reanimated react-native-gesture-handlerВнесем настройки для подключения анимаций. Переходим в babel.config.js
babel.config.js
module.exports = {
presets: ["module:@react-native/babel-preset"],
// add plugins here
plugins: [
"@babel/plugin-proposal-export-namespace-from",
"react-native-reanimated/plugin",
],
};Ставим поды для iOS:
cd ios && pod install && cd ..Далее создадим папки src, src/components, src/screens и файлы в них:
- 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
Внутри папки src мы получаем вот такую структуру:
— components
- Screen.tsx
- Coin.tsx
- Progress.tsx
- index.ts
- Header.tsx
— screens
- HomeScreen.tsx
— utils.ts
— App.tsx
— RootNavigator.tsx
Итак, мы создали файлы для компонентов. Теперь давайте приступим к их наполнению и созданию самих компонентов. Начнем с header:
Переходим в src/components/Header.tsx
src/components/Header.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,
},
});
Как видно из примера выше, мы используем config().themeParams, чтобы достать настройки темы для цветов в header. Отсюда же достаем информацию о пользователе – берем username и photo_url. Но, как указано в документации, photo_url может отсутствовать. Поэтому добавим проверку его наличия и вывод заглушки, если его действительно нет. Создадим в корне проекта папку assets/icons, где будем хранить наши картинки. В этом приложении понадобится только две: заглушка для фото пользователя и картинка самой монетки, по которой будем кликать.
С header мы закончили. Приступаем к следующему компоненту – самой монете. Просто вставить картинку и повесить на нее клик – скучно. Лучше добавим несколько анимаций: например, для появления и исчезновения цифры, что довольно легко, а также анимацию поворота монетки.
src/components/Coin.tsx
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,
},
});Все анимации воспроизводятся дважды – в момент нажатия на монету, и когда мы отпускаем палец. Разберем эту часть кода чуть подробнее.Создаем rotateX, rotateY (SharedValue) и rotateStyle(AnimatedStyle). В rotateStyle смотрим на изменения наших SharedValue и мутируем их в соответствии с позицией нажатия на нашу монету. Сам анимированный стиль передаем в AnimatedButton, который получили после использования функции createAnimatedComponent и ее аргумента Pressable. В зависимости от rotateX и rotateY, монета будет наклоняться в одну или другую сторону.
RotateX и rotateY будем изменять при нажатии на кнопку, после которого мы получаем координаты места нажатия. Далее отнимаем от этих координат центр нашего элемента и так находим дельту. Теперь нужно умножить дельту на значение чувствительности (от 0 до 1), после чего получаем угол наклона по осям X и Y. Прописываем значения нажатия по X и Y вnumber, чтобы дальше использовать их для отображения улетающей цифры.
Все эти действия нужны для написания логики при нажатии на кнопку. Но в коде также должно быть описано, что происходит, когда мы ее отпускаем. Первым делом, ставим анимированное значение в 0, чтобы монета могла вернуться в начальное положение. Обратите внимание, что у нас есть условия для веба и других платформ: и при нажатии, и когда мы отпускаем кнопку. У React Native Reanimated есть проблемы с анимациями в вебе, поэтому в некоторых ситуациях им нужен повторный рендер. Для этого нужна проверка, так как мы используем withTiming. Он отвечает за то, что значение меняется не в момент, а со временем, указанным в animationConfig.
Далее вызываем метод onClick, который передаем в пропсах, чтобы выполнять действия при нажатии. В setTimeout (он нужен для своевременного появления элемента с цифрой) убираем значение number и showNumber, чтобы сработала наша анимация по выходу элемента из DOM дерева.
Раз уж мы затронули анимацию цифры, то для нее используем простой Animated.View и его пропс – exiting для анимации выхода из рендера из библиотеки Reanimated. Теперь при выходе элемента будет срабатывать анимация, которая показывает, как монета уходит вверх. Также в стили мы передаем x и y из number, чтобы разместить его в месте нажатия на кнопку.
Теперь перейдем к Progress.tsx
src/components/Progress.tsx
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,
},
});В этой части нет ничего сложного. Просто через пропсы передаем значение max и текущие значение (amount). При увеличении amount растет и значение прогресса. Также используем цвета из нашего конфига, который можно взять или из параметров, или из настроек темы Telegram пользователя.
Теперь создадим простой Screen.
src/components/Screen.tsx
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>;
};Соберем все в нашем HomeScreen:
src/screens/HomeScreen.tsx
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,
},
});Тут все тоже довольно просто. Единственное, что может вызывать вопросы, – clickedAmount & amount. По сути это два одинаковых значения, зачем они нам? Ответ прост:
- amount – это количество всех момент пользователя
- clickedAmount – количество раз, которое пользователь нажал на кнопку.
Amount нужно где-то сохранять. А clickedAmount, когда пользователь получает новые клики, – со временем сбрасывать. Этот функционал мы не прописывали, поэтому можете поэкспериментировать с ним самостоятельно.
Далее поместим все это в RootNavigator, а сам навигатор – в App.tsx
src/RootNavigator.tsx
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>
);
};src/tApp.tsx
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>
);
}В App.tsx мы в useEffect вызываем метод expand. Он нужен, чтобы приложение при запуске в Telegram открылось во весь экран.
Итоговый вариант кода выглядит вот так – ссылка на репозиторий.

Итак, у нас получился обычный кликер с базовым функционалом. Хочу заметить, что в этом примере мы достаем данные пользователя из initDataUnsafe. И это не лучшее решение, потому что по документации можно провалидировать нашу initData и использовать ApiKey от Telegram-бота. Но наш пример – просто демонстрация, поэтому такого варианта тоже достаточно.
Использовать мок юзера в мобильном приложении – тоже так себе идея. Лучше отдельно обработать и показать авторизацию, либо сделать вход из гостевого аккаунта. На эту тему можно долго рассуждать, но тут мы оставим вам пространство для фантазий. Просто клонируйте репозиторий и играйте с ним, как хочется. А мы продолжим.
Теперь дополнительно проверим, что дает в плане дополнительных функций Telegram client. Для этого используем Haptic Feedback из WebApp библиотеки. Для мобильного приложения он не подойдет, поэтому поступим по-другому.
Поставим библиотеку для Haptic. Мы использовали expo-haptics, потому что у них примерно схожие аргументы с HapticFeedback из Telegram. Наш проект написан на чистом React Native, поэтому сначала поставим expo и потом – expo-haptics.
- pnpx install-expo-modules@latest
- pnpx expo install expo-haptics
- cd ios && pod install
Далее пропишем hook, который будет служить нам оберткой.
src/useHaptics.ts
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;
};Теперь мы можем использовать HapticFeedback и в Telegram mini app, и в нашем обычном приложении. Остается только добавить haptic при клике на монетку.
Также, как и с haptics, вы можете попробовать сделать хранилище для сохранения результата. Но эта часть – уже на вашей стороне ✊
Дело за малым – развернуть приложение в Telegram. Но об этом уже в следующей части.
До скорых встреч с dev.family 💜!