diff --git a/.github/workflows/branch-check.yml b/.github/workflows/branch-check.yml index 36f08b0..ce7080b 100644 --- a/.github/workflows/branch-check.yml +++ b/.github/workflows/branch-check.yml @@ -10,6 +10,7 @@ concurrency: jobs: checkup: + if: github.event.pull_request.draft != true name: Typecheck, Lint and Format runs-on: ubuntu-latest steps: diff --git a/.nvmrc b/.nvmrc index 9bdb657..f3c67fc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16 \ No newline at end of file +22.14 \ No newline at end of file diff --git a/apps/mobile/.eslintignore b/apps/mobile/.eslintignore deleted file mode 100644 index 7d772ae..0000000 --- a/apps/mobile/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -babel.config.js -metro.config.js \ No newline at end of file diff --git a/apps/mobile/.eslintrc b/apps/mobile/.eslintrc deleted file mode 100644 index 2c6d9e1..0000000 --- a/apps/mobile/.eslintrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": ["@pegada/eslint-config/expo"], - "root": true, - "parserOptions": { - "project": "./tsconfig.json" - } -} diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9992424..42efbc9 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,4 +1,4 @@ -import { ExpoConfig } from "expo/config"; +import type { ExpoConfig } from "expo/config"; const config: ExpoConfig = { /** @@ -6,7 +6,7 @@ const config: ExpoConfig = { * That affects eas updates and makes sure the app doesn't * break when updating Over The Air */ - version: "1.3.1", + version: "1.4.0", runtimeVersion: { policy: "appVersion" }, @@ -183,20 +183,6 @@ const config: ExpoConfig = { apiKey: process.env.EXPO_PUBLIC_ANDROID_GOOGLE_MAPS_API_KEY } } - // intentFilters: [ - // { - // action: 'VIEW', - // autoVerify: true, - // data: [ - // { - // scheme: 'https', - // host: '*.pegada.app', - // pathPrefix: '/', - // }, - // ], - // category: ['BROWSABLE', 'DEFAULT'], - // }, - // ], }, userInterfaceStyle: "automatic", locales: { @@ -222,17 +208,8 @@ const config: ExpoConfig = { usesNonExemptEncryption: false }, bundleIdentifier: "app.pegada" - // associatedDomains: [ - // 'applinks:pegada.app', - // 'applinks:www.pegada.app', - // ], - }, - packagerOpts: { - config: "metro.config.js", - sourceExts: ["ts", "tsx", "js", "jsx", "json", "wasm", "svg"] }, extra: { - oneSignalAppId: "", bugsnag: { apiKey: process.env.EXPO_PUBLIC_BUGSNAG_API_KEY }, diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js new file mode 100644 index 0000000..1c25031 --- /dev/null +++ b/apps/mobile/eslint.config.js @@ -0,0 +1,38 @@ +import expoConfig from "@pegada/eslint-config/expo"; + +// Additional ignore patterns specific to the mobile package. These prevent ESLint +// from attempting to parse React-Native's large JS bundles inside node_modules +// as well as local build/config files that are not part of the source. + +/** @type {import('eslint').Linter.Config} */ +const packageOverrides = { + ignores: ["**/node_modules/**", "*.config.js", "*.config.mjs", "*.yml"] +}; + +/** @type {import('typescript-eslint').Config} */ +export default [ + ...expoConfig, + { + files: ["**/*.ts", "**/*.tsx"], + rules: { + "@typescript-eslint/no-unnecessary-condition": "off", + "@typescript-eslint/no-extraneous-class": "off", // That's good, keep it + "@shopify/jsx-no-hardcoded-content": "off", // That's good, keep it + "@typescript-eslint/no-confusing-void-expression": "off", // That's good, keep it + "@typescript-eslint/no-unsafe-enum-comparison": "off", // That's good, keep it + "@typescript-eslint/consistent-type-definitions": ["error", "interface"], + "@typescript-eslint/no-misused-promises": "error", + "react/no-unstable-nested-components": "error", + "react-native/no-inline-styles": "off", // That's fine, keep it + "@typescript-eslint/non-nullable-type-assertion-style": "error", + "import/no-cycle": "off" // This one is fine + } + }, + { + files: ["src/services/config.ts", "app.config.ts"], + rules: { + "no-restricted-syntax": "off" + } + }, + packageOverrides +]; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 70497f7..4d6d96c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -2,6 +2,7 @@ "version": "0.1.0", "name": "@pegada/mobile", "main": "index.js", + "type": "module", "private": true, "installConfig": { "hoistingLimits": "workspaces" @@ -29,109 +30,111 @@ "eas-build-on-success": "npx bugsnag-eas-build-on-success" }, "dependencies": { - "@amplitude/ampli": "^1.35.0", - "@amplitude/analytics-react-native": "^1.4.9", - "@bugsnag/expo": "^51.0.0", - "@bugsnag/plugin-react": "^7.25.0", - "@expo/config-types": "^51.0.2", - "@gorhom/bottom-sheet": "^4.6.4", - "@hookform/resolvers": "^3.9.0", + "@amplitude/ampli": "^1.36.2", + "@amplitude/analytics-react-native": "^1.4.13", + "@bugsnag/expo": "^53.0.0", + "@bugsnag/plugin-react": "^8.4.0", + "@expo/config-types": "^53.0.4", + "@gorhom/bottom-sheet": "^5.1.6", + "@hookform/resolvers": "5.0.1", "@pegada/eslint-config": "workspace:*", "@pegada/shared": "workspace:*", "@ptomasroos/react-native-multi-slider": "^2.2.2", "@react-native-anywhere/polyfill-base64": "0.0.1-alpha.0", - "@react-native-async-storage/async-storage": "1.23.1", - "@react-native-community/netinfo": "11.3.1", - "@react-navigation/elements": "^1.3.31", - "@react-navigation/native": "^6.1.18", - "@reduxjs/toolkit": "^2.2.7", - "@shopify/flash-list": "1.6.4", + "@react-native-async-storage/async-storage": "2.1.2", + "@react-native-community/netinfo": "11.4.1", + "@react-navigation/elements": "^2.5.2", + "@react-navigation/native": "^7.1.14", + "@reduxjs/toolkit": "^2.8.2", + "@shopify/flash-list": "1.7.6", "@styled/typescript-styled-plugin": "^1.0.1", - "@tanstack/react-query": "^5.51.21", - "@trpc/client": "11.0.0-rc.477", - "@trpc/react-query": "11.0.0-rc.477", - "@trpc/server": "11.0.0-rc.477", - "color": "^4.2.3", - "date-fns": "^3.6.0", - "expo": "^51.0.24", - "expo-blur": "~13.0.2", - "expo-build-properties": "~0.12.4", - "expo-constants": "~16.0.2", - "expo-crypto": "~13.0.2", - "expo-device": "~6.0.2", - "expo-file-system": "~17.0.1", - "expo-font": "~12.0.9", - "expo-image": "~1.12.13", - "expo-image-manipulator": "^12.0.5", - "expo-image-picker": "~15.0.7", - "expo-insights": "^0.7.0", - "expo-linear-gradient": "~13.0.2", - "expo-linking": "~6.3.1", - "expo-localization": "~15.0.3", - "expo-location": "~17.0.1", - "expo-notifications": "^0.28.15", - "expo-router": "^3.5.20", - "expo-secure-store": "^13.0.2", - "expo-splash-screen": "~0.27.5", - "expo-status-bar": "~1.12.1", - "expo-store-review": "~7.0.2", - "expo-system-ui": "~3.0.7", - "expo-tracking-transparency": "~4.0.2", - "expo-updates": "~0.25.21", - "expo-web-browser": "~13.0.3", - "i18next": "^23.12.2", + "@tanstack/react-query": "^5.81.5", + "@trpc/client": "11.4.3", + "@trpc/react-query": "11.4.3", + "@trpc/server": "11.4.3", + "babel-plugin-react-compiler": "19.0.0-beta-af1b7da-20250417", + "color": "^5.0.0", + "date-fns": "^4.1.0", + "expo": "^53.0.13", + "expo-blur": "~14.1.5", + "expo-build-properties": "~0.14.6", + "expo-constants": "~17.1.6", + "expo-crypto": "~14.1.5", + "expo-device": "~7.1.4", + "expo-file-system": "~18.1.10", + "expo-font": "~13.3.1", + "expo-image": "~2.3.0", + "expo-image-manipulator": "^13.1.7", + "expo-image-picker": "~16.1.4", + "expo-insights": "^0.9.3", + "expo-linear-gradient": "~14.1.5", + "expo-linking": "~7.1.5", + "expo-localization": "~16.1.5", + "expo-location": "~18.1.5", + "expo-notifications": "^0.31.3", + "expo-router": "^5.1.1", + "expo-secure-store": "^14.2.3", + "expo-splash-screen": "~0.30.9", + "expo-status-bar": "~2.2.3", + "expo-store-review": "~8.1.5", + "expo-system-ui": "~5.0.9", + "expo-tracking-transparency": "~5.2.4", + "expo-updates": "~0.28.15", + "expo-web-browser": "~14.2.0", + "i18next": "^25.2.1", "immer": "^10.1.1", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", - "lottie-react-native": "6.7.0", - "react": "18.3.1", - "react-dom": "18.2.0", - "react-error-boundary": "^4.0.13", - "react-hook-form": "7.52.2", - "react-i18next": "^15.0.0", - "react-native": "0.74.3", - "react-native-draggable-grid": "^2.2.1", - "react-native-gesture-handler": "~2.16.2", + "lottie-react-native": "7.2.2", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-error-boundary": "^6.0.0", + "react-hook-form": "7.55.0", + "react-i18next": "^15.5.3", + "react-native": "0.79.4", + "react-native-draggable-grid": "^2.2.2", + "react-native-gesture-handler": "~2.24.0", "react-native-get-random-values": "^1.11.0", - "react-native-google-mobile-ads": "^14.2.1", - "react-native-magic-modal": "^5.1.16", + "react-native-google-mobile-ads": "^15.4.0", + "react-native-magic-modal": "^6.1.0", "react-native-magic-toast": "^0.3.1", - "react-native-maps": "1.14.0", + "react-native-maps": "1.20.1", "react-native-mime-types": "^2.5.0", - "react-native-purchases": "^8.0.0", - "react-native-reanimated": "~3.10.1", - "react-native-safe-area-context": "4.10.5", - "react-native-screens": "~3.31.1", - "react-native-svg": "^15.2.0", - "react-native-svg-transformer": "^1.5.0", - "react-native-web": "~0.19.12", - "react-redux": "^9.1.2", + "react-native-purchases": "^8.11.7", + "react-native-reanimated": "~3.17.5", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "~4.11.1", + "react-native-svg": "^15.11.2", + "react-native-svg-transformer": "^1.5.1", + "react-native-web": "~0.20.0", + "react-redux": "^9.2.0", "reduce-reducers": "^1.0.4", "redux": "^5.0.1", "redux-saga": "^1.3.0", "reselect": "^5.1.1", - "styled-components": "6.1.11", - "superjson": "^2.2.1", + "styled-components": "6.1.19", + "superjson": "^2.2.2", "typesafe-actions": "^5.1.0", - "uuid": "^10.0.0", - "zod": "3.23.8" + "uuid": "^11.1.0", + "zod": "3.24.2" }, "devDependencies": { - "@babel/core": "^7.25.2", - "@bugsnag/plugin-expo-eas-sourcemaps": "^51.0.0", + "@babel/core": "^7.27.7", + "@bugsnag/plugin-expo-eas-sourcemaps": "^53.0.0", "@bugsnag/source-maps": "^2.3.3", "@pegada/api": "workspace:*", "@pegada/prettier-config": "workspace:*", "@pegada/tsconfig": "workspace:*", - "@types/color": "^3.0.6", - "@types/lodash": "^4.17.7", - "@types/react": "^18.2.79", - "@types/react-dom": "~18.2.25", - "@types/react-redux": "^7.1.33", - "@types/react-test-renderer": "^18.3.0", + "@types/color": "^4.2.0", + "@types/lodash": "^4.17.19", + "@types/react": "^19.0.14", + "@types/react-dom": "~19.1.6", + "@types/react-redux": "^7.1.34", + "@types/react-test-renderer": "^19.1.0", "@types/uuid": "^10.0.0", - "@welldone-software/why-did-you-render": "^8.0.3", - "babel-plugin-styled-components": "^2.1.4" + "@welldone-software/why-did-you-render": "^10.0.1", + "babel-plugin-styled-components": "^2.1.4", + "prettier": "catalog:" }, "prettier": "@pegada/prettier-config" } diff --git a/apps/mobile/src/ampli/index.ts b/apps/mobile/src/ampli/index.ts index f11f3e7..fd7e075 100644 --- a/apps/mobile/src/ampli/index.ts +++ b/apps/mobile/src/ampli/index.ts @@ -1,4 +1,3 @@ -/* tslint:disable */ /* eslint-disable */ // @ts-nocheck /** diff --git a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx index b4c5359..e5cc01e 100644 --- a/apps/mobile/src/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/_layout.tsx @@ -6,10 +6,17 @@ import Logo from "@/assets/images/Logo"; import Messages from "@/assets/images/Messages"; import Profile from "@/assets/images/Profile"; -interface TabBarIconProps { - focused: boolean; - color: string; -} +const getSwipeIcon = ({ color }: { color: string }) => ( + +); + +const getMessagesIcon = ({ color }: { color: string }) => ( + +); + +const getProfileIcon = ({ color }: { color: string }) => ( + +); export default () => { const theme = useTheme(); @@ -17,8 +24,8 @@ export default () => { return ( { ( - - ) + tabBarIcon: getSwipeIcon }} /> ( - - ) + tabBarIcon: getMessagesIcon }} /> ( - - ) + tabBarIcon: getProfileIcon }} /> diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2b224ee..b371075 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -18,7 +18,7 @@ import { useGetInitialNotifications } from "@/services/linking"; import { store } from "@/store"; // Wait for the assets to load before hiding the SplashScreen -SplashScreen.preventAutoHideAsync()?.catch(sendError); +SplashScreen.preventAutoHideAsync().catch(sendError); const AppContainer = styled(GestureHandlerRootView)` flex: 1; @@ -28,10 +28,10 @@ const App = () => { const { initialRouteName } = useProtectedRoute(); useEffect(() => { - if (initialRouteName) { - SplashScreen.hideAsync()?.catch(sendError); - router.replace(initialRouteName); - } + if (!initialRouteName) return; + + SplashScreen.hideAsync().catch(sendError); + router.replace(initialRouteName); }, [initialRouteName]); useTrackScreens(); diff --git a/apps/mobile/src/assets/images/Logo.tsx b/apps/mobile/src/assets/images/Logo.tsx index 067a37c..137d336 100644 --- a/apps/mobile/src/assets/images/Logo.tsx +++ b/apps/mobile/src/assets/images/Logo.tsx @@ -1,10 +1,5 @@ -import Svg, { - Defs, - LinearGradient, - Path, - Stop, - SvgProps -} from "react-native-svg"; +import type { SvgProps } from "react-native-svg"; +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; const SvgComponent = ({ colorStopOne, diff --git a/apps/mobile/src/assets/images/Messages.tsx b/apps/mobile/src/assets/images/Messages.tsx index d03e28b..94a862c 100644 --- a/apps/mobile/src/assets/images/Messages.tsx +++ b/apps/mobile/src/assets/images/Messages.tsx @@ -1,10 +1,5 @@ -import Svg, { - Defs, - LinearGradient, - Path, - Stop, - SvgProps -} from "react-native-svg"; +import type { SvgProps } from "react-native-svg"; +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; const SvgComponent = ({ colorStopOne, diff --git a/apps/mobile/src/assets/images/Profile.tsx b/apps/mobile/src/assets/images/Profile.tsx index ee78264..e60de33 100644 --- a/apps/mobile/src/assets/images/Profile.tsx +++ b/apps/mobile/src/assets/images/Profile.tsx @@ -1,10 +1,5 @@ -import Svg, { - Defs, - LinearGradient, - Path, - Stop, - SvgProps -} from "react-native-svg"; +import type { SvgProps } from "react-native-svg"; +import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; const SvgComponent = ({ colorStopOne, diff --git a/apps/mobile/src/components/BlurView.tsx b/apps/mobile/src/components/BlurView.tsx index ea70e3b..e655264 100644 --- a/apps/mobile/src/components/BlurView.tsx +++ b/apps/mobile/src/components/BlurView.tsx @@ -1,7 +1,9 @@ +import type { BlurViewProps } from "expo-blur"; +import type { DefaultTheme } from "styled-components/native"; import { Platform, View } from "react-native"; -import { BlurViewProps, BlurView as ExpoBlurView } from "expo-blur"; +import { BlurView as ExpoBlurView } from "expo-blur"; import Color from "color"; -import styled, { DefaultTheme } from "styled-components/native"; +import styled from "styled-components/native"; type MixinProps = { theme: DefaultTheme } & BlurViewProps; diff --git a/apps/mobile/src/components/BottomAction/index.tsx b/apps/mobile/src/components/BottomAction/index.tsx index b0bff26..5893c61 100644 --- a/apps/mobile/src/components/BottomAction/index.tsx +++ b/apps/mobile/src/components/BottomAction/index.tsx @@ -1,11 +1,11 @@ +import type { BlurViewProps } from "expo-blur"; import * as React from "react"; import { Platform } from "react-native"; -import { BlurViewProps } from "expo-blur"; import { useTheme } from "styled-components/native"; import { useKeyboardAwareSafeAreaInsets } from "../../hooks/useKeyboardAwareSafeAreaInsets"; import { BUTTON_HEIGHT } from "../Button/styles"; -import * as S from "./styles"; +import { Container } from "./styles"; export const useBottomActionStyle = () => { const insets = useKeyboardAwareSafeAreaInsets(); @@ -43,13 +43,12 @@ export const useBottomActionStyle = () => { }; }; -const Container: React.FC = React.forwardRef((props, ref) => { +const ContainerFC: React.FC = (props) => { const { height, paddingBottom } = useBottomActionStyle(); return ( - = React.forwardRef((props, ref) => { ]} /> ); -}); +}; export const BottomAction = { - Container + Container: ContainerFC }; diff --git a/apps/mobile/src/components/BreedPicker/index.tsx b/apps/mobile/src/components/BreedPicker/index.tsx index 3ffd6c4..4b7273f 100644 --- a/apps/mobile/src/components/BreedPicker/index.tsx +++ b/apps/mobile/src/components/BreedPicker/index.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import styled from "styled-components/native"; -import { BreedSlug } from "@pegada/shared/i18n/i18n"; +import type { BreedSlug } from "@pegada/shared/i18n/i18n"; import { Namespace } from "@pegada/shared/i18n/types/types"; import { Input } from "@/components/Input"; diff --git a/apps/mobile/src/components/Button/index.tsx b/apps/mobile/src/components/Button/index.tsx index 8f4d809..71b1070 100644 --- a/apps/mobile/src/components/Button/index.tsx +++ b/apps/mobile/src/components/Button/index.tsx @@ -1,15 +1,16 @@ +import type { PressableProps } from "react-native"; import * as React from "react"; -import { PressableProps } from "react-native"; +import type { ContainerProps } from "./styles"; import Loading from "@/components/Loading"; -import { ButtonText, Container, ContainerProps } from "./styles"; +import { ButtonText, Container } from "./styles"; export interface ButtonProps extends ContainerProps, PressableProps { children: string; } export const Button: React.FC = ({ children, ...props }) => { - const disabled = props.loading || props.disabled; + const disabled = props.loading ?? props.disabled; const onPress = disabled ? null : props.onPress; return ( diff --git a/apps/mobile/src/components/CustomBackdrop.tsx b/apps/mobile/src/components/CustomBackdrop.tsx index e5e6483..9cf2e48 100644 --- a/apps/mobile/src/components/CustomBackdrop.tsx +++ b/apps/mobile/src/components/CustomBackdrop.tsx @@ -1,3 +1,4 @@ +import type { BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; import React from "react"; import { Pressable, StyleSheet, useWindowDimensions } from "react-native"; import Animated, { @@ -5,10 +6,7 @@ import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated"; -import { - BottomSheetBackdropProps, - useBottomSheetModal -} from "@gorhom/bottom-sheet"; +import { useBottomSheetModal } from "@gorhom/bottom-sheet"; const CustomBackdrop = ({ style, @@ -30,7 +28,7 @@ const CustomBackdrop = ({ return ( diff --git a/apps/mobile/src/components/DefaultModal/index.tsx b/apps/mobile/src/components/DefaultModal/index.tsx index 6910c97..dfd7287 100644 --- a/apps/mobile/src/components/DefaultModal/index.tsx +++ b/apps/mobile/src/components/DefaultModal/index.tsx @@ -20,7 +20,7 @@ export const DefaultModal: React.FC = ({ {title} {description} - hide()}>{t("common.ok")} + { hide(); }}>{t("common.ok")} ); }; diff --git a/apps/mobile/src/components/FeedbackCard/components/MaybeFeedback/styles.ts b/apps/mobile/src/components/FeedbackCard/components/MaybeFeedback/styles.ts index e4dc1ed..a20c6a8 100644 --- a/apps/mobile/src/components/FeedbackCard/components/MaybeFeedback/styles.ts +++ b/apps/mobile/src/components/FeedbackCard/components/MaybeFeedback/styles.ts @@ -1,7 +1,7 @@ import styled from "styled-components/native"; -import * as LikeFeedbackStyles from "../LikeFeedback/styles"; +import { Container as LikeFeedbackContainer } from "../LikeFeedback/styles"; -export const Container = styled(LikeFeedbackStyles.Container)` +export const Container = styled(LikeFeedbackContainer)` background-color: #fffacb; `; diff --git a/apps/mobile/src/components/FeedbackCard/components/NopeFeedback/styles.ts b/apps/mobile/src/components/FeedbackCard/components/NopeFeedback/styles.ts index 32fd63e..11fe714 100644 --- a/apps/mobile/src/components/FeedbackCard/components/NopeFeedback/styles.ts +++ b/apps/mobile/src/components/FeedbackCard/components/NopeFeedback/styles.ts @@ -1,7 +1,7 @@ import styled from "styled-components/native"; -import * as LikeFeedbackStyles from "../LikeFeedback/styles"; +import { Container as LikeFeedbackContainer } from "../LikeFeedback/styles"; -export const Container = styled(LikeFeedbackStyles.Container)` +export const Container = styled(LikeFeedbackContainer)` background-color: #ffcecb; `; diff --git a/apps/mobile/src/components/FeedbackCard/index.tsx b/apps/mobile/src/components/FeedbackCard/index.tsx index 177e1e3..3485bdc 100644 --- a/apps/mobile/src/components/FeedbackCard/index.tsx +++ b/apps/mobile/src/components/FeedbackCard/index.tsx @@ -1,13 +1,13 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; +import type { SharedValue } from "react-native-reanimated"; import * as React from "react"; import { Extrapolation, interpolate, - SharedValue, useAnimatedStyle } from "react-native-reanimated"; import { ACTION_OFFSET } from "@/constants"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import LikeFeedback from "./components/LikeFeedback"; import MaybeFeedback from "./components/MaybeFeedback"; import NopeFeedback from "./components/NopeFeedback"; diff --git a/apps/mobile/src/components/Glassmorphism/index.tsx b/apps/mobile/src/components/Glassmorphism/index.tsx index 1cc7e6c..cb2681d 100644 --- a/apps/mobile/src/components/Glassmorphism/index.tsx +++ b/apps/mobile/src/components/Glassmorphism/index.tsx @@ -1,12 +1,15 @@ +import type { BlurViewProps } from "expo-blur"; import * as React from "react"; -import { BlurViewProps } from "expo-blur"; +import { useTheme } from "styled-components/native"; -import { Container, Gradient } from "./styles"; +import { Container, getGradientProps, Gradient } from "./styles"; const Glassmorphism: React.FC = ({ children, ...props }) => { + const theme = useTheme(); + const gradientProps = getGradientProps({ theme }); return ( - {children} + {children} ); }; diff --git a/apps/mobile/src/components/Glassmorphism/styles.ts b/apps/mobile/src/components/Glassmorphism/styles.ts index cbf921f..394b33d 100644 --- a/apps/mobile/src/components/Glassmorphism/styles.ts +++ b/apps/mobile/src/components/Glassmorphism/styles.ts @@ -1,6 +1,7 @@ +import type { DefaultTheme } from "styled-components/native"; import { LinearGradient } from "expo-linear-gradient"; import Color from "color"; -import styled, { DefaultTheme } from "styled-components/native"; +import styled from "styled-components/native"; import { BlurView } from "@/components/BlurView"; @@ -14,7 +15,7 @@ export const getGradientProps = (props: { theme: DefaultTheme }) => ({ colors: [ Color(props.theme.colors.card).fade(0.3).rgb().string(), Color(props.theme.colors.card).fade(0.5).rgb().string() - ], + ] as const, start: { x: 0, y: 1 }, end: { x: 1, y: 0 } }); diff --git a/apps/mobile/src/components/Image.tsx b/apps/mobile/src/components/Image.tsx index 1fc79c2..90ebdcf 100644 --- a/apps/mobile/src/components/Image.tsx +++ b/apps/mobile/src/components/Image.tsx @@ -1,11 +1,11 @@ -import { forwardRef } from "react"; +import type { ImageProps, ImageSource } from "expo-image"; import { View } from "react-native"; -import { Image as ExpoImage, ImageProps } from "expo-image"; +import { Image as ExpoImage } from "expo-image"; import styled from "styled-components/native"; interface LocalImageProps extends Omit { source?: { - blurhash?: string | null | undefined; + blurhash?: string | undefined; uri?: string; }; } @@ -16,22 +16,30 @@ const AbsoluteImage = styled(ExpoImage)` height: 100%; `; -const ImageWrapper = styled.View` +const ImageWrapper = styled(View)` overflow: hidden; `; -export const Image = forwardRef( - ({ source, ...props }, ref) => { - const blurhash = source?.blurhash; +export const Image = ({ + ref, + source, + ...props +}: LocalImageProps & { + ref?: React.RefObject; +}) => { + const blurhash = source?.blurhash; - return ( - - {blurhash ? : null} - - - ); - } -); + return ( + + {blurhash ? : null} + + + ); +}; diff --git a/apps/mobile/src/components/ImageBackground.tsx b/apps/mobile/src/components/ImageBackground.tsx index de5a471..9f68c2f 100644 --- a/apps/mobile/src/components/ImageBackground.tsx +++ b/apps/mobile/src/components/ImageBackground.tsx @@ -1,7 +1,5 @@ -import { - ImageBackground as ExpoImageBackground, - ImageBackgroundProps as ExpoImageBackgroundProps -} from "expo-image"; +import type { ImageBackgroundProps as ExpoImageBackgroundProps } from "expo-image"; +import { ImageBackground as ExpoImageBackground } from "expo-image"; export type ImageBackgroundProps = ExpoImageBackgroundProps; diff --git a/apps/mobile/src/components/Input/index.tsx b/apps/mobile/src/components/Input/index.tsx index 01bd39a..6a667fa 100644 --- a/apps/mobile/src/components/Input/index.tsx +++ b/apps/mobile/src/components/Input/index.tsx @@ -1,9 +1,17 @@ +import type { TextInput, TextInputProps, ViewProps } from "react-native"; import * as React from "react"; -import { TextInput, TextInputProps, ViewProps } from "react-native"; import { useTranslation } from "react-i18next"; import { Text } from "@/components/Text"; -import * as S from "./styles"; +import { + ActivityIndicatorComponent, + CancelIcon, + CancelTouchArea, + Container, + Content, + TextInput as StyledTextInput, + TitleContainer +} from "./styles"; interface TextFieldContainerProps { loading?: boolean; @@ -15,10 +23,10 @@ const TextFieldContainer: React.FC = ({ children, ...props }) => ( - + {!loading && children} - {loading ? : null} - + {loading ? : null} + ); interface InputProps extends TextInputProps { @@ -29,51 +37,47 @@ interface InputProps extends TextInputProps { error?: string; } -export const Input = React.forwardRef( - ( - { - title, - canCancel = true, - error, - loading = false, - optional = false, - ...props - }, - ref - ) => { - const { t } = useTranslation(); +export const Input = ({ + ref, + title, + canCancel = true, + error, + loading = false, + optional = false, + ...props +}: InputProps & { + ref?: React.RefObject; +}) => { + const { t } = useTranslation(); - return ( - - {Boolean(title || optional) && ( - - - {title} - - {optional ? ( - {t("common.optional")} - ) : null} - - )} - - - {Boolean(props.value) && canCancel ? ( - props.onChangeText?.("")}> - - - ) : null} - - {Boolean(error) && ( - - *{error} + return ( + + {Boolean(title ?? optional) && ( + + + {title} - )} - - ); - } -); + {optional ? {t("common.optional")} : null} + + )} + + + {Boolean(props.value) && canCancel ? ( + props.onChangeText?.("")}> + + + ) : null} + + {Boolean(error) && ( + + *{error} + + )} + + ); +}; diff --git a/apps/mobile/src/components/LikeLimitReached/index.tsx b/apps/mobile/src/components/LikeLimitReached/index.tsx index b358da7..05bff7a 100644 --- a/apps/mobile/src/components/LikeLimitReached/index.tsx +++ b/apps/mobile/src/components/LikeLimitReached/index.tsx @@ -1,3 +1,4 @@ +import type { LikeLimitReachedProps } from "@/components/LikeLimitReached/useCountdown"; import { useEffect } from "react"; import * as React from "react"; import { magicModal, useMagicModal } from "react-native-magic-modal"; @@ -13,7 +14,6 @@ import { Header } from "@/components/LikeLimitReached/styles"; import { - LikeLimitReachedProps, useCountdown, ZERO_TIME_LEFT } from "@/components/LikeLimitReached/useCountdown"; @@ -36,9 +36,9 @@ const LikeLimitReached: React.FC = ({ useEffect(() => { // Hide the modal when the time is up - if (timeLeft === ZERO_TIME_LEFT) { - hide(); - } + if (timeLeft !== ZERO_TIME_LEFT) return; + + hide(); }, [hide, timeLeft]); return ( @@ -77,7 +77,7 @@ const LikeLimitReached: React.FC = ({ hide()} + onPress={() => { hide(); }} > diff --git a/apps/mobile/src/components/LikeLimitReached/styles.ts b/apps/mobile/src/components/LikeLimitReached/styles.ts index 9a035bb..2f0ad38 100644 --- a/apps/mobile/src/components/LikeLimitReached/styles.ts +++ b/apps/mobile/src/components/LikeLimitReached/styles.ts @@ -1,8 +1,8 @@ import styled from "styled-components/native"; -import * as ModalStyles from "@/components/DefaultModal/styles"; +import { Container as ModalContainer } from "@/components/DefaultModal/styles"; -export const Container = styled(ModalStyles.Container)` +export const Container = styled(ModalContainer)` gap: ${(props) => props.theme.spacing[2]}px; padding-top: ${(props) => props.theme.spacing[7]}px; `; diff --git a/apps/mobile/src/components/LikeLimitReached/useCountdown.tsx b/apps/mobile/src/components/LikeLimitReached/useCountdown.tsx index 09d1aa3..b2d4c4e 100644 --- a/apps/mobile/src/components/LikeLimitReached/useCountdown.tsx +++ b/apps/mobile/src/components/LikeLimitReached/useCountdown.tsx @@ -33,7 +33,7 @@ export const useCountdown = (likeLimitResetAt: Date) => { } }, 1000); // Update every second - return () => clearInterval(interval); + return () => { clearInterval(interval); }; }, [likeLimitResetAt]); return timeLeft; diff --git a/apps/mobile/src/components/Loading.ts b/apps/mobile/src/components/Loading.ts deleted file mode 100644 index 70d83e2..0000000 --- a/apps/mobile/src/components/Loading.ts +++ /dev/null @@ -1,18 +0,0 @@ -import LottieView from "lottie-react-native"; -import styled from "styled-components/native"; - -interface LoadingProps { - inverse?: boolean; -} - -export default styled(LottieView).attrs((props) => ({ - autoPlay: true, - source: props.inverse - ? require("@/assets/animations/inverseLoadingDots.json") - : require("@/assets/animations/primaryLoadingDots.json"), - ...props -}))` - width: 50px; - height: 20px; - margin: auto; -`; diff --git a/apps/mobile/src/components/Loading.tsx b/apps/mobile/src/components/Loading.tsx new file mode 100644 index 0000000..6917855 --- /dev/null +++ b/apps/mobile/src/components/Loading.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import LottieView from "lottie-react-native"; +import styled from "styled-components/native"; + +// Internal styled component that only takes the original LottieView props. +// Note: not exported to keep its stricter API internal. +const _Loading = styled(LottieView)` + width: 50px; + height: 20px; + margin: auto; +`; + +export interface LoadingProps + extends Omit, "source"> { + /** + * When true, uses the inverse coloured animation instead of the primary one. + */ + inverse?: boolean; +} + +/** + * Loading indicator that wraps the base LottieView ensuring the mandatory + * `source` prop is always provided, so callers don't need to specify it. + * + * We intentionally hide the `source` prop from the public interface because + * the component decides which animation file to use based on the `inverse` + * flag. This fixes the TypeScript error where the required `source` prop was + * considered missing in places that render . + */ +const Loading: React.FC = ({ inverse = false, ...rest }) => { + const source = inverse + ? require("@/assets/animations/inverseLoadingDots.json") + : require("@/assets/animations/primaryLoadingDots.json"); + + return <_Loading autoPlay source={source} {...rest} />; +}; + +export default Loading; diff --git a/apps/mobile/src/components/MainCard/components/Distance/index.tsx b/apps/mobile/src/components/MainCard/components/Distance/index.tsx index 0877e7f..7f5e320 100644 --- a/apps/mobile/src/components/MainCard/components/Distance/index.tsx +++ b/apps/mobile/src/components/MainCard/components/Distance/index.tsx @@ -1,9 +1,9 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; import * as React from "react"; import { View } from "react-native"; import { useTranslation } from "react-i18next"; import Location from "@/assets/images/Location.svg"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import { Container, Content, DistanceText } from "./styles"; interface DistanceProps { @@ -35,7 +35,7 @@ const formatDistance = (distance: number, locale: string) => { const Distance: React.FC = ({ dog }) => { const [_t, i18n] = useTranslation(); - if (dog.distance === null || dog.distance === undefined) { + if (dog.distance === null) { return ( @@ -48,7 +48,7 @@ const Distance: React.FC = ({ dog }) => { - {formatDistance(dog.distance ?? 0, i18n.language)} + {formatDistance(dog.distance, i18n.language)} diff --git a/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx b/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx index 6becb1c..8044ca0 100644 --- a/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx +++ b/apps/mobile/src/components/MainCard/components/PersonalInfo/index.tsx @@ -1,9 +1,9 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; +import type { ViewProps } from "react-native"; import * as React from "react"; -import { ViewProps } from "react-native"; import { LinearGradient } from "expo-linear-gradient"; import { useGetFormattedYears } from "@/services/useGetFormattedYears"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import { Age, Container, Description, Name } from "./styles"; export const BIO_NUMBER_OF_LINES = 3; diff --git a/apps/mobile/src/components/MainCard/index.tsx b/apps/mobile/src/components/MainCard/index.tsx index ac373c6..fa8d928 100644 --- a/apps/mobile/src/components/MainCard/index.tsx +++ b/apps/mobile/src/components/MainCard/index.tsx @@ -60,11 +60,16 @@ const VisitingCard: React.FC = ({ const gotoPreviousImage = () => { // If there is only one image, open the user profile for now. // Not ideal to be here, but improves UX a little - just a quick fix - if (images.length <= 1 && shouldShowPersonalInfo) return openUserProfile(); + if (images.length <= 1 && shouldShowPersonalInfo) { + openUserProfile(); + return; + } - if (currentImage !== 0) return setCurrentImage((index) => index - 1); + if (currentImage !== 0) { + setCurrentImage((index) => index - 1); + return; + } - // eslint-disable-next-line react-compiler/react-compiler -- false positive rotation.value = withSequence( withSpring(-0.5, springConfig), withSpring(0, springConfig) @@ -74,11 +79,16 @@ const VisitingCard: React.FC = ({ const gotoNextImage = () => { // If there is only one image, open the user profile for now. // Not ideal to be here, but improves UX a little - just a quick fix - if (images.length <= 1 && shouldShowPersonalInfo) return openUserProfile(); + if (images.length <= 1 && shouldShowPersonalInfo) { + openUserProfile(); + return; + } if (currentImage + 1 < images.length) { - return setCurrentImage((index) => index + 1); + setCurrentImage((index) => index + 1); + return; } + // eslint-disable-next-line react-compiler/react-compiler rotation.value = withSequence( withSpring(0.5, springConfig), withSpring(0, springConfig) @@ -96,8 +106,8 @@ const VisitingCard: React.FC = ({ diff --git a/apps/mobile/src/components/NetworkBoundary/index.tsx b/apps/mobile/src/components/NetworkBoundary/index.tsx index 99bd776..7a49b6d 100644 --- a/apps/mobile/src/components/NetworkBoundary/index.tsx +++ b/apps/mobile/src/components/NetworkBoundary/index.tsx @@ -1,6 +1,8 @@ -import { PropsWithChildren, Suspense, useEffect, useState } from "react"; +import type { PropsWithChildren } from "react"; +import type { ViewProps } from "react-native"; +import { Suspense, useEffect, useState } from "react"; import * as React from "react"; -import { ActivityIndicator, ViewProps } from "react-native"; +import { ActivityIndicator } from "react-native"; import NetInfo from "@react-native-community/netinfo"; import { QueryErrorResetBoundary, @@ -29,7 +31,11 @@ export const OfflineComponent = ({ reset }: { reset: () => void }) => { {t("networkBoundary.offline.title")} {t("networkBoundary.offline.message")} - @@ -48,7 +54,12 @@ export const RequestErrorComponent = ({ reset }: { reset: () => void }) => { {t("networkBoundary.requestError.message")} - @@ -124,29 +135,46 @@ export const DefaultLoadingComponent = () => { ); }; -type NetworkBoundaryProps = { +interface NetworkBoundaryProps { children: React.ReactNode; suspenseFallback?: React.ReactNode; errorFallback?: IErrorBoundary; +} + +const ErrorAndBugsnagBoundary = ({ + children, + errorFallback, + ...queryProps +}: PropsWithChildren< + { errorFallback?: IErrorBoundary } & QueryErrorResetBoundaryValue +>) => { + const ErrorComponent = errorFallback ?? DefaultErrorComponent; + + const Fallback = React.useCallback( + () => , + [ErrorComponent, queryProps] + ); + + return ( + + {children} + + ); }; const QueryAwareErrorBoundary = ({ children, errorFallback }: PropsWithChildren>) => { - const handleError = (props: QueryErrorResetBoundaryValue) => { - const ErrorComponent = errorFallback ?? DefaultErrorComponent; - - return ( - } - > - {children} - - ); - }; - - return {handleError}; + return ( + + {(props) => ( + + {children} + + )} + + ); }; export const NetworkBoundary = ({ diff --git a/apps/mobile/src/components/NetworkBoundary/styles.ts b/apps/mobile/src/components/NetworkBoundary/styles.tsx similarity index 61% rename from apps/mobile/src/components/NetworkBoundary/styles.ts rename to apps/mobile/src/components/NetworkBoundary/styles.tsx index becd437..5efc3f3 100644 --- a/apps/mobile/src/components/NetworkBoundary/styles.ts +++ b/apps/mobile/src/components/NetworkBoundary/styles.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import LottieView from "lottie-react-native"; import styled from "styled-components/native"; @@ -29,23 +30,20 @@ export const ContainedText = styled(Text)` text-align: center; `; -export const DisconnectedIllustration = styled(LottieView).attrs({ - autoPlay: true, - loop: true, - source: require("@/assets/animations/disconnected.json") -})` - width: 150px; - height: 150px; - align-self: center; -`; +export const DisconnectedIllustration = () => ( + +); -export const ErrorIllustration = styled(LottieView).attrs({ - autoPlay: true, - loop: true, - delay: 2000, - source: require("@/assets/animations/error.json") -})` - height: 150px; - width: 150px; - align-self: center; -`; +export const ErrorIllustration = () => ( + +); diff --git a/apps/mobile/src/components/Picker/index.tsx b/apps/mobile/src/components/Picker/index.tsx index 67e6d80..8cef6e3 100644 --- a/apps/mobile/src/components/Picker/index.tsx +++ b/apps/mobile/src/components/Picker/index.tsx @@ -1,9 +1,10 @@ +import type { BottomSheetFlatListProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetScrollable/types"; +import type { ListRenderItemInfo } from "react-native"; import { useState } from "react"; import * as React from "react"; -import { ListRenderItemInfo, Pressable } from "react-native"; +import { Pressable } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BottomSheetFlatList, BottomSheetModal } from "@gorhom/bottom-sheet"; -import { BottomSheetFlatListProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetScrollable/types"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; @@ -19,10 +20,10 @@ import { TitleContainer } from "./styles"; -export type Item = { +export interface Item { id: string | null; name: string; -}; +} export interface InputPickerProps extends Partial, "ref">> { @@ -55,7 +56,7 @@ const PickerSelectItem = ({ { - onChange?.(item); + onChange(item); onClose(); }} > @@ -74,7 +75,7 @@ const UnForwardedPickerSheet = ( const insets = useSafeAreaInsets(); - const pickerSheetRef = ref as React.MutableRefObject; + const pickerSheetRef = ref as React.RefObject; const onClose = () => { pickerSheetRef.current.close(); diff --git a/apps/mobile/src/components/Picker/styles.ts b/apps/mobile/src/components/Picker/styles.ts index 825cb05..cd002f6 100644 --- a/apps/mobile/src/components/Picker/styles.ts +++ b/apps/mobile/src/components/Picker/styles.ts @@ -13,9 +13,9 @@ export const TitleContainer = styled.View` border-color: ${(props) => props.theme.colors.border}; `; -type SelectedItemProps = { +interface SelectedItemProps { selected?: boolean; -}; +} export const SelectItem = styled(PressableArea)` padding: ${(props) => props.theme.spacing[4]}px; diff --git a/apps/mobile/src/components/PressableArea.tsx b/apps/mobile/src/components/PressableArea.tsx index 49fd34d..506889d 100644 --- a/apps/mobile/src/components/PressableArea.tsx +++ b/apps/mobile/src/components/PressableArea.tsx @@ -1,5 +1,6 @@ +import type { PressableProps } from "react-native"; import * as React from "react"; -import { Pressable, PressableProps } from "react-native"; +import { Pressable } from "react-native"; const ACTIVE_OPACITY = 0.9; export const PressableArea: React.FC = ({ style, ...rest }) => { diff --git a/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx b/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx index b3876b6..fa2a6d8 100644 --- a/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx +++ b/apps/mobile/src/components/ProfileImageUploader/components/AddUserPhoto/index.tsx @@ -1,3 +1,4 @@ +import type { Picture } from "@/components/ProfileImageUploader/utils"; import { useState } from "react"; import * as React from "react"; import { ActivityIndicator, Alert } from "react-native"; @@ -14,20 +15,27 @@ import { PressableArea } from "@/components/PressableArea"; import { compressImage, ImagePickerError, - Picture, showImagePickerOptions } from "@/components/ProfileImageUploader/utils"; import { Text } from "@/components/Text"; import { getTrcpContext } from "@/contexts/trcpContext"; import { sendError } from "@/services/errorTracking"; import { getMimeType } from "@/services/getMimeType"; -import * as S from "./styles"; - -type AddUserPhotoProps = { +import { + AddRemoveContainer, + AnimatedOverlay, + DebugImageStatusContainer, + FadedDog, + UserPicture, + UserPictureContainer, + UserPictureContent +} from "./styles"; + +interface AddUserPhotoProps { picture: Picture; onDelete: () => void; onAdd: ({ url }: { url: string }) => void; -}; +} const hitSlop = { top: 150, @@ -66,7 +74,12 @@ export const AddUserPhoto: React.FC = ({ onAdd({ url: selectedImage.uri }); setLocalPicture(selectedImage.uri); - const presignedUrl = await getTrcpContext().image.signedUrl.fetch(); + const presignedUrlResponse = + await getTrcpContext().image.signedUrl.fetch(); + + if (typeof presignedUrlResponse !== "string") { + throw new Error("Invalid presigned URL response"); + } /** * Compress the image before uploading. Expensive operation, @@ -74,19 +87,27 @@ export const AddUserPhoto: React.FC = ({ */ const compressedImage = await compressImage(selectedImage.uri); - const response = await uploadAsync(presignedUrl, compressedImage.uri, { - mimeType: getMimeType(compressedImage.uri), - uploadType: FileSystemUploadType.BINARY_CONTENT, - httpMethod: "PUT" - }); + const response = await uploadAsync( + presignedUrlResponse, + compressedImage.uri, + { + mimeType: getMimeType(compressedImage.uri), + uploadType: FileSystemUploadType.BINARY_CONTENT, + httpMethod: "PUT" + } + ); if (response.status !== 200) { throw new Error("Failed to upload image"); } - const finalUrl = presignedUrl.split("?")[0] as string; + const finalUrlPart = presignedUrlResponse.split("?")[0]; + + if (!finalUrlPart) { + throw new Error("Invalid presigned URL format"); + } - onAdd({ url: finalUrl }); + onAdd({ url: finalUrlPart }); } catch (err) { // When the user cancels the image picker, we don't want to show an error if (err instanceof Error && err.message === ImagePickerError.CANCELED) { @@ -103,18 +124,18 @@ export const AddUserPhoto: React.FC = ({ const isLoading = Boolean(localPicture && !picture.url.includes("http")); return ( - - - + + {isLoading ? ( - + - + ) : null} {!hasPicture && ( = ({ // Takes up the whole component, hitSlop={hitSlop} > - + )} { /** Picture status is only returned in development mode for debugging */ picture.status ? ( - + {picture.status} - + ) : null } - - + = ({ - - + + ); }; diff --git a/apps/mobile/src/components/ProfileImageUploader/index.tsx b/apps/mobile/src/components/ProfileImageUploader/index.tsx index 6335c76..c3799d3 100644 --- a/apps/mobile/src/components/ProfileImageUploader/index.tsx +++ b/apps/mobile/src/components/ProfileImageUploader/index.tsx @@ -1,13 +1,12 @@ +import type { + DeletedPicture, + Picture +} from "@/components/ProfileImageUploader/utils"; import * as React from "react"; import { View } from "react-native"; import { DraggableGrid } from "react-native-draggable-grid"; -import { - DeletedPicture, - deleteItem, - Picture, - sortByUrl -} from "@/components/ProfileImageUploader/utils"; +import { deleteItem, sortByUrl } from "@/components/ProfileImageUploader/utils"; import { Text } from "@/components/Text"; import { AddUserPhoto } from "./components/AddUserPhoto"; import { @@ -71,7 +70,7 @@ export const ProfileImagesUploader: React.FC = ({ const draggableGridStyle = { zIndex: 20 }; - const onDragStart = () => setGesturesEnabled(false); + const onDragStart = () => { setGesturesEnabled(false); }; const onDragRelease = (newImages: GenericPictures) => { setGesturesEnabled(true); diff --git a/apps/mobile/src/components/ProfileImageUploader/utils/index.ts b/apps/mobile/src/components/ProfileImageUploader/utils/index.ts index a363834..52577e2 100644 --- a/apps/mobile/src/components/ProfileImageUploader/utils/index.ts +++ b/apps/mobile/src/components/ProfileImageUploader/utils/index.ts @@ -1,8 +1,15 @@ +import type { ImageResult } from "expo-image-manipulator"; +import type { ImagePickerAsset } from "expo-image-picker"; import { Alert, Platform } from "react-native"; -import { manipulateAsync, SaveFormat } from "expo-image-manipulator"; -import * as ImagePicker from "expo-image-picker"; +import { ImageManipulator, SaveFormat } from "expo-image-manipulator"; +import { + launchCameraAsync, + launchImageLibraryAsync, + requestCameraPermissionsAsync, + requestMediaLibraryPermissionsAsync +} from "expo-image-picker"; -import { IMAGE_STATUS } from "@pegada/shared/schemas/dogSchema"; +import type { IMAGE_STATUS } from "@pegada/shared/schemas/dogSchema"; import i18n from "@/i18n"; import { sendError } from "@/services/errorTracking"; @@ -54,8 +61,10 @@ export const deleteItem = }; }; -export const compressImage = async (uri: string) => { - const manipResult = await manipulateAsync(uri, [], { +export const compressImage = async (uri: string): Promise => { + const renderResult = await ImageManipulator.manipulate(uri).renderAsync(); + + const manipResult = await renderResult.saveAsync({ format: SaveFormat.WEBP, compress: 0.8 }); @@ -63,7 +72,7 @@ export const compressImage = async (uri: string) => { return manipResult; }; -const formatImage = (image: ImagePicker.ImagePickerAsset) => { +const formatImage = (image: ImagePickerAsset) => { const pictureUri = Platform.OS === "ios" ? image.uri.replace("file://", "") : image.uri; @@ -77,8 +86,7 @@ export enum ImagePickerError { } export const pickImage = async () => { - const cameraRollStatus = - await ImagePicker.requestMediaLibraryPermissionsAsync(); + const cameraRollStatus = await requestMediaLibraryPermissionsAsync(); if (cameraRollStatus.status !== "granted") { Alert.alert( @@ -88,8 +96,8 @@ export const pickImage = async () => { throw new Error(ImagePickerError.NO_PERMISSION); } - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, + const result = await launchImageLibraryAsync({ + mediaTypes: "images", allowsEditing: true, aspect: [9, 16], quality: 1 @@ -109,7 +117,7 @@ export const pickImage = async () => { }; export const takeImage = async () => { - const cameraStatus = await ImagePicker.requestCameraPermissionsAsync(); + const cameraStatus = await requestCameraPermissionsAsync(); if (cameraStatus.status !== "granted") { Alert.alert( @@ -119,8 +127,8 @@ export const takeImage = async () => { throw new Error(ImagePickerError.NO_PERMISSION); } - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, + const result = await launchCameraAsync({ + mediaTypes: "images", allowsEditing: true, aspect: [9, 16], quality: 1 @@ -153,8 +161,10 @@ export const showImagePickerOptions = (): Promise<{ text: i18n.t("imagePicker.takePhoto"), onPress: () => { takeImage() - .then((imageUrl) => resolve(imageUrl)) - .catch((error) => { + .then((imageUrl) => { + resolve(imageUrl); + }) + .catch((error: unknown) => { if ( error instanceof Error && error.message !== ImagePickerError.CANCELED @@ -162,7 +172,9 @@ export const showImagePickerOptions = (): Promise<{ sendError(error); } - reject(error); + reject( + error instanceof Error ? error : new Error(String(error)) + ); }); } }, @@ -170,8 +182,10 @@ export const showImagePickerOptions = (): Promise<{ text: i18n.t("imagePicker.chooseFromLibrary"), onPress: () => { pickImage() - .then((imageUrl) => resolve(imageUrl)) - .catch((error) => { + .then((imageUrl) => { + resolve(imageUrl); + }) + .catch((error: unknown) => { if ( error instanceof Error && error.message !== ImagePickerError.CANCELED @@ -179,7 +193,9 @@ export const showImagePickerOptions = (): Promise<{ sendError(error); } - reject(error); + reject( + error instanceof Error ? error : new Error(String(error)) + ); }); } }, diff --git a/apps/mobile/src/components/RadioButtons/index.tsx b/apps/mobile/src/components/RadioButtons/index.tsx index 947ad44..81305ea 100644 --- a/apps/mobile/src/components/RadioButtons/index.tsx +++ b/apps/mobile/src/components/RadioButtons/index.tsx @@ -1,13 +1,9 @@ import * as React from "react"; +import type { OptionButtonProps } from "./styles"; import { Container } from "@/components/Input/styles"; import { Text } from "@/components/Text"; -import { - Content, - OptionButtonProps, - RadioButtonContainer, - TextButton -} from "./styles"; +import { Content, RadioButtonContainer, TextButton } from "./styles"; interface RadioButtonsProps { title: string; @@ -51,7 +47,7 @@ export const RadioButtons: React.FC = ({ onChange(item)} + onPress={() => { onChange(item); }} last={index === data.length - 1} > {item} diff --git a/apps/mobile/src/components/RadioButtons/styles.ts b/apps/mobile/src/components/RadioButtons/styles.ts index b8d4944..7719883 100644 --- a/apps/mobile/src/components/RadioButtons/styles.ts +++ b/apps/mobile/src/components/RadioButtons/styles.ts @@ -1,4 +1,4 @@ -import { PressableProps } from "react-native"; +import type { PressableProps } from "react-native"; import styled, { css } from "styled-components/native"; import { PressableArea } from "@/components/PressableArea"; @@ -31,19 +31,19 @@ export const RadioButtonContainer = styled(PressableArea)` align-items: center; ${(props) => { - if (!props?.last) { - return css` - margin-right: ${(props) => props.theme.spacing[3]}px; - `; - } + if (props.last) return undefined; + + return css` + margin-right: ${(props) => props.theme.spacing[3]}px; + `; }}; ${(props) => { - if (props?.marked) { - return css` - background-color: ${(props) => props.theme.colors.primary}; - `; - } + if (!props.marked) return undefined; + + return css` + background-color: ${(props) => props.theme.colors.primary}; + `; }}; `; @@ -51,10 +51,10 @@ export const TextButton = styled(Text)` color: ${(props) => props.theme.colors.primary}; ${(props) => { - if (props?.marked) { - return css` - color: ${(props) => props.theme.colors.background}; - `; - } + if (!props.marked) return undefined; + + return css` + color: ${(props) => props.theme.colors.background}; + `; }} `; diff --git a/apps/mobile/src/components/Slider/index.tsx b/apps/mobile/src/components/Slider/index.tsx index 569866c..3cc6d2a 100644 --- a/apps/mobile/src/components/Slider/index.tsx +++ b/apps/mobile/src/components/Slider/index.tsx @@ -1,9 +1,10 @@ -import * as React from "react"; -import { Platform, View } from "react-native"; -import MultiSlider, { +import type { LabelProps, MultiSliderProps } from "@ptomasroos/react-native-multi-slider"; +import * as React from "react"; +import { Platform, View } from "react-native"; +import MultiSlider from "@ptomasroos/react-native-multi-slider"; import { useTheme } from "styled-components/native"; import { Text } from "@/components/Text"; @@ -62,6 +63,32 @@ const markerHitSlop = { right: 15 }; +const createCustomLabels = (max: number | undefined): React.FC => { + const CustomLabels: React.FC = (label) => { + const oneMarkerValue = + Number(label.oneMarkerValue) >= (max ?? 0) ? "∞" : label.oneMarkerValue; + + const twoMarkerValue = + Number(label.twoMarkerValue) >= (max ?? 0) ? "∞" : label.twoMarkerValue; + + return ( + <> + {Number(label.oneMarkerValue) >= 0 && ( + + {oneMarkerValue} + + )} + {Number(label.twoMarkerValue) >= 0 && ( + + {twoMarkerValue} + + )} + + ); + }; + return CustomLabels; +}; + const CustomMarker = () => ; export const Root = (props: MultiSliderProps) => { @@ -73,12 +100,17 @@ export const Root = (props: MultiSliderProps) => { // workaround to prevent that. const safePadding = Platform.OS === "android" ? theme.spacing[7] : 0; - const sliderLength = (props?.sliderLength ?? 0) - safePadding * 2; + const sliderLength = (props.sliderLength ?? 0) - safePadding * 2; const hasSecondMarker = (props.values?.length ?? 0) > 1; const stroke = 3; + const CustomLabels = React.useMemo( + () => createCustomLabels(props.max), + [props.max] + ); + const safeBorderStyle = { height: stroke, width: safePadding, @@ -88,33 +120,6 @@ export const Root = (props: MultiSliderProps) => { borderBottomRightRadius: theme.radii.md }; - const CustomLabels = (label: LabelProps) => { - const oneMarkerValue = - Number(label.oneMarkerValue) >= (props.max ?? 0) - ? "∞" - : label.oneMarkerValue; - - const twoMarkerValue = - Number(label.twoMarkerValue) >= (props.max ?? 0) - ? "∞" - : label.twoMarkerValue; - - return ( - <> - {Number(label.oneMarkerValue) >= 0 && ( - - {oneMarkerValue} - - )} - {Number(label.twoMarkerValue) >= 0 && ( - - {twoMarkerValue} - - )} - - ); - }; - const style = { flexDirection: "row", alignItems: "center" diff --git a/apps/mobile/src/components/Text.ts b/apps/mobile/src/components/Text.ts index ce92eff..9bc7bfc 100644 --- a/apps/mobile/src/components/Text.ts +++ b/apps/mobile/src/components/Text.ts @@ -1,7 +1,5 @@ -import styled, { - css, - DefaultTheme as DefaultThemeProps -} from "styled-components/native"; +import type { DefaultTheme as DefaultThemeProps } from "styled-components/native"; +import styled, { css } from "styled-components/native"; export interface TextProps { fontSize?: keyof DefaultThemeProps["typography"]["sizes"]; diff --git a/apps/mobile/src/config.ts b/apps/mobile/src/config.ts index a7cd77d..c37912a 100644 --- a/apps/mobile/src/config.ts +++ b/apps/mobile/src/config.ts @@ -2,14 +2,13 @@ import "react-native-get-random-values"; import "@/why-did-you-render"; +import type { BugsnagErrorBoundary as IBugsnagErrorBoundary } from "@bugsnag/plugin-react"; import * as React from "react"; import { LogBox, Text, View } from "react-native"; import mobileAds, { MaxAdContentRating } from "react-native-google-mobile-ads"; -import * as Updates from "expo-updates"; +import { manifest as updatesManifest } from "expo-updates"; import Bugsnag from "@bugsnag/expo"; -import BugsnagPluginReact, { - BugsnagErrorBoundary as IBugsnagErrorBoundary -} from "@bugsnag/plugin-react"; +import BugsnagPluginReact from "@bugsnag/plugin-react"; import { ampli } from "@/ampli"; import { config } from "@/services/config"; @@ -29,7 +28,7 @@ mobileAds().initialize().catch(sendError); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -Text.defaultProps = Text.defaultProps || {}; +Text.defaultProps = Text.defaultProps ?? {}; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Text.defaultProps.allowFontScaling = false; @@ -51,15 +50,14 @@ ampli.load({ } }); -const manifest = Updates.manifest; +const manifest = updatesManifest; const metadata = "metadata" in manifest ? manifest.metadata : undefined; const updateGroup = metadata && "updateGroup" in metadata ? metadata.updateGroup : undefined; Bugsnag.start({ apiKey: config.BUGSNAG_API_KEY, - codeBundleId: (updateGroup as string) || "", - metadata: { env: config.ENV }, + codeBundleId: updateGroup as string, plugins: [new BugsnagPluginReact()], releaseStage: config.ENV, enabledReleaseStages: ["production", "staging"], @@ -69,4 +67,4 @@ Bugsnag.start({ Bugsnag.setContext("app"); export const BugsnagErrorBoundary: IBugsnagErrorBoundary = - Bugsnag.getPlugin("react")?.createErrorBoundary(React) || View; + Bugsnag.getPlugin("react")?.createErrorBoundary(React) ?? View; diff --git a/apps/mobile/src/contexts/TRPCProvider.tsx b/apps/mobile/src/contexts/TRPCProvider.tsx index 0d40f9d..0a5dff4 100644 --- a/apps/mobile/src/contexts/TRPCProvider.tsx +++ b/apps/mobile/src/contexts/TRPCProvider.tsx @@ -24,13 +24,13 @@ export { type RouterInputs, type RouterOutputs } from "@pegada/api"; */ export const api = createTRPCReact(); -type ResponseJSON = { +interface ResponseJSON { error?: { json?: { message?: string; }; }; -}; +} export const trpcQueryClient = api.createClient({ links: [ @@ -59,7 +59,7 @@ export const trpcQueryClient = api.createClient({ if (res.status === 401) { const unauthorized = responsesJSON.some((responseJSON) => { - const errorMessage = responseJSON?.error?.json?.message; + const errorMessage = responseJSON.error?.json?.message; return errorMessage === "UNAUTHORIZED"; }); @@ -68,11 +68,13 @@ export const trpcQueryClient = api.createClient({ i18n.t("session.expired"), i18n.t("session.expiredMessage") ); - throw logout(); + await logout(); + throw new Error("UNAUTHORIZED"); } } return { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...res, // Already decoded here json: async () => responsesJSON diff --git a/apps/mobile/src/contexts/ThemeProvider.tsx b/apps/mobile/src/contexts/ThemeProvider.tsx index e633ff0..fca78f9 100644 --- a/apps/mobile/src/contexts/ThemeProvider.tsx +++ b/apps/mobile/src/contexts/ThemeProvider.tsx @@ -1,3 +1,4 @@ +import type { StorageDataTypes } from "@/services/storage"; import { useContext, useEffect, useState } from "react"; import * as React from "react"; import { Appearance, useColorScheme } from "react-native"; @@ -9,7 +10,6 @@ import { sendError } from "@/services/errorTracking"; import { deleteData, getData, - StorageDataTypes, StorageKeys, storeData, Theme diff --git a/apps/mobile/src/contexts/trcpContext.tsx b/apps/mobile/src/contexts/trcpContext.tsx index 84042d6..0976143 100644 --- a/apps/mobile/src/contexts/trcpContext.tsx +++ b/apps/mobile/src/contexts/trcpContext.tsx @@ -1,4 +1,4 @@ -import { api } from "./TRPCProvider"; +import type { api } from "./TRPCProvider"; let trcpContext = undefined as unknown as ReturnType; diff --git a/apps/mobile/src/hooks/useCurrentCityText.tsx b/apps/mobile/src/hooks/useCurrentCityText.tsx index 6d86a91..7820dd7 100644 --- a/apps/mobile/src/hooks/useCurrentCityText.tsx +++ b/apps/mobile/src/hooks/useCurrentCityText.tsx @@ -9,13 +9,17 @@ export const useCurrentCityText = () => { refetchOnMount: false }); - const hasLatLng = myDog?.user?.latitude && myDog.user?.longitude; + if (!myDog) { + return t("common.unknown"); + } + + const hasLatLng = myDog.user.latitude && myDog.user.longitude; const currentCityFallback = hasLatLng ? t("common.nearYou") : t("common.unknown"); - const currentCityText = myDog?.user?.city ?? currentCityFallback; + const currentCityText = myDog.user.city ?? currentCityFallback; // Use this once we have more specific location data // const currentNeighborhoodText = t('changeLocation.nearCurrentLocation', { diff --git a/apps/mobile/src/hooks/useIsFirstRenderRef.ts b/apps/mobile/src/hooks/useIsFirstRenderRef.ts new file mode 100644 index 0000000..96c326c --- /dev/null +++ b/apps/mobile/src/hooks/useIsFirstRenderRef.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export const useIsFirstRenderRef = () => { + const isFirstRenderRef = useRef(true); + + useEffect(() => { + isFirstRenderRef.current = false; + }, []); + + return isFirstRenderRef; +}; diff --git a/apps/mobile/src/hooks/useIsMounted.ts b/apps/mobile/src/hooks/useIsMounted.ts new file mode 100644 index 0000000..46b1186 --- /dev/null +++ b/apps/mobile/src/hooks/useIsMounted.ts @@ -0,0 +1,15 @@ +import { useEffect, useRef } from "react"; + +export const useIsMounted = () => { + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + return isMountedRef; +}; diff --git a/apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx b/apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx index 16e02f1..5c7fee2 100644 --- a/apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx +++ b/apps/mobile/src/hooks/useKeyboardAwareSafeAreaInsets.tsx @@ -9,11 +9,11 @@ export const useKeyboardAwareSafeAreaInsets = () => { useEffect(() => { const keyboardDidShowListener = Keyboard.addListener( "keyboardWillShow", - () => setKeyboardOpen(true) + () => { setKeyboardOpen(true); } ); const keyboardDidHideListener = Keyboard.addListener( "keyboardWillHide", - () => setKeyboardOpen(false) + () => { setKeyboardOpen(false); } ); return () => { diff --git a/apps/mobile/src/hooks/usePayments.tsx b/apps/mobile/src/hooks/usePayments.tsx index f3ce372..fc656b7 100644 --- a/apps/mobile/src/hooks/usePayments.tsx +++ b/apps/mobile/src/hooks/usePayments.tsx @@ -1,5 +1,5 @@ +import type { CustomerInfo, PurchasesPackage } from "react-native-purchases"; import { useEffect } from "react"; -import { CustomerInfo, PurchasesPackage } from "react-native-purchases"; import { useSuspenseQuery } from "@tanstack/react-query"; import { identifyUser } from "@/services/getInitialRouteName"; @@ -27,13 +27,13 @@ export const useEligibleForTrial = ({ const customerInfo = useCustomerInfo(); const hasIntroPrice = offering?.product.introPrice; - const hadPremium = customerInfo.data?.entitlements.all.premium; + const hadPremium = customerInfo.data.entitlements.all.premium; return hasIntroPrice && !hadPremium; }; export const useCustomerInfo = () => { - const loginProps = usePaymentsLogin(); + usePaymentsLogin(); const customerInfoProps = useSuspenseQuery({ queryFn: payments.getCustomerInfo, @@ -44,11 +44,7 @@ export const useCustomerInfo = () => { refetchOnWindowFocus: true }); - return { - ...customerInfoProps, - isLoading: loginProps.isLoading || customerInfoProps.isLoading, - error: loginProps.error || customerInfoProps.error - }; + return customerInfoProps; }; export const useCustomerPlan = () => { @@ -104,6 +100,6 @@ export const useOfferings = () => { payments.init(); // Automatically update the query data when the customer info changes -payments.addCustomerInfoUpdateListener(async (customerInfo: CustomerInfo) => { +payments.addCustomerInfoUpdateListener((customerInfo: CustomerInfo) => { queryClient.setQueryData([PaymentCacheKey.CustomerInfo], customerInfo); }); diff --git a/apps/mobile/src/hooks/useTrackScreens.ts b/apps/mobile/src/hooks/useTrackScreens.ts index f948d07..8cfb201 100644 --- a/apps/mobile/src/hooks/useTrackScreens.ts +++ b/apps/mobile/src/hooks/useTrackScreens.ts @@ -5,7 +5,7 @@ import Bugsnag from "@bugsnag/expo"; import { analytics } from "@/services/analytics"; export const useTrackScreens = () => { - const routeNameRef = useRef(); + const routeNameRef = useRef(undefined); const pathname = usePathname(); useEffect(() => { diff --git a/apps/mobile/src/hooks/useWarmUpBrowser.tsx b/apps/mobile/src/hooks/useWarmUpBrowser.tsx index 5d32ac3..be01907 100644 --- a/apps/mobile/src/hooks/useWarmUpBrowser.tsx +++ b/apps/mobile/src/hooks/useWarmUpBrowser.tsx @@ -1,14 +1,14 @@ import { useFocusEffect } from "expo-router"; -import * as WebBrowser from "expo-web-browser"; +import { coolDownAsync, warmUpAsync } from "expo-web-browser"; import { sendError } from "@/services/errorTracking"; export const useWarmUpBrowser = () => { useFocusEffect(() => { - WebBrowser.warmUpAsync().catch(sendError); + warmUpAsync().catch(sendError); return () => { - WebBrowser.coolDownAsync().catch(sendError); + coolDownAsync().catch(sendError); }; }); }; diff --git a/apps/mobile/src/i18n.ts b/apps/mobile/src/i18n.ts index b67d615..dde8e95 100644 --- a/apps/mobile/src/i18n.ts +++ b/apps/mobile/src/i18n.ts @@ -1,5 +1,6 @@ +import type { LanguageDetectorAsyncModule } from "i18next"; import { getLocales } from "expo-localization"; -import i18n, { LanguageDetectorAsyncModule } from "i18next"; +import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import { initI18n } from "@pegada/shared/i18n/i18n"; @@ -8,7 +9,7 @@ import { sendError } from "./services/errorTracking"; import { getData, StorageKeys, storeData } from "./services/storage"; export const getSystemLanguage = () => { - const phoneLanguage = getLocales()?.[0]?.languageTag; + const phoneLanguage = getLocales()[0]?.languageTag; return phoneLanguage; }; diff --git a/apps/mobile/src/services/appReview.tsx b/apps/mobile/src/services/appReview.tsx index a6c73ce..a12c309 100644 --- a/apps/mobile/src/services/appReview.tsx +++ b/apps/mobile/src/services/appReview.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { KeyboardAvoidingView, Platform } from "react-native"; import { magicModal, useMagicModal } from "react-native-magic-modal"; import { magicToast } from "react-native-magic-toast"; -import * as StoreReview from "expo-store-review"; +import { requestReview } from "expo-store-review"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components"; import styled from "styled-components/native"; @@ -55,7 +55,7 @@ const Title = styled(CenterText).attrs({ const handleReview = async () => { try { analytics.track({ event_type: "App Review" }); - await StoreReview.requestReview(); + await requestReview(); await storeData(StorageKeys.AppReviewStatus, "completed"); } catch (error) { sendError(error); diff --git a/apps/mobile/src/services/errorTracking.ts b/apps/mobile/src/services/errorTracking.ts index 7101f0d..518654d 100644 --- a/apps/mobile/src/services/errorTracking.ts +++ b/apps/mobile/src/services/errorTracking.ts @@ -1,12 +1,15 @@ import Bugsnag from "@bugsnag/expo"; +import { isError } from "lodash"; import { config } from "./config"; -export const sendError = (error: any) => { +export const sendError = (error: unknown) => { if (config.ENV === "development") { // eslint-disable-next-line no-console console.error(error); - } else { + } else if (isError(error)) { Bugsnag.notify(error); + } else { + Bugsnag.notify(new Error(String(error))); } }; diff --git a/apps/mobile/src/services/getError.ts b/apps/mobile/src/services/getError.ts index 96d2600..67bd3e7 100644 --- a/apps/mobile/src/services/getError.ts +++ b/apps/mobile/src/services/getError.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { get } from "lodash"; type GenericClass = new (...args: any[]) => any; @@ -6,16 +7,18 @@ export const getError = < error_code: string; } >( - error: any, + error: unknown, instance: T ): InstanceType | undefined => { if (error instanceof instance) { - return error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return error as InstanceType; } - const errorCode = get(error, "data.error.error_code"); + const errorCode = get(error, "data.error.error_code") as string | undefined; if (errorCode === instance.error_code) { - return error.data.error; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return get(error, "data.error") as InstanceType; } }; diff --git a/apps/mobile/src/services/getInitialRouteName.ts b/apps/mobile/src/services/getInitialRouteName.ts index 7d7eaf3..f6b021e 100644 --- a/apps/mobile/src/services/getInitialRouteName.ts +++ b/apps/mobile/src/services/getInitialRouteName.ts @@ -12,7 +12,8 @@ export const identifyUser = async ( ) => { try { const userId = await getLoggedUserID(); - return analytics.identify(userId, props); + analytics.identify(userId, props); + return; } catch (e) { sendError(e); } @@ -45,7 +46,7 @@ export const getInitialRouteName = async () => { return SceneName.CreateProfile; } - if (!response.user?.latitude || !response.user?.longitude) { + if (!response.user.latitude || !response.user.longitude) { return SceneName.AskForLocation; } diff --git a/apps/mobile/src/services/getMimeType.ts b/apps/mobile/src/services/getMimeType.ts index 30aa05c..61dd629 100644 --- a/apps/mobile/src/services/getMimeType.ts +++ b/apps/mobile/src/services/getMimeType.ts @@ -1,8 +1,8 @@ +import type { ImagePickerAsset } from "expo-image-picker"; import mime from "react-native-mime-types"; -import { ImagePickerAsset } from "expo-image-picker"; const getMimeTypeFromUri = (uri: string) => { - const fileName = uri?.substring(uri.lastIndexOf("/") + 1, uri.length); + const fileName = uri.substring(uri.lastIndexOf("/") + 1, uri.length); const mimeByFileName = mime.lookup(fileName); if (mimeByFileName) { return mimeByFileName; diff --git a/apps/mobile/src/services/getPushNotificationToken.ts b/apps/mobile/src/services/getPushNotificationToken.ts index 516f205..d4644f3 100644 --- a/apps/mobile/src/services/getPushNotificationToken.ts +++ b/apps/mobile/src/services/getPushNotificationToken.ts @@ -1,18 +1,29 @@ +import type { NotificationBehavior } from "expo-notifications"; import { Platform } from "react-native"; import Constants from "expo-constants"; -import * as Device from "expo-device"; -import * as Notifications from "expo-notifications"; +import { isDevice } from "expo-device"; +import { + AndroidImportance, + getExpoPushTokenAsync, + getPermissionsAsync, + requestPermissionsAsync, + setNotificationChannelAsync, + setNotificationHandler +} from "expo-notifications"; import Color from "color"; import { LightTheme } from "@pegada/shared/themes/themes"; import { getTrcpContext } from "@/contexts/trcpContext"; -Notifications.setNotificationHandler({ - handleNotification: async () => ({ +setNotificationHandler({ + handleNotification: async (): Promise => ({ shouldShowAlert: true, shouldPlaySound: false, - shouldSetBadge: false + shouldSetBadge: false, + // Properties for iOS 15+ notification presentation + shouldShowBanner: true, + shouldShowList: true }) }); @@ -21,28 +32,28 @@ export enum NotificationTokenError { } export const getPushNotificationToken = async () => { - if (!Device.isDevice) return; + if (!isDevice) return; if (Platform.OS === "android") { - await Notifications.setNotificationChannelAsync("default", { + await setNotificationChannelAsync("default", { name: "default", - importance: Notifications.AndroidImportance.MAX, + importance: AndroidImportance.MAX, vibrationPattern: [0, 250, 250, 250], lightColor: Color(LightTheme.colors.primary).alpha(0.7).hex() }); } - const { status: existingStatus } = await Notifications.getPermissionsAsync(); + const { status: existingStatus } = await getPermissionsAsync(); // Makes sure the user has accepted push notifications permissions if (existingStatus !== "granted") { - const { status: newStatus } = await Notifications.requestPermissionsAsync(); + const { status: newStatus } = await requestPermissionsAsync(); if (newStatus !== "granted") { throw new Error(NotificationTokenError.Denied); } } - const { data } = await Notifications.getExpoPushTokenAsync({ + const { data } = await getExpoPushTokenAsync({ projectId: Constants.expoConfig?.extra?.eas?.projectId }); @@ -50,7 +61,7 @@ export const getPushNotificationToken = async () => { }; export const setPushNotificationToken = (pushToken: string) => { - if (!Device.isDevice) return; + if (!isDevice) return; return getTrcpContext().client.user.update.mutate({ pushToken }); }; diff --git a/apps/mobile/src/services/linking/handlers/notification.ts b/apps/mobile/src/services/linking/handlers/notification.ts index 77f499d..3668498 100644 --- a/apps/mobile/src/services/linking/handlers/notification.ts +++ b/apps/mobile/src/services/linking/handlers/notification.ts @@ -1,4 +1,4 @@ -import * as Notifications from "expo-notifications"; +import type { NotificationResponse } from "expo-notifications"; import { router } from "expo-router"; import { sendError } from "@/services/errorTracking"; @@ -10,9 +10,10 @@ enum NotificationUrl { } export const getNotificationUrl = ( - response: Notifications.NotificationResponse + response: NotificationResponse ): string | undefined => { - return response.notification.request.content.data.url; + const data = response.notification.request.content.data as { url?: string }; + return data.url; }; const handleUnknownNotification = (url: string) => { @@ -20,14 +21,14 @@ const handleUnknownNotification = (url: string) => { }; const handleMatchNotification = async (matchId: string, dogId: string) => { - return router.push({ + router.push({ pathname: SceneName.NewMatch, params: { matchDogId: dogId, matchId: matchId } }); }; const handleChatNotification = async (matchId: string, dogId: string) => { - return router.push({ + router.push({ pathname: `${SceneName.Chat}/[matchId]`, params: { dogId, matchId } }); diff --git a/apps/mobile/src/services/linking/index.ts b/apps/mobile/src/services/linking/index.ts index d32ab61..0f1fcb3 100644 --- a/apps/mobile/src/services/linking/index.ts +++ b/apps/mobile/src/services/linking/index.ts @@ -1,5 +1,8 @@ import { useEffect } from "react"; -import * as Notifications from "expo-notifications"; +import { + addNotificationResponseReceivedListener, + getLastNotificationResponseAsync +} from "expo-notifications"; import { sendError } from "@/services/errorTracking"; import { @@ -19,11 +22,12 @@ export const processLinks = () => { setInitialNotification(undefined); // When the app is already running, and the user clicks on a notification - const notificationSubscription = - Notifications.addNotificationResponseReceivedListener((response) => { + const notificationSubscription = addNotificationResponseReceivedListener( + (response) => { const url = getNotificationUrl(response); customNotificationHandler(url).catch(sendError); - }); + } + ); return { remove: () => { @@ -35,7 +39,7 @@ export const processLinks = () => { export const useGetInitialNotifications = () => { useEffect(() => { // When the app is not already running, and the user clicks on a notification - Notifications.getLastNotificationResponseAsync() + getLastNotificationResponseAsync() .then((response) => { if (!response) return; const url = getNotificationUrl(response); @@ -44,11 +48,12 @@ export const useGetInitialNotifications = () => { .catch(sendError); // When the app is already running, and the user clicks on a notification - const notificationSubscription = - Notifications.addNotificationResponseReceivedListener((response) => { + const notificationSubscription = addNotificationResponseReceivedListener( + (response) => { const url = getNotificationUrl(response); setInitialNotification(url); - }); + } + ); return () => { notificationSubscription.remove(); diff --git a/apps/mobile/src/services/openWebBrowser.ts b/apps/mobile/src/services/openWebBrowser.ts index 6099d14..d928951 100644 --- a/apps/mobile/src/services/openWebBrowser.ts +++ b/apps/mobile/src/services/openWebBrowser.ts @@ -1,7 +1,10 @@ -import * as WebBrowser from "expo-web-browser"; +import { + openBrowserAsync, + WebBrowserPresentationStyle +} from "expo-web-browser"; export const openWebBrowser = async (url: string) => { - return WebBrowser.openBrowserAsync(url, { - presentationStyle: WebBrowser.WebBrowserPresentationStyle.PAGE_SHEET + await openBrowserAsync(url, { + presentationStyle: WebBrowserPresentationStyle.PAGE_SHEET }); }; diff --git a/apps/mobile/src/services/payments/index.ts b/apps/mobile/src/services/payments/index.ts index 79c7e85..9b4df66 100644 --- a/apps/mobile/src/services/payments/index.ts +++ b/apps/mobile/src/services/payments/index.ts @@ -1,6 +1,7 @@ +import type { CustomerInfo } from "react-native-purchases"; import { Alert, Platform } from "react-native"; -import Purchases, { CustomerInfo, LOG_LEVEL } from "react-native-purchases"; -import * as Device from "expo-device"; +import Purchases, { LOG_LEVEL } from "react-native-purchases"; +import { isDevice } from "expo-device"; import { get } from "lodash"; import { getTrcpContext } from "@/contexts/trcpContext"; @@ -85,7 +86,7 @@ export enum UserPlan { const purchasePackage = async ( ...props: Parameters ) => { - const isSimulator = Platform.OS === "ios" && !Device.isDevice; + const isSimulator = Platform.OS === "ios" && !isDevice; if (isSimulator) { Alert.alert( @@ -101,9 +102,11 @@ const purchasePackage = async ( const result = await Purchases.purchasePackage(...props); return result; } catch (e) { + const message = get(e, "message"); // On Android, this happens when a transfer is needed (the user already purchased on another account) - if (get(e, "message") === "This product is already active for the user.") { - return restorePurchases(); + if (message === "This product is already active for the user.") { + await restorePurchases(); + return undefined; } throw e; @@ -140,7 +143,7 @@ const getPlan = (customerInfo?: CustomerInfo) => { }; const restorePurchases = async () => { - if (Platform.OS === "ios" && !Device.isDevice) { + if (Platform.OS === "ios" && !isDevice) { Alert.alert( "Simulator Detected", "Restore is not available in the IOS simulator. Please try on a real device." diff --git a/apps/mobile/src/services/queryClient.tsx b/apps/mobile/src/services/queryClient.tsx index 00ed596..fffaa06 100644 --- a/apps/mobile/src/services/queryClient.tsx +++ b/apps/mobile/src/services/queryClient.tsx @@ -1,4 +1,5 @@ -import { AppState, AppStateStatus, Platform } from "react-native"; +import type { AppStateStatus } from "react-native"; +import { AppState, Platform } from "react-native"; import NetInfo from "@react-native-community/netinfo"; import { focusManager, @@ -15,9 +16,9 @@ onlineManager.setEventListener((setOnline) => { }); const onAppStateChange = (status: AppStateStatus) => { - if (Platform.OS !== "web") { - focusManager.setFocused(status === "active"); - } + if (Platform.OS === "web") return; + + focusManager.setFocused(status === "active"); }; AppState.addEventListener("change", onAppStateChange); diff --git a/apps/mobile/src/services/themeUtils.ts b/apps/mobile/src/services/themeUtils.ts index b93c724..1a2e572 100644 --- a/apps/mobile/src/services/themeUtils.ts +++ b/apps/mobile/src/services/themeUtils.ts @@ -1,7 +1,7 @@ -import { DefaultTheme } from "styled-components/native"; +import type { DefaultTheme } from "styled-components/native"; export const getSpacing = (spacing: keyof DefaultTheme["spacing"]) => ({ theme }: { theme: DefaultTheme }) => { - return theme.spacing[spacing] + "px"; + return `${theme.spacing[spacing]}px`; }; diff --git a/apps/mobile/src/services/utils.ts b/apps/mobile/src/services/utils.ts deleted file mode 100644 index 7401e67..0000000 --- a/apps/mobile/src/services/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DependencyList, EffectCallback, useEffect, useRef } from "react"; - -export const useDidMountEffect = ( - func: EffectCallback, - deps: DependencyList -) => { - const didMount = useRef(false); - - useEffect(() => { - if (didMount.current) func(); - else didMount.current = true; - }, deps); -}; diff --git a/apps/mobile/src/store/reducers/dogs/index.ts b/apps/mobile/src/store/reducers/dogs/index.ts index 9b09d8d..f7dc315 100644 --- a/apps/mobile/src/store/reducers/dogs/index.ts +++ b/apps/mobile/src/store/reducers/dogs/index.ts @@ -1,26 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Reducer } from "typesafe-actions"; import reduceReducers from "reduce-reducers"; -import { Reducer } from "typesafe-actions"; -import * as list from "./list"; -import * as logout from "./logout"; -import * as swipe from "./swipe"; -import { initialState } from "./swipe"; +import listReducer, { ListAction, Actions as ListActions } from "./list"; +import logoutReducer, { + LogoutAction, + Actions as LogoutActions +} from "./logout"; +import swipeReducer, { + initialState, + SwipeAction, + Actions as SwipeActions +} from "./swipe"; export const Types = { - ...list.ListAction, - ...swipe.SwipeAction, - ...logout.LogoutAction + ...ListAction, + ...SwipeAction, + ...LogoutAction }; export const Actions = { - swipe: swipe.Actions, - list: list.Actions, - logout: logout.Actions + swipe: SwipeActions, + list: ListActions, + logout: LogoutActions }; export default reduceReducers( initialState, - swipe.default as Reducer, - list.default as Reducer, - logout.default as Reducer + swipeReducer as Reducer, + listReducer as Reducer, + logoutReducer as Reducer ); diff --git a/apps/mobile/src/store/reducers/dogs/list.ts b/apps/mobile/src/store/reducers/dogs/list.ts index be690f5..4c16261 100644 --- a/apps/mobile/src/store/reducers/dogs/list.ts +++ b/apps/mobile/src/store/reducers/dogs/list.ts @@ -1,12 +1,13 @@ +import type { ActionType } from "typesafe-actions"; import { produce } from "immer"; import { - ActionType, createAction, createAsyncAction, createReducer } from "typesafe-actions"; -import { initialState, SwipeDog } from "./swipe"; +import type { SwipeDog } from "./swipe"; +import { initialState } from "./swipe"; export enum ListAction { FetchDogsRequest = "FETCH_DOGS_REQUEST", @@ -19,7 +20,7 @@ const asyncActions = createAsyncAction( ListAction.FetchDogsRequest, ListAction.FetchDogsSuccess, ListAction.FetchDogsFailure -)(); +)(); const refetch = createAction(ListAction.RefetchDogsRequest)(); diff --git a/apps/mobile/src/store/reducers/dogs/logout.ts b/apps/mobile/src/store/reducers/dogs/logout.ts index bff42b9..b20b00b 100644 --- a/apps/mobile/src/store/reducers/dogs/logout.ts +++ b/apps/mobile/src/store/reducers/dogs/logout.ts @@ -1,5 +1,6 @@ +import type { ActionType } from "typesafe-actions"; import { produce } from "immer"; -import { ActionType, createAction, createReducer } from "typesafe-actions"; +import { createAction, createReducer } from "typesafe-actions"; import { initialState } from "@/store/reducers/dogs/swipe"; diff --git a/apps/mobile/src/store/reducers/dogs/swipe.ts b/apps/mobile/src/store/reducers/dogs/swipe.ts index 6c56366..f1413f1 100644 --- a/apps/mobile/src/store/reducers/dogs/swipe.ts +++ b/apps/mobile/src/store/reducers/dogs/swipe.ts @@ -1,6 +1,7 @@ +import type { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; +import type { ActionType } from "typesafe-actions"; import { produce } from "immer"; import { - ActionType, createAction, createAsyncAction, createReducer @@ -8,8 +9,6 @@ import { import type { RouterOutputs } from "@pegada/api"; -import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; - export type SwipeDog = RouterOutputs["swipe"]["all"][number]; interface IInitialState { diff --git a/apps/mobile/src/store/reducers/index.ts b/apps/mobile/src/store/reducers/index.ts index 2c2671f..de1647e 100644 --- a/apps/mobile/src/store/reducers/index.ts +++ b/apps/mobile/src/store/reducers/index.ts @@ -1,22 +1,25 @@ import { combineReducers } from "redux"; -import * as dogs from "./dogs"; -import * as swipe from "./dogs/swipe"; +import type { initialState as SwipeInitialState } from "./dogs/swipe"; +import dogsReducer, { + Actions as DogsActions, + Types as DogsTypes +} from "./dogs"; export const Types = { - ...dogs.Types + ...DogsTypes }; export const Actions = { - dogs: dogs.Actions + dogs: DogsActions }; const rootReducer = combineReducers({ - dogs: dogs.default + dogs: dogsReducer }); export interface RootReducer { - dogs: typeof swipe.initialState; + dogs: typeof SwipeInitialState; } export default rootReducer; diff --git a/apps/mobile/src/store/sagas/dogs/list.ts b/apps/mobile/src/store/sagas/dogs/list.ts index 76529a2..da9000e 100644 --- a/apps/mobile/src/store/sagas/dogs/list.ts +++ b/apps/mobile/src/store/sagas/dogs/list.ts @@ -1,9 +1,12 @@ +import type { RootReducer } from "@/store/reducers"; import { all, call, put, select, takeLatest } from "redux-saga/effects"; +import type { DogSafeSchema } from "@pegada/shared/schemas/dogSchema"; + import { getTrcpContext } from "@/contexts/trcpContext"; import i18n from "@/i18n"; import { sendError } from "@/services/errorTracking"; -import { Actions, RootReducer } from "@/store/reducers"; +import { Actions } from "@/store/reducers"; import { ListAction } from "@/store/reducers/dogs/list"; // Without marking as unknown, saga complains about the swipe all type inference @@ -13,12 +16,15 @@ export function* fetchUsersRequest(): unknown { ); try { - const response = yield call(getTrcpContext().client.swipe.all.query, { - limit: dogs.config.limit, - - // Avoids fetching dogs that are already on screen - notIn: dogs.request.data.map((dog) => dog.id) - }); + const response: DogSafeSchema[] = yield call( + getTrcpContext().client.swipe.all.query, + { + limit: dogs.config.limit, + + // Avoids fetching dogs that are already on screen + notIn: dogs.request.data.map((dog) => dog.id) + } + ); // For each dog, mutate the cache, so that the dog is not fetched again for (const dog of response) { diff --git a/apps/mobile/src/store/sagas/dogs/swipe.ts b/apps/mobile/src/store/sagas/dogs/swipe.ts index 473fd82..244d88d 100644 --- a/apps/mobile/src/store/sagas/dogs/swipe.ts +++ b/apps/mobile/src/store/sagas/dogs/swipe.ts @@ -1,7 +1,8 @@ +import type { RootReducer } from "@/store/reducers"; +import type { ActionType } from "typesafe-actions"; import { router } from "expo-router"; import { isBefore } from "date-fns"; import { all, call, fork, put, select, takeLatest } from "redux-saga/effects"; -import { ActionType } from "typesafe-actions"; import { LikeLimitReached } from "@pegada/shared/errors/errors"; @@ -10,14 +11,14 @@ import { getTrcpContext } from "@/contexts/trcpContext"; import { getUnsafeIsPremium } from "@/hooks/usePayments"; import { sendError } from "@/services/errorTracking"; import { getError } from "@/services/getError"; -import { Actions, RootReducer } from "@/store/reducers"; +import { Actions } from "@/store/reducers"; import { SwipeAction } from "@/store/reducers/dogs/swipe"; import { SceneName } from "@/types/SceneName"; import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; function* swipeUserRequest({ payload -}: ActionType): any { +}: ActionType): unknown { const { id, swipeType: _swipeType } = payload; try { @@ -49,7 +50,7 @@ function* swipeUserRequest({ } yield put(Actions.dogs.swipe.success()); - } catch (err: any) { + } catch (err: unknown) { const likeLimitReachedError = getError(err, LikeLimitReached); if (likeLimitReachedError) { const { likeLimitResetAt } = likeLimitReachedError; @@ -84,7 +85,7 @@ function* handleCardFetching() { export function* handleSwipeUserRequest( props: ActionType -) { +): unknown { yield all([fork(() => swipeUserRequest(props)), fork(handleCardFetching)]); } diff --git a/apps/mobile/src/store/selectors.ts b/apps/mobile/src/store/selectors.ts index 5a57220..9faa912 100644 --- a/apps/mobile/src/store/selectors.ts +++ b/apps/mobile/src/store/selectors.ts @@ -1,7 +1,6 @@ +import type { RootReducer } from "@/store/reducers/index"; import { createSelector } from "reselect"; -import { RootReducer } from "@/store/reducers/index"; - export const getCards = createSelector( (state: RootReducer) => state.dogs.request, (request) => request.data diff --git a/apps/mobile/src/types/styled-components.d.ts b/apps/mobile/src/types/styled-components.d.ts index 1132c86..790da0b 100644 --- a/apps/mobile/src/types/styled-components.d.ts +++ b/apps/mobile/src/types/styled-components.d.ts @@ -1,13 +1,14 @@ +/* eslint-disable @typescript-eslint/no-empty-object-type */ import "styled-components"; -import { DefaultTheme as DefaultPegadaTheme } from "@pegada/shared/themes/themes"; +import type { DefaultTheme as DefaultPegadaTheme } from "@pegada/shared/themes/themes"; type PegadaTheme = typeof DefaultPegadaTheme; declare module "styled-components" { - export type DefaultTheme = PegadaTheme; + export interface DefaultTheme extends PegadaTheme {} } declare module "styled-components/native" { - export type DefaultTheme = PegadaTheme; + export interface DefaultTheme extends PegadaTheme {} } diff --git a/apps/mobile/src/views/(auth)/AskForLocation/index.tsx b/apps/mobile/src/views/(auth)/AskForLocation/index.tsx index 34ac0a4..425f1e7 100644 --- a/apps/mobile/src/views/(auth)/AskForLocation/index.tsx +++ b/apps/mobile/src/views/(auth)/AskForLocation/index.tsx @@ -2,7 +2,13 @@ import { useState } from "react"; import * as React from "react"; import { Alert, Linking, ScrollView } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import * as Location from "expo-location"; +import { + Accuracy, + getCurrentPositionAsync, + getLastKnownPositionAsync, + requestForegroundPermissionsAsync, + reverseGeocodeAsync +} from "expo-location"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; @@ -21,14 +27,14 @@ enum UpdateLocationError { } const getApproximatedPosition = async () => { - const lastKnownPosition = await Location.getLastKnownPositionAsync({ + const lastKnownPosition = await getLastKnownPositionAsync({ maxAge: 1000 * 60 * 60 * 24 * 2 // 2 days }); if (lastKnownPosition) return lastKnownPosition.coords; - const currentPostion = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.Low + const currentPostion = await getCurrentPositionAsync({ + accuracy: Accuracy.Low }); return currentPostion.coords; @@ -38,7 +44,7 @@ export const updateUserLocation = async (newLocation?: { longitude: number; latitude: number; }) => { - const { status } = await Location.requestForegroundPermissionsAsync(); + const { status } = await requestForegroundPermissionsAsync(); if (status !== "granted") { throw new Error(UpdateLocationError.PermissionNotGranted); @@ -46,7 +52,7 @@ export const updateUserLocation = async (newLocation?: { const position = newLocation ?? (await getApproximatedPosition()); - const geocode = await Location.reverseGeocodeAsync({ + const geocode = await reverseGeocodeAsync({ latitude: position.latitude, longitude: position.longitude }); @@ -143,7 +149,7 @@ const AskForLocation: React.FC = () => { error instanceof Error && error.message === UpdateLocationError.PermissionNotGranted ) { - return Alert.alert( + Alert.alert( t("askForLocation.enableLocation"), t("askForLocation.permissionPrompt"), [ @@ -155,6 +161,7 @@ const AskForLocation: React.FC = () => { } ] ); + return; } sendError(error); diff --git a/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx b/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx index 7f123ec..adff47f 100644 --- a/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx +++ b/apps/mobile/src/views/(auth)/CompleteProfile/index.tsx @@ -6,10 +6,7 @@ import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; -import { - DogCompleteClientSchema, - dogCompleteClientSchema -} from "@pegada/shared/schemas/dogSchema"; +import { dogCompleteClientSchema } from "@pegada/shared/schemas/dogSchema"; import { BottomAction, useBottomActionStyle } from "@/components/BottomAction"; import BreedPicker from "@/components/BreedPicker"; @@ -33,14 +30,13 @@ const CompleteProfile = () => { const { profileImageUrl } = useLocalSearchParams(); - const { control, handleSubmit, getValues, watch } = - useForm({ - defaultValues: { - birthDate: "", - breedId: "" - }, - resolver: zodResolver(dogCompleteClientSchema) - }); + const { control, handleSubmit, getValues, watch } = useForm({ + defaultValues: { + birthDate: "", + breedId: "" + }, + resolver: zodResolver(dogCompleteClientSchema) + }); const form = watch(); @@ -120,7 +116,7 @@ const CompleteProfile = () => { onChange(breed.id)} + setBreed={(breed) => { onChange(breed.id); }} error={fieldState.error?.message} optional /> @@ -149,7 +145,7 @@ const CompleteProfile = () => { const currentLength = getValues()[name]?.length ?? 0; const isErasing = value.length < currentLength; - if (isErasing) return onChange(value); + if (isErasing) { onChange(value); return; } // Mask to MM/DD/YYYY onChange(maskDate(value)); @@ -175,7 +171,7 @@ const CompleteProfile = () => { placeholder={t("sizes.small")} data={sizes} value={sizes.find((sizeValue) => sizeValue.id === value)} - onChange={(size) => onChange(size.id)} + onChange={(size) => { onChange(size.id); }} error={fieldState.error?.message} /> )} @@ -193,7 +189,7 @@ const CompleteProfile = () => { placeholder={colors[0]?.name} data={colors} value={colors.find((color) => color.id === value)} - onChange={(color) => onChange(color.id)} + onChange={(color) => { onChange(color.id); }} error={fieldState.error?.message} /> )} diff --git a/apps/mobile/src/views/(auth)/CreateProfile/index.tsx b/apps/mobile/src/views/(auth)/CreateProfile/index.tsx index f7b4162..b9c41aa 100644 --- a/apps/mobile/src/views/(auth)/CreateProfile/index.tsx +++ b/apps/mobile/src/views/(auth)/CreateProfile/index.tsx @@ -1,4 +1,7 @@ +import type { ProfileImagesUploaderProps } from "@/components/ProfileImageUploader"; +import type { Picture } from "@/components/ProfileImageUploader/utils"; import { useState } from "react"; +import * as React from "react"; import { KeyboardAvoidingView, Platform, View } from "react-native"; import { magicToast } from "react-native-magic-toast"; import { useRouter } from "expo-router"; @@ -7,19 +10,14 @@ import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; -import { - DogQuickClientSchema, - dogQuickClientSchema -} from "@pegada/shared/schemas/dogSchema"; +import type { DogQuickClientSchema } from "@pegada/shared/schemas/dogSchema"; +import { dogQuickClientSchema } from "@pegada/shared/schemas/dogSchema"; import { BottomAction, useBottomActionStyle } from "@/components/BottomAction"; import { Button } from "@/components/Button"; import { Input } from "@/components/Input"; -import { - ProfileImagesUploader, - ProfileImagesUploaderProps -} from "@/components/ProfileImageUploader"; -import { Picture, pictures } from "@/components/ProfileImageUploader/utils"; +import { ProfileImagesUploader } from "@/components/ProfileImageUploader"; +import { pictures } from "@/components/ProfileImageUploader/utils"; import { RadioButtons } from "@/components/RadioButtons"; import { Text } from "@/components/Text"; import { getTrcpContext } from "@/contexts/trcpContext"; @@ -86,11 +84,16 @@ const CreateProfile = () => { gender: data.gender, images: data.images .filter((image) => Boolean(image.url)) - .map((image, index) => ({ - id: image.id, - url: image.url as string, - position: index - })) + .map((image, index) => { + if (!image.url) { + throw new Error("Image URL not available after filtering"); + } + return { + id: image.id, + url: image.url, + position: index + }; + }) }; await dogCreateMutation.mutateAsync(dogData); diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx index 57e308d..2eed13d 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/index.tsx @@ -1,104 +1,108 @@ -import { forwardRef, useState } from "react"; -import { TextInput } from "react-native"; +import type { TextInput as RNTextInput } from "react-native"; +import { useState } from "react"; import { useTheme } from "styled-components/native"; -import * as S from "./styles"; +import { + AbsoluteContainer, + Container, + isSmallDevice, + StyledText, + TextInput +} from "./styles"; export enum KeyboardKeys { Backspace = "Backspace" } -type OtpDigitProps = { - children: string; +interface OtpDigitProps { + children?: string; length: number; index: number; handleChange: (text: string, index: number) => void; handleErase: (text: string, index: number) => void; pointerEvents?: "auto" | "none"; testID: string; -}; + ref: React.RefObject; +} -export const OTP_INPUT_HEIGHT = S.isSmallDevice ? 62 : 80; +export const OTP_INPUT_HEIGHT = isSmallDevice ? 62 : 80; export const OTP_INPUT_MARGIN = 6; -const OtpDigit = forwardRef< - TextInput, - Omit, "children"> & - OtpDigitProps ->( - ( - { - children, - index, - length, - pointerEvents, - handleChange, - handleErase, - testID - }, - ref - ) => { - const [selected, setSelected] = useState(false); +const OtpDigit = ({ + ref, + children, + index, + length, + pointerEvents, + handleChange, + handleErase, + testID +}: OtpDigitProps) => { + const [selected, setSelected] = useState(false); - const { colors } = useTheme(); + const { colors } = useTheme(); - const isFirst = index === 0; - const isLast = index === length - 1; + const isFirst = index === 0; + const isLast = index === length - 1; - const rightMargin = isLast ? 0 : OTP_INPUT_MARGIN; - const selectedBorderColor = colors.border; + const rightMargin = isLast ? 0 : OTP_INPUT_MARGIN; + const selectedBorderColor = colors.border; - const digit = isNaN(Number(children)) ? "" : children; + const digit = children && !isNaN(Number(children)) ? children : ""; - return ( - + { + setSelected(false); }} - > - setSelected(false)} - onFocus={() => setSelected(true)} - accessibilityLabel="Text input field" - accessibilityHint="Enter the verification code" - value={digit} - keyboardType="number-pad" - onChangeText={(text: string) => handleChange(text, index)} - numberOfLines={1} - maxLength={length} - returnKeyType="next" - pointerEvents={pointerEvents} - selectionColor="transparent" - autoFocus={isFirst} - importantForAutofill={isFirst ? "yes" : "no"} - textContentType={isFirst ? "oneTimeCode" : "none"} - autoComplete={isFirst ? "sms-otp" : "off"} - onKeyPress={({ - nativeEvent: { key } - }: { - nativeEvent: { key: KeyboardKeys }; - }) => { - if (key === KeyboardKeys.Backspace) - return handleErase(digit, index); - }} - /> + onFocus={() => { + setSelected(true); + }} + accessibilityLabel="Text input field" + accessibilityHint="Enter the verification code" + value={digit} + keyboardType="number-pad" + onChangeText={(text: string) => { + handleChange(text, index); + }} + numberOfLines={1} + maxLength={length} + returnKeyType="next" + pointerEvents={pointerEvents} + selectionColor="transparent" + autoFocus={isFirst} + importantForAutofill={isFirst ? "yes" : "no"} + textContentType={isFirst ? "oneTimeCode" : "none"} + autoComplete={isFirst ? "sms-otp" : "off"} + onKeyPress={(e) => { + const { key } = e.nativeEvent; + if (key === KeyboardKeys.Backspace) { + handleErase(digit, index); + return; + } + }} + /> - - - {digit || "0"} - - - - ); - } -); + + + {digit || "0"} + + + + ); +}; export default OtpDigit; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts index fbbcc2b..7189cc0 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpDigit/styles.ts @@ -1,3 +1,7 @@ +import type { + TextInput as _TextInput, + TextInputProps as _TextInputProps +} from "react-native"; import { Dimensions } from "react-native"; import styled from "styled-components/native"; @@ -12,7 +16,8 @@ export const Container = styled.View` background-color: ${(props) => props.theme.colors.input}; `; -export const TextInput = styled.TextInput` +type TextInputProps = _TextInputProps & { ref?: React.RefObject<_TextInput> }; +export const TextInput = styled.TextInput` flex: 1; text-align: center; color: transparent; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx index 85ce861..bbc62bb 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/components/OtpInput/index.tsx @@ -1,15 +1,15 @@ -import { forwardRef, useImperativeHandle, useRef } from "react"; +import type { TextInput } from "react-native"; +import { useImperativeHandle, useRef } from "react"; import * as React from "react"; -import { TextInput } from "react-native"; import OtpDigit, { OTP_INPUT_HEIGHT, OTP_INPUT_MARGIN } from "../OtpDigit"; import { VerifyRowView } from "./styles"; const OTP_INPUT_MAX_WIDTH = OTP_INPUT_HEIGHT; -export type OtpInputRef = { +export interface OtpInputRef { focus: () => void; -}; +} interface OtpInputProps { value: string; @@ -17,71 +17,79 @@ interface OtpInputProps { onChangeText: React.Dispatch>; } -const OTPInput = forwardRef( - ({ length, value, onChangeText }, ref) => { - const inputRefs: { current: (TextInput | null | any)[] } = useRef([]); - - const handleFocus = () => { - inputRefs.current[0]?.focus(); - }; - - useImperativeHandle(ref, () => ({ - focus: handleFocus - })); - - const changeDigit = (digit: string, index: number) => { - return onChangeText((previousValue) => { - const newValue = previousValue - .slice(0, index) - .concat(digit) - .concat(previousValue.slice(index + 1)); - - return newValue.slice(0, length); - }); - }; - - const handleChange = (digit: string, index: number) => { - if (!digit || digit.match(/[^0-9]/g)) return; - changeDigit(digit, index); - - const nextIndex = Math.min(index + digit.length - 1, length - 1); - - inputRefs.current?.[nextIndex]?.focus(); - }; - - const handleErase = (_digit: string, index: number) => { - changeDigit("", index); - - inputRefs.current?.[index - 1]?.focus(); - }; - - const otp_max_width = (OTP_INPUT_MAX_WIDTH + OTP_INPUT_MARGIN) * length; - - return ( - - {[...Array(length)].map((_, index) => { - const previousValue = value?.[index - 1]; - const isFirst = index === 0; - - return ( - { +const OTPInput = ({ + ref, + length, + value, + onChangeText +}: OtpInputProps & { + ref: React.RefObject; +}) => { + const inputRefs = useRef<(TextInput | null)[]>([]); + + const handleFocus = () => { + inputRefs.current[0]?.focus(); + }; + + useImperativeHandle(ref, () => ({ + focus: handleFocus + })); + + const changeDigit = (digit: string, index: number) => { + onChangeText((previousValue) => { + const newValue = previousValue + .slice(0, index) + .concat(digit) + .concat(previousValue.slice(index + 1)); + + return newValue.slice(0, length); + }); + }; + + const handleChange = (digit: string, index: number) => { + if (!digit || digit.match(/[^0-9]/g)) return; + changeDigit(digit, index); + + const nextIndex = Math.min(index + digit.length - 1, length - 1); + + inputRefs.current[nextIndex]?.focus(); + }; + + const handleErase = (_digit: string, index: number) => { + changeDigit("", index); + + inputRefs.current[index - 1]?.focus(); + }; + + const otp_max_width = (OTP_INPUT_MAX_WIDTH + OTP_INPUT_MARGIN) * length; + + return ( + + {[...Array(length)].map((_, index) => { + const previousValue = value[index - 1]; + const isFirst = index === 0; + + return ( + { inputRefs.current[index] = el; - }} - index={index} - length={length} - pointerEvents={previousValue || isFirst ? "auto" : "none"} - handleChange={handleChange} - handleErase={handleErase} - > - {value[index]} - - ); - })} - - ); - } -); + }) as unknown as React.RefObject + } + testID={`otp-input-${index}`} + index={index} + length={length} + pointerEvents={previousValue || isFirst ? "auto" : "none"} + handleChange={handleChange} + handleErase={handleErase} + > + {value[index]} + + ); + })} + + ); +}; export default OTPInput; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts b/apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts index 6525ac9..585696c 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts +++ b/apps/mobile/src/views/(auth)/OneTimeCode/hooks/useTimer.ts @@ -1,4 +1,5 @@ -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; const useTimer = ( seconds: number @@ -6,10 +7,10 @@ const useTimer = ( const [timer, setTimer] = useState(seconds); useEffect(() => { - if (timer) { - const timer = setInterval(() => setTimer((count) => count - 1), 1000); - return () => clearInterval(timer); - } + if (!timer) return; + + const interval = setInterval(() => { setTimer((count) => count - 1); }, 1000); + return () => { clearInterval(interval); }; }, [timer]); return [timer, setTimer]; diff --git a/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx b/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx index fdd5ef8..54c8ad2 100644 --- a/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx +++ b/apps/mobile/src/views/(auth)/OneTimeCode/index.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Platform } from "react-native"; import { magicToast } from "react-native-magic-toast"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -11,17 +11,18 @@ import { OTPRequiredError } from "@pegada/shared/errors/errors"; +import type { OtpInputRef } from "./components/OtpInput"; import { Text } from "@/components/Text"; import { api } from "@/contexts/TRPCProvider"; +import { useIsFirstRenderRef } from "@/hooks/useIsFirstRenderRef"; import { analytics } from "@/services/analytics"; import { sendError } from "@/services/errorTracking"; import { getError } from "@/services/getError"; import { getInitialRouteName } from "@/services/getInitialRouteName"; import { StorageKeys, storeData } from "@/services/storage"; -import { useDidMountEffect } from "@/services/utils"; import { Underline } from "../SignIn/components/HeroText"; import GoBack from "./components/GoBack"; -import OTPInput, { OtpInputRef } from "./components/OtpInput"; +import OTPInput from "./components/OtpInput"; import useTimer from "./hooks/useTimer"; import { Container, @@ -51,7 +52,7 @@ const OneTimeCode = () => { const router = useRouter(); const { t } = useTranslation(); - const inputRef = useRef(null); + const inputRef = useRef(null); const insetTop = Math.max(15 + insets.top, 50); @@ -97,11 +98,13 @@ const OneTimeCode = () => { loginMutation.mutate({ email: email as string }); }; - useDidMountEffect(() => { - if (keyboardInput.length === CODE_LENGTH) { - loginMutation.mutate({ email: email as string, code: keyboardInput }); - } - }, [keyboardInput]); + const isFirstRender = useIsFirstRenderRef(); + + useEffect(() => { + if (keyboardInput.length !== CODE_LENGTH || isFirstRender.current) return; + + loginMutation.mutate({ email: email as string, code: keyboardInput }); + }, [isFirstRender, keyboardInput, loginMutation, email]); return ( { paddingRight: insets.right + 20 }} > - router.back()} /> + { + router.back(); + }} + /> diff --git a/apps/mobile/src/views/(auth)/SignIn/index.tsx b/apps/mobile/src/views/(auth)/SignIn/index.tsx index f3c387f..38cabee 100644 --- a/apps/mobile/src/views/(auth)/SignIn/index.tsx +++ b/apps/mobile/src/views/(auth)/SignIn/index.tsx @@ -51,10 +51,11 @@ const InsertEmail = () => { if (getError(error, OTPRequiredError)) { requestTrackingPermissionsAsync().catch(sendError); - return router.push({ + router.push({ pathname: SceneName.OneTimeCode, params: { email } }); + return; } Alert.alert(t("common.oops"), t("insertEmail.loginError")); @@ -66,14 +67,19 @@ const InsertEmail = () => { const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); if (!isValidEmail) { - return setError(t("insertEmail.validEmail")); + setError(t("insertEmail.validEmail")); + return; } loginMutation.mutate({ email }); }; return ( - Keyboard.dismiss()}> + { + Keyboard.dismiss(); + }} + > @@ -96,6 +102,7 @@ const InsertEmail = () => { enablesReturnKeyAutomatically returnKeyType="send" onSubmitEditing={handleLogin} + // eslint-disable-next-line @typescript-eslint/no-deprecated blurOnSubmit={false} placeholder={t("insertEmail.emailPlaceholder")} value={email} diff --git a/apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx b/apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx index aa6351a..0415219 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/EmptyMessages.tsx @@ -58,11 +58,11 @@ export const EmptyMessages: React.FC = ({ {search ? ( - ) : ( - )} diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx index 420f226..ef7c4ea 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/Header/index.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { FlatList, View } from "react-native"; import { useTheme } from "styled-components/native"; -import { Match } from "../.."; +import type { Match } from "../.."; import { Preview } from "../Preview"; import { Container } from "./styles"; @@ -10,6 +10,11 @@ interface HeaderProps { matches?: Match[]; } +const Separator = () => { + const theme = useTheme(); + return ; +}; + export const Header: React.FC = ({ matches }) => { const theme = useTheme(); @@ -25,9 +30,7 @@ export const Header: React.FC = ({ matches }) => { contentContainerStyle={{ paddingHorizontal: theme.spacing[4] }} - ItemSeparatorComponent={() => ( - - )} + ItemSeparatorComponent={Separator} showsHorizontalScrollIndicator={false} /> diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx index 13898d9..e1dad1f 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/Message/index.tsx @@ -3,11 +3,11 @@ import { View } from "react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; +import type { Match } from "../.."; import { ThinkingEmoji } from "@/components/MatchActionBar/styles"; import { Text } from "@/components/Text"; import { SceneName } from "@/types/SceneName"; import { Swipe } from "@/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture"; -import { Match } from "../.."; import { Container, EmojiContainer, Picture } from "./styles"; const getEmojiBySwipeType = (swipeType?: Swipe) => { @@ -35,10 +35,10 @@ export const Message: React.FC = ({ item }) => { return ( - router.push({ + { router.push({ pathname: `${SceneName.Chat}/[matchId]`, params: { dogId: item.dog.id, matchId: item.id } - }) + }); } } > diff --git a/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx b/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx index acd679b..8d90762 100644 --- a/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx +++ b/apps/mobile/src/views/(tabs)/Messages/components/Preview/index.tsx @@ -1,8 +1,8 @@ +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; import * as React from "react"; import { useRouter } from "expo-router"; import { Text } from "@/components/Text"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import { SceneName } from "@/types/SceneName"; import { Container, Content, Picture } from "./styles"; @@ -19,10 +19,10 @@ export const Preview: React.FC = ({ item }) => { return ( - router.push({ + { router.push({ pathname: `${SceneName.Chat}/[matchId]`, params: { dogId: item.dog.id, matchId: item.id } - }) + }); } } > { if (!a.lastMessage) return 1; if (!b.lastMessage) return -1; - if (a.lastMessage?.createdAt < b.lastMessage?.createdAt) return 1; - if (a.lastMessage?.createdAt > b.lastMessage?.createdAt) return -1; + if (a.lastMessage.createdAt < b.lastMessage.createdAt) return 1; + if (a.lastMessage.createdAt > b.lastMessage.createdAt) return -1; return 0; }); @@ -120,7 +122,7 @@ const Messages = () => { return ( - {Boolean(matches?.length) && ( + {Boolean(matches.length) && ( )} { data={data} ref={scrollRef} keyExtractor={getKeyMemoized} - ListHeaderComponent={data?.length ? MemoizedHeader : undefined} + ListHeaderComponent={data.length ? MemoizedHeader : undefined} ItemSeparatorComponent={MemoizedDivider} renderItem={({ item }) => { return ; @@ -143,7 +145,7 @@ const Messages = () => { paddingTop: theme.spacing[1], // Increase size only if data is empty // Otherwise it bugs stuff - flexGrow: data?.length ? undefined : 1 + flexGrow: data.length ? undefined : 1 }} /> diff --git a/apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx index b0e7531..74b711e 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/CurrentPlanConfig.tsx @@ -3,25 +3,14 @@ import Constants from "expo-constants"; import { useRouter } from "expo-router"; import { format } from "date-fns"; import { useTranslation } from "react-i18next"; -import styled, { useTheme } from "styled-components/native"; +import { useTheme } from "styled-components/native"; import Premium from "@/assets/images/Badge.svg"; -import Loading from "@/components/Loading"; import { useCustomerPlan } from "@/hooks/usePayments"; import { UserPlan } from "@/services/payments"; import { SceneName } from "@/types/SceneName"; import { Config } from "./Config"; -const PlanLoading = styled.View` - width: 18px; - height: 18px; - transform: translateY(2px) translateX(-12px); -`; - -const StyledLoading = styled(Loading)` - height: 12px; -`; - export const CurrentPlanConfig = () => { const plan = useCustomerPlan(); const { t } = useTranslation(); @@ -32,7 +21,7 @@ export const CurrentPlanConfig = () => { const userPlan = plan.data?.userPlan; const expirationDate = plan.data?.expirationDate - ? format(plan.data?.expirationDate, "MMM do") + ? format(plan.data.expirationDate, "MMM do") : null; const handlePress = () => { @@ -41,7 +30,8 @@ export const CurrentPlanConfig = () => { } if (userPlan === UserPlan.Free) { - return router.push(SceneName.UpgradeWall); + router.push(SceneName.UpgradeWall); + return; } if (Platform.OS === "android") { @@ -58,11 +48,6 @@ export const CurrentPlanConfig = () => { {t("profile.plan.currentPlan")} - {plan.isLoading ? ( - - - - ) : null} {userPlan ? ( {t(`plans.${userPlan}`)} ) : null} @@ -72,14 +57,12 @@ export const CurrentPlanConfig = () => { ) : null} - {!plan.isLoading ? ( - - {userPlan === UserPlan.Free && t("profile.plan.upgradeToPremium")} - {userPlan === UserPlan.Premium && - t("profile.plan.until", { date: expirationDate })} - {plan.isError ? t("profile.plan.clickToRetry") : null} - - ) : null} + + {userPlan === UserPlan.Free && t("profile.plan.upgradeToPremium")} + {userPlan === UserPlan.Premium && + t("profile.plan.until", { date: expirationDate })} + {plan.isError ? t("profile.plan.clickToRetry") : null} + ); diff --git a/apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx index 056a892..db88cc9 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/LanguageConfig.tsx @@ -1,5 +1,5 @@ +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useRef } from "react"; -import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; @@ -36,7 +36,7 @@ export const LanguageConfig = () => { const pickerSheetRef = useRef(null); return ( - pickerSheetRef?.current?.present()}> + pickerSheetRef.current?.present()}> diff --git a/apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx index b52f851..9c0daa5 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/LocationConfig.tsx @@ -44,7 +44,7 @@ export const LocationConfig = () => { const theme = useTheme(); return ( - router.push(SceneName.LocationMap)}> + { router.push(SceneName.LocationMap); }}> diff --git a/apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx b/apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx index 88708be..17c7757 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/ThemeConfig.tsx @@ -1,11 +1,12 @@ +import type { ActiveTheme } from "@/contexts/ThemeProvider"; +import type { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useRef } from "react"; -import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { useTranslation } from "react-i18next"; import { useTheme } from "styled-components/native"; import LightMode from "@/assets/images/LightMode.svg"; import { PickerSheet } from "@/components/Picker"; -import { ActiveTheme, useActiveTheme } from "@/contexts/ThemeProvider"; +import { useActiveTheme } from "@/contexts/ThemeProvider"; import { sendError } from "@/services/errorTracking"; import { Config } from "./Config"; @@ -35,7 +36,7 @@ export const ThemeConfig = () => { const pickerSheetRef = useRef(null); return ( - pickerSheetRef?.current?.present()}> + pickerSheetRef.current?.present()}> diff --git a/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx b/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx index 64cd097..f5116b7 100644 --- a/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/components/UserDogProfileHeader/index.tsx @@ -5,7 +5,12 @@ import { useTheme } from "styled-components/native"; import Premium from "@/assets/images/Premium.svg"; import { BIO_NUMBER_OF_LINES } from "@/components/MainCard/components/PersonalInfo"; -import * as PersonalInfo from "@/components/MainCard/components/PersonalInfo/styles"; +import { + Age as PersonalInfoAge, + Container as PersonalInfoContainer, + Description as PersonalInfoDescription, + Name as PersonalInfoName +} from "@/components/MainCard/components/PersonalInfo/styles"; import { Container, Picture } from "@/components/MainCard/styles"; import { NetworkBoundary } from "@/components/NetworkBoundary"; import { api } from "@/contexts/TRPCProvider"; @@ -55,7 +60,7 @@ const UserDogProfileHeader = () => { "rgba(0, 0, 0, .7)" ]} > - + { gap: theme.spacing[1.5] }} > - {dog.name} {dog.birthDate ? ( - , {getFormattedYears(dog.birthDate)} - + ) : null} - + {plan.data?.userPlan === UserPlan.Premium ? ( ) : null} {dog.bio ? ( - {dog.bio} - + ) : null} - + ); diff --git a/apps/mobile/src/views/(tabs)/Profile/index.tsx b/apps/mobile/src/views/(tabs)/Profile/index.tsx index c17e543..e158c48 100644 --- a/apps/mobile/src/views/(tabs)/Profile/index.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/index.tsx @@ -152,7 +152,7 @@ const Profile = () => { - router.push(SceneName.Preferences)}> + { router.push(SceneName.Preferences); }}> {t("profile.matchPreferences")} @@ -164,7 +164,7 @@ const Profile = () => { - router.push(SceneName.EditProfile)}> + { router.push(SceneName.EditProfile); }}> {t("profile.editProfile")} diff --git a/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx b/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx index 14be642..117629e 100644 --- a/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx +++ b/apps/mobile/src/views/(tabs)/Profile/utils/deleteAccount.tsx @@ -31,7 +31,7 @@ export const deleteAccount = () => { getTrcpContext() .client.myDog.delete.mutate() .then(() => logout()) - .catch((error) => { + .catch((error: unknown) => { Alert.alert(t("common.oops"), t("common.tryAgainLater")); sendError(error); }); diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx index 680ef1e..f970cf0 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeBackButton/index.tsx @@ -28,7 +28,7 @@ const SwipeBackButton = () => { // Free users can't swipe back if (!isPremium) { - return router.push(SceneName.UpgradeWall); + router.push(SceneName.UpgradeWall); return; } return dispatch(Actions.dogs.swipe.swipeBack()); diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts index 0718e02..3c10ac5 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/hooks/useSwipeGesture.ts @@ -1,12 +1,12 @@ -import { useState } from "react"; -import { - Gesture, +import type { GestureEventPayload, PanGestureHandlerEventPayload } from "react-native-gesture-handler"; +import type { SharedValue } from "react-native-reanimated"; +import { useState } from "react"; +import { Gesture } from "react-native-gesture-handler"; import { runOnJS, - SharedValue, useSharedValue, withSpring, withTiming @@ -113,7 +113,7 @@ export const useSwipeGesture = ({ onSwipeComplete }: UseSwipeGestureProps) => { // the card to be swiped again. Otherwise the dog will be able // to 'catch' the card on the middle of the animation. const safelyEnableWithDelay = (duration: number) => { - setTimeout(() => setEnabled(true), duration); + setTimeout(() => { setEnabled(true); }, duration); }; const gotoDirection = ( @@ -127,7 +127,7 @@ export const useSwipeGesture = ({ onSwipeComplete }: UseSwipeGestureProps) => { runOnJS(setEnabled)(false); const swipeCoordinates = getDirectionCoordinates(swipeDirection); - return gotoCoordinate( + gotoCoordinate( translation, swipeCoordinates, () => { @@ -151,7 +151,7 @@ export const useSwipeGesture = ({ onSwipeComplete }: UseSwipeGestureProps) => { const swipeType = getSwipeType(event); if (swipeType) { - return gotoDirection(swipeType); + gotoDirection(swipeType); return; } translation.x.value = withSpring(0, { stiffness: 50 }); diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx index 3628432..9860b2d 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeHandler/index.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import type { SwipeDog } from "@/store/reducers/dogs/swipe"; +import { useCallback, useEffect } from "react"; import * as React from "react"; import { StyleSheet } from "react-native"; import { GestureDetector } from "react-native-gesture-handler"; @@ -10,13 +11,13 @@ import Animated, { } from "react-native-reanimated"; import { useDispatch, useSelector } from "react-redux"; +import type { Swipe } from "./hooks/useSwipeGesture"; import FeedbackCard from "@/components/FeedbackCard"; import { ACTION_OFFSET } from "@/constants"; -import { useDidMountEffect } from "@/services/utils"; +import { useIsFirstRenderRef } from "@/hooks/useIsFirstRenderRef"; import { Actions } from "@/store/reducers"; -import { SwipeDog } from "@/store/reducers/dogs/swipe"; import { getCurrentCardId } from "@/store/selectors"; -import { Swipe, useSwipeGesture } from "./hooks/useSwipeGesture"; +import { useSwipeGesture } from "./hooks/useSwipeGesture"; const ROTATION_DEG = 8; @@ -44,30 +45,32 @@ const SwipeHandler: React.FC = ({ card }) => { { onSwipeComplete } ); - const automaticSwipe = (swipeType: Swipe) => { - "worklet"; + const automaticSwipe = useCallback( + (swipeType: Swipe) => { + "worklet"; - gotoDirection(swipeType, { duration: 500 }); - }; + gotoDirection(swipeType, { duration: 500 }); + }, + [gotoDirection] + ); - // useImperativeHandle unloads the ref depending on component rendering order - // This is a new behavior that caused bugs, and had to be replaced with useEffect useEffect(() => { - if (isFirstCard) { - // @ts-expect-error - ref is mutable, ts doesn't know it - swipeHandlerRef.current = { - gotoDirection: runOnUI(automaticSwipe) - }; - } + if (!isFirstCard) return; + + swipeHandlerRef.current = { + gotoDirection: runOnUI(automaticSwipe) + }; }, [automaticSwipe, isFirstCard]); - useDidMountEffect(() => { - if (isFirstCard) { - // eslint-disable-next-line react-compiler/react-compiler -- false positive - translation.x.value = withSpring(0, { stiffness: 50 }); - translation.y.value = withSpring(0, { stiffness: 50 }); - } - }, [isFirstCard]); + const isFirstRender = useIsFirstRenderRef(); + + useEffect(() => { + if (!isFirstCard || isFirstRender.current) return; + + // eslint-disable-next-line react-compiler/react-compiler + translation.x.value = withSpring(0, { stiffness: 50 }); + translation.y.value = withSpring(0, { stiffness: 50 }); + }, [isFirstCard, isFirstRender, translation.x, translation.y]); const transform = useAnimatedStyle(() => { "worklet"; diff --git a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx index 230ac74..de89fe6 100644 --- a/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx +++ b/apps/mobile/src/views/(tabs)/Swipe/components/SwipeRequestFeedback/index.tsx @@ -1,3 +1,4 @@ +import type { RootReducer } from "@/store/reducers"; import { View } from "react-native"; import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; import { router } from "expo-router"; @@ -11,7 +12,7 @@ import { useIsOffline } from "@/components/NetworkBoundary"; import { Container, Content } from "@/components/NetworkBoundary/styles"; -import { Actions, RootReducer } from "@/store/reducers"; +import { Actions } from "@/store/reducers"; import { SceneName } from "@/types/SceneName"; import { Description, EmptyAnimation, LogoLoading, Title } from "./styles"; @@ -40,7 +41,7 @@ const EmptyState = () => { {t("swipeRequestFeedback.emptyDescription")}