diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d8fc6e879..3899f1b20 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -69,6 +69,7 @@ "react-dom": "^18.3.1", "react-hook-form": "7.54.0", "react-native": "0.76.5", + "react-native-bouncy-checkbox": "4.1.2", "react-native-context-menu-view": "1.16.0", "react-native-gesture-handler": "~2.20.2", "react-native-image-colors": "2.4.0", diff --git a/apps/mobile/src/components/common/ThemedText.tsx b/apps/mobile/src/components/common/ThemedText.tsx index d10df5793..48d7be8db 100644 --- a/apps/mobile/src/components/common/ThemedText.tsx +++ b/apps/mobile/src/components/common/ThemedText.tsx @@ -1,6 +1,7 @@ +import { cn } from "@follow/utils" import type { TextProps } from "react-native" import { Text } from "react-native" export function ThemedText(props: TextProps) { - return + return } diff --git a/apps/mobile/src/components/ui/context-menu/index.ios.tsx b/apps/mobile/src/components/ui/context-menu/index.ios.tsx index 5e504ee9a..33d4c2d9a 100644 --- a/apps/mobile/src/components/ui/context-menu/index.ios.tsx +++ b/apps/mobile/src/components/ui/context-menu/index.ios.tsx @@ -15,6 +15,7 @@ export const ContextMenu: FC = ({ onPressMenuItem, children, renderPreview, + onPressPreview, ...props }) => { const [actionKeyMap] = useState(() => new Map()) @@ -80,6 +81,7 @@ export const ContextMenu: FC = ({ } renderPreview={renderPreview} menuConfig={menuViewConfig} + onPressMenuPreview={onPressPreview} onPressMenuItem={(e) => { onPressMenuItem(actionKeyMap.get(e.nativeEvent.actionKey)!) }} diff --git a/apps/mobile/src/components/ui/context-menu/types.ts b/apps/mobile/src/components/ui/context-menu/types.ts index a8c605b2f..74768cf0d 100644 --- a/apps/mobile/src/components/ui/context-menu/types.ts +++ b/apps/mobile/src/components/ui/context-menu/types.ts @@ -34,4 +34,8 @@ export interface ContextMenuProps extends ViewProps { * @note only available on iOS */ renderPreview?: RenderItem + /** + * @note only available on iOS + */ + onPressPreview?: () => void } diff --git a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx index 47833fad0..f6ad599be 100644 --- a/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx +++ b/apps/mobile/src/components/ui/typography/MarkdownWeb.tsx @@ -8,12 +8,18 @@ import { useDarkMode } from "usehooks-ts" import { useCSSInjection } from "@/src/theme/web" -const MarkdownWeb: WebComponent<{ value: string }> = ({ value }) => { +const MarkdownWeb: WebComponent<{ value: string; style?: React.CSSProperties }> = ({ + value, + style, +}) => { useCSSInjection() const { isDarkMode } = useDarkMode() return ( -
+
{useMemo(() => parseMarkdown(value).content, [value])}
) diff --git a/apps/mobile/src/contexts/LoginTeamsContext.tsx b/apps/mobile/src/contexts/LoginTeamsContext.tsx new file mode 100644 index 000000000..f79b2b18b --- /dev/null +++ b/apps/mobile/src/contexts/LoginTeamsContext.tsx @@ -0,0 +1,7 @@ +import { createContext } from "react" + +export const LoginTeamsCheckedContext = createContext(__DEV__) + +export const LoginTeamsCheckGuardContext = createContext<((callback: () => void) => void) | null>( + () => {}, +) diff --git a/apps/mobile/src/modules/login/email.tsx b/apps/mobile/src/modules/login/email.tsx index 8ae7f5828..f90a55361 100644 --- a/apps/mobile/src/modules/login/email.tsx +++ b/apps/mobile/src/modules/login/email.tsx @@ -1,6 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useMutation } from "@tanstack/react-query" -import { useEffect } from "react" +import { router } from "expo-router" +import { useContext, useEffect } from "react" import type { Control } from "react-hook-form" import { useController, useForm } from "react-hook-form" import type { TextInputProps } from "react-native" @@ -17,6 +18,7 @@ import { z } from "zod" import { ReAnimatedPressable } from "@/src/components/common/AnimatedComponents" import { ThemedText } from "@/src/components/common/ThemedText" +import { LoginTeamsCheckGuardContext } from "@/src/contexts/LoginTeamsContext" import { signIn } from "@/src/lib/auth" import { toast } from "@/src/lib/toast" import { accentColor, useColor } from "@/src/theme/colors" @@ -75,7 +77,10 @@ export function EmailLogin() { mutationFn: onSubmit, }) - const login = handleSubmit((values) => submitMutation.mutate(values)) + const teamsCheckGuard = useContext(LoginTeamsCheckGuardContext) + const login = handleSubmit((values) => { + teamsCheckGuard?.(() => submitMutation.mutate(values)) + }) const disableColor = useColor("gray3") diff --git a/apps/mobile/src/modules/login/index.tsx b/apps/mobile/src/modules/login/index.tsx index c7c087f0b..a31ffcdea 100644 --- a/apps/mobile/src/modules/login/index.tsx +++ b/apps/mobile/src/modules/login/index.tsx @@ -1,36 +1,146 @@ +import { noop } from "es-toolkit/compat" +import { router } from "expo-router" +import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react" import { TouchableWithoutFeedback, View } from "react-native" +import BouncyCheckbox from "react-native-bouncy-checkbox" import { KeyboardController } from "react-native-keyboard-controller" +import Animated, { + runOnUI, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated" import { ThemedText } from "@/src/components/common/ThemedText" +import { ContextMenu } from "@/src/components/ui/context-menu" import { Logo } from "@/src/components/ui/logo" +import { + LoginTeamsCheckedContext, + LoginTeamsCheckGuardContext, +} from "@/src/contexts/LoginTeamsContext" +import { isIOS } from "@/src/lib/platform" +import { toast } from "@/src/lib/toast" +import { TeamsMarkdown } from "@/src/screens/(headless)/teams" import { EmailLogin } from "./email" import { SocialLogin } from "./social" export function Login() { + const [isChecked, setIsChecked] = useState(false) + + const teamsCheckBoxRef = useRef<{ shake: () => void }>(null) return ( - - { - KeyboardController.dismiss() - }} - accessible={false} + + void) => { + if (isChecked) { + callback() + } else { + toast.info("Please accept the Terms of Service and Privacy Policy") + + teamsCheckBoxRef.current?.shake() + } + }, + [isChecked], + )} > - - - Login to Follow - + + { + KeyboardController.dismiss() + }} + accessible={false} + > + + + Login to Follow + + + + + + + + + or + + + + - - - - - - or - - - - - + + + ) +} + +const TeamsCheckBox = forwardRef< + { shake: () => void }, + { + isChecked: boolean + setIsChecked: (isChecked: boolean) => void + } +>(({ isChecked, setIsChecked }, ref) => { + const shakeSharedValue = useSharedValue(0) + const shakeStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: shakeSharedValue.value }], + })) + useImperativeHandle(ref, () => ({ + shake: () => { + const animations = [-10, 10, -8, 8, -6, 6, 0] + + runOnUI(() => { + "worklet" + shakeSharedValue.value = 0 + + const runAnimation = (index: number) => { + "worklet" + if (index < animations.length) { + shakeSharedValue.value = withTiming(animations[index], { duration: 100 }, () => { + runAnimation(index + 1) + }) + } + } + + runAnimation(0) + })() + }, + })) + return ( + + } + onLongPress={() => { + if (!isIOS) { + router.push("/teams") + } + }} + /> + + ) +}) + +const TeamsText = () => { + return ( + { + router.push("/teams") + }} + renderPreview={() => ( + + + + )} + > + + I agree to the Terms of Service and Privacy Policy + + ) } diff --git a/apps/mobile/src/modules/login/social.tsx b/apps/mobile/src/modules/login/social.tsx index babe586af..d1dec8a73 100644 --- a/apps/mobile/src/modules/login/social.tsx +++ b/apps/mobile/src/modules/login/social.tsx @@ -1,7 +1,9 @@ import * as AppleAuthentication from "expo-apple-authentication" import { useColorScheme } from "nativewind" +import { useContext } from "react" import { Platform, TouchableOpacity, View } from "react-native" +import { LoginTeamsCheckGuardContext } from "@/src/contexts/LoginTeamsContext" import { AppleCuteFiIcon } from "@/src/icons/apple_cute_fi" import { GithubCuteFiIcon } from "@/src/icons/github_cute_fi" import { GoogleCuteFiIcon } from "@/src/icons/google_cute_fi" @@ -38,6 +40,7 @@ const provider: Record< export function SocialLogin() { const { data } = useAuthProviders() + const teamsCheckGuard = useContext(LoginTeamsCheckGuardContext) const { colorScheme } = useColorScheme() return ( @@ -50,40 +53,42 @@ export function SocialLogin() { { - if (!data?.[providerInfo.id]) return + onPress={() => + teamsCheckGuard?.(async () => { + if (!data?.[providerInfo.id]) return - if (providerInfo.id === "apple") { - try { - const credential = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], - }) - - if (credential.identityToken) { - await signIn.social({ - provider: "apple", - idToken: { - token: credential.identityToken, - }, + if (providerInfo.id === "apple") { + try { + const credential = await AppleAuthentication.signInAsync({ + requestedScopes: [ + AppleAuthentication.AppleAuthenticationScope.FULL_NAME, + AppleAuthentication.AppleAuthenticationScope.EMAIL, + ], }) - } else { - throw new Error("No identityToken.") - } - } catch (e) { - console.error(e) - // handle errors - } - return - } - signIn.social({ - provider: providerInfo.id as any, - callbackURL: "/", + if (credential.identityToken) { + await signIn.social({ + provider: "apple", + idToken: { + token: credential.identityToken, + }, + }) + } else { + throw new Error("No identityToken.") + } + } catch (e) { + console.error(e) + // handle errors + } + return + } + + signIn.social({ + provider: providerInfo.id as any, + callbackURL: "/", + }) }) - }} + } disabled={!data?.[providerInfo.id]} > { + return ( + + ) +} + +export default function Teams() { + return ( + + + + + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 050baabd3..2a5fb1618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -562,6 +562,9 @@ importers: react-native: specifier: 0.76.5 version: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1) + react-native-bouncy-checkbox: + specifier: 4.1.2 + version: 4.1.2 react-native-context-menu-view: specifier: 1.16.0 version: 1.16.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -3744,6 +3747,9 @@ packages: '@fontsource/sn-pro@5.1.0': resolution: {integrity: sha512-k7cdU1hftD/pyrnrmQg+egKAssbuPORbtgQM/9JG5b76EchEKZdl/juO8xBjN3O/H5BQ16iu8/9vNPgzyExZeg==} + '@freakycoder/react-native-bounceable@1.0.3': + resolution: {integrity: sha512-+iMq2tnqxCwFjitbPUz9nZ+VfJ8OU9waIlDJAAsoq1229QEwCmERCNy5zVtDsz75q3i4FLXX/n7fimdMzmP21A==} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -12688,6 +12694,9 @@ packages: react-merge-refs@1.1.0: resolution: {integrity: sha512-alTKsjEL0dKH/ru1Iyn7vliS2QRcBp9zZPGoWxUOvRGWPUYgjo+V01is7p04It6KhgrzhJGnIj9GgX8W4bZoCQ==} + react-native-bouncy-checkbox@4.1.2: + resolution: {integrity: sha512-hB7YwCGTNoMpTPOPiP+RWyQH35S6vxUbc7IGEW/Rqyp7GonEyhtqtthmxiphneRXnywMh8CZwND7OnvppJZscg==} + react-native-context-menu-view@1.16.0: resolution: {integrity: sha512-zqeOAizM7MVV9o6h/quS0REQikBq3J4BkIRLFygY6RiCjr6rwuzSGkif7JRCHpAQQumSKlLqYl4N2h3AdoIHVg==} peerDependencies: @@ -18229,6 +18238,8 @@ snapshots: '@fontsource/sn-pro@5.1.0': {} + '@freakycoder/react-native-bounceable@1.0.3': {} + '@gar/promisify@1.1.3': {} '@gorhom/portal@1.0.14(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': @@ -29273,6 +29284,10 @@ snapshots: react-merge-refs@1.1.0: {} + react-native-bouncy-checkbox@4.1.2: + dependencies: + '@freakycoder/react-native-bounceable': 1.0.3 + react-native-context-menu-view@1.16.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1