Skip to content

Commit

Permalink
feat: optimize login page
Browse files Browse the repository at this point in the history
  • Loading branch information
DIYgod committed Feb 26, 2025
1 parent ee01f9a commit e481486
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 163 deletions.
20 changes: 13 additions & 7 deletions apps/mobile/src/components/common/SubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { cn } from "@follow/utils"
import { useEffect } from "react"
import type { PressableProps } from "react-native"
import { ActivityIndicator, Text } from "react-native"
import {
interpolate,
import { ActivityIndicator } from "react-native"
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
Expand All @@ -20,29 +19,36 @@ export function SubmitButton({
title,
...props
}: PressableProps & { isLoading?: boolean; title: string }) {
const disableColor = useColor("gray3")
const disableColor = useColor("gray6")
const disabledTextColor = useColor("gray2")
const textColor = useColor("gray6")

const disabledValue = useSharedValue(1)
useEffect(() => {
disabledValue.value = withTiming(props.disabled ? 1 : 0)
}, [props.disabled])

Check warning on line 29 in apps/mobile/src/components/common/SubmitButton.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint and Typecheck (lts/*)

React Hook useEffect has a missing dependency: 'disabledValue'. Either include it or remove the dependency array

const buttonStyle = useAnimatedStyle(() => ({
opacity: interpolate(disabledValue.value, [0, 1], [1, 0.5]),
backgroundColor: interpolateColor(disabledValue.value, [1, 0], [disableColor, accentColor]),
}))

const textStyle = useAnimatedStyle(() => ({
color: interpolateColor(disabledValue.value, [1, 0], [disabledTextColor, textColor]),
}))

return (
<ReAnimatedPressable
{...props}
disabled={props.disabled || isLoading}
style={[buttonStyle, props.style]}
className={cn("h-10 flex-row items-center justify-center rounded-3xl", props.className)}
className={cn("h-[48] flex-row items-center justify-center rounded-2xl", props.className)}
>
{isLoading ? (
<ActivityIndicator className="text-white" />
) : (
<Text className="text-center font-semibold text-white">{title}</Text>
<Animated.Text className="text-center text-xl font-semibold" style={textStyle}>
{title}
</Animated.Text>
)}
</ReAnimatedPressable>
)
Expand Down
7 changes: 0 additions & 7 deletions apps/mobile/src/contexts/LoginTermsContext.tsx

This file was deleted.

15 changes: 5 additions & 10 deletions apps/mobile/src/modules/login/email.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import { useContext } from "react"
import type { Control } from "react-hook-form"
import { useController, useForm } from "react-hook-form"
import type { TextInputProps } from "react-native"
import { Text, TextInput, View } from "react-native"
import { TextInput, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
import { z } from "zod"

import { SubmitButton } from "@/src/components/common/SubmitButton"
import { LoginTermsCheckGuardContext } from "@/src/contexts/LoginTermsContext"
import { signIn } from "@/src/lib/auth"
import { toast } from "@/src/lib/toast"
import { accentColor } from "@/src/theme/colors"
Expand Down Expand Up @@ -77,16 +75,14 @@ export function EmailLogin() {
mutationFn: onSubmit,
})

const termsCheckGuard = useContext(LoginTermsCheckGuardContext)
const login = handleSubmit((values) => {
termsCheckGuard?.(() => submitMutation.mutate(values))
submitMutation.mutate(values)
})

return (
<View className="mx-auto flex w-full max-w-sm gap-6">
<View className="gap-4">
<View className="bg-system-grouped-background gap-4 rounded-2xl px-6 py-4">
<View className="flex-row">
<Text className="text-label w-28">Account</Text>
<Input
autoCapitalize="none"
autoCorrect={false}
Expand All @@ -102,16 +98,15 @@ export function EmailLogin() {
}}
/>
</View>
<View className="border-b-opaque-separator border-b-hairline ml-28" />
<View className="border-b-opaque-separator border-b-hairline" />
<View className="flex-row">
<Text className="text-label w-28">Password</Text>
<Input
autoCapitalize="none"
autoCorrect={false}
autoComplete="current-password"
control={control}
name="password"
placeholder="Enter password"
placeholder="Password"
className="text-text flex-1"
secureTextEntry
returnKeyType="go"
Expand Down
138 changes: 35 additions & 103 deletions apps/mobile/src/modules/login/index.tsx
Original file line number Diff line number Diff line change
@@ -1,138 +1,70 @@
import { Link, router } from "expo-router"
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"
import { useCallback } from "react"
import { Text, 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 Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"
import * as ContextMenu from "zeego/context-menu"

import { Logo } from "@/src/components/ui/logo"
import {
LoginTermsCheckedContext,
LoginTermsCheckGuardContext,
} from "@/src/contexts/LoginTermsContext"
import { isIOS } from "@/src/lib/platform"
import { toast } from "@/src/lib/toast"
import { TermsMarkdown } from "@/src/screens/(headless)/terms"

import { EmailLogin } from "./email"
import { SocialLogin } from "./social"

export function Login() {
const [isChecked, setIsChecked] = useState(false)

const termsCheckBoxRef = useRef<{ shake: () => void }>(null)

return (
<LoginTermsCheckedContext.Provider value={isChecked}>
<LoginTermsCheckGuardContext.Provider
value={useCallback(
(callback: () => void) => {
if (isChecked) {
callback()
} else {
toast.info("Please accept the Terms of Service and Privacy Policy")

termsCheckBoxRef.current?.shake()
}
},
[isChecked],
)}
<View className="flex h-full justify-center p-safe">
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="flex-1 p-safe">
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View className="flex-1 items-center gap-8 pt-20">
<Logo style={{ width: 80, height: 80 }} />
<Text className="text-label text-2xl font-bold">Login to Follow</Text>
<EmailLogin />
</View>
</TouchableWithoutFeedback>
<TermsCheckBox ref={termsCheckBoxRef} isChecked={isChecked} setIsChecked={setIsChecked} />
<View className="border-t-opaque-separator border-t-hairline mx-28" />
<View className="mt-2 items-center">
<View className="mb-4 flex w-full max-w-sm flex-row items-center gap-4">
<View className="bg-separator my-4 h-[0.5px] flex-1" />
<Text className="text-secondary-label text-lg">or</Text>
<View className="bg-separator my-4 h-[0.5px] flex-1" />
</View>
<SocialLogin />
</View>
<View className="mb-16 items-center gap-8 pt-20">
<Logo style={{ width: 80, height: 80 }} />
<Text className="text-label text-3xl">
Sign in to <Text className="font-bold">Follow</Text>
</Text>
<EmailLogin />
</View>
</TouchableWithoutFeedback>
<View className="border-t-opaque-separator border-t-hairline mx-28" />
<View className="mb-20 mt-2 items-center">
<View className="mb-4 flex w-full max-w-sm flex-row items-center gap-4">
<View className="bg-separator my-4 h-[0.5px] flex-1" />
<Text className="text-secondary-label text-lg">or</Text>
<View className="bg-separator my-4 h-[0.5px] flex-1" />
</View>
</LoginTermsCheckGuardContext.Provider>
</LoginTermsCheckedContext.Provider>
<SocialLogin />
</View>
<TermsCheckBox />
</View>
)
}

const TermsCheckBox = forwardRef<
{ shake: () => void },
{
isChecked: boolean
setIsChecked: (isChecked: boolean) => void
}
>(({ isChecked, setIsChecked }, ref) => {
const TermsCheckBox = () => {
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 (
<Animated.View className="mb-4 flex-row items-center gap-2 px-8" style={shakeStyle}>
<BouncyCheckbox
className="gap-2"
isChecked={isChecked}
onPress={setIsChecked}
size={14}
textComponent={<TermsText />}
onLongPress={() => {
if (!isIOS) {
router.push("/terms")
}
}}
/>
<Animated.View
className="mt-4 w-full flex-row items-center justify-center gap-2 px-8"
style={shakeStyle}
>
<TermsText />
</Animated.View>
)
})
}

const TermsText = () => {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="overflow-hidden rounded-full">
<ContextMenu.Trigger className="w-full overflow-hidden rounded-full">
<Text className="text-secondary-label text-sm">
I agree to the{" "}
<Link href="/terms" className="text-primary-label">
Terms of Service
</Link>{" "}
and Privacy Policy
</Link>
</Text>
</ContextMenu.Trigger>

Expand Down
61 changes: 28 additions & 33 deletions apps/mobile/src/modules/login/social.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import * as AppleAuthentication from "expo-apple-authentication"
import { useColorScheme } from "nativewind"
import { useContext } from "react"
import { Platform, TouchableOpacity, View } from "react-native"

import { LoginTermsCheckGuardContext as LoginTermsCheckGuardContext } from "@/src/contexts/LoginTermsContext"
import { AppleCuteFiIcon } from "@/src/icons/apple_cute_fi"
import { GithubCuteFiIcon } from "@/src/icons/github_cute_fi"
import { GoogleCuteFiIcon } from "@/src/icons/google_cute_fi"
Expand Down Expand Up @@ -40,7 +38,6 @@ const provider: Record<

export function SocialLogin() {
const { data } = useAuthProviders()
const termsCheckGuard = useContext(LoginTermsCheckGuardContext)
const { colorScheme } = useColorScheme()

return (
Expand All @@ -53,42 +50,40 @@ export function SocialLogin() {
<TouchableOpacity
key={key}
className="border-opaque-separator border-hairline rounded-full p-2"
onPress={() =>
termsCheckGuard?.(async () => {
if (!data?.[providerInfo.id]) return
onPress={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 (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,
},
})
} else {
throw new Error("No identityToken.")
}
} catch (e) {
console.error(e)
// handle errors
if (credential.identityToken) {
await signIn.social({
provider: "apple",
idToken: {
token: credential.identityToken,
},
})
} else {
throw new Error("No identityToken.")
}
return
} catch (e) {
console.error(e)
// handle errors
}
return
}

signIn.social({
provider: providerInfo.id as any,
callbackURL: "/",
})
signIn.social({
provider: providerInfo.id as any,
callbackURL: "/",
})
}
}}
disabled={!data?.[providerInfo.id]}
>
<providerInfo.icon
Expand Down
Loading

0 comments on commit e481486

Please sign in to comment.