Создаем Telegram Web App. Часть I: разработка на React Native Web

Время прочтения — 13 минут
Содержание
Всем привет! На связи команда dev.family, и мы вернулись с новым экспериментом. Хотим поделиться опытом разработки приложения на React Native для нескольких платформ и мессенджера, а именно – iOS, Android, Web и Telegram.
Разработчики давно пишут приложения под Web на React Native. Например, так работают Meta, Twitter (X) и Flipkart. Но для нашего кейса важен контекст, с которым можете столкнуться и вы. К нам пришел клиент, у которого уже было приложение под Android и iOS на React Native. Он захотел еще одну версию продукта – в формате Telegram Web App. Ранее мы работали c таким видом приложений для другого проекта, но так и не зарелизили его. Готовый прототип стал основой для разработки нового кейса.
Telegram WebApp – это веб-приложение, которое рендерится в своем WebView. Его можно написать на React, а стили и навигацию зашарить через Tamagui. Но в нашем случае мобильные приложения уже были полностью реализованы на React Native. Чтобы не писать код заново, мы решили использовать react-native-web.

Установка react-native-web

В документации технология описана так: «React Native for Web – это слой совместимости между React DOM и React Native. Его можно использовать в новых и существующих приложениях, веб-приложениях и многоплатформенных приложениях».
Проще говоря, это библиотека, которая позволяет запускать код на react-native в качестве веб-приложения. Больше информации тут.
Важно! Из-за NDA мы не можем показать то самое приложение, которое вдохновило нас на этот эксперимент. Поэтому прямо по ходу статьи напишем простой кликер, где нужно продумать стили, использование Haptic, получение данных профиля и использование темы. Обращаем внимание, что наш вариант кода может расходиться с документацией. Когда мы пытались полностью сделать все по ней, возникали определенные проблемы.
Для старта понадобится 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",
 ],
};
Мы использовали babel.config.js чистого React Native проекта на версии 0.74.5 с дополнительными плагинами. Если вы работаете с другой версией React Native, обратите внимание только на plugins.
Далее добавьте следующие команды в 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.
Обозначим один момент: в рамках статьи мы не стремимся создать сложное приложение. Намного важнее – показать, как можно использовать Telegram client и его функции/опции без каких-либо проблем.
Пропишем команду, чтобы поставить библиотеки для навигации внутри приложения. Для данного примера это необязательно, но мы все равно сделали это для вас ❤️

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 💜!
Читайте также
Инструкции
1 февраля 2022
2 минуты

Как добавить карту в Apple Wallet на React Native?

Мы – фанаты кросс-платформенных приложений. В частности, написанных на React Native. Проекты, которые мы делали с его помощью, всегда были совершенно разными. Поэтому нам редко удавалось переиспользовать фрагменты кода. Иногда на помощь приходили библиотеки с открытым исходным кодом, а иногда нам приходилось писать их самим. И эта библиотека – одна из них.

Маша, mobile-разработчик