diff --git a/expo/app/(tabs)/_layout.tsx b/expo/app/(tabs)/_layout.tsx index f87a6224..ca36be34 100644 --- a/expo/app/(tabs)/_layout.tsx +++ b/expo/app/(tabs)/_layout.tsx @@ -1,17 +1,19 @@ -import { Redirect, Tabs } from "expo-router"; +import { Redirect, Tabs, useRouter } from "expo-router"; import * as WebBrowser from "expo-web-browser"; import HouseLine from "phosphor-react-native/src/icons/HouseLine"; import MusicNote from "phosphor-react-native/src/icons/MusicNote"; import User from "phosphor-react-native/src/icons/User"; import Users from "phosphor-react-native/src/icons/Users"; +import { useEffect, useState } from "react"; import { HomeTabHeader } from "."; import ApplicationLoadingScreen from "../../components/ApplicationLoadingScreen"; import { Text } from "../../components/Tamed"; -import { View } from "../../components/Themed"; import FriendHeader from "../../components/headers/FriendHeader"; import Colors from "../../constants/Colors"; +import { supabase } from "../../lib/supabase"; import { useSupabaseUserHook } from "../../lib/useSupabaseUser"; +import { useUserFullProfile } from "../../lib/userProfile"; WebBrowser.maybeCompleteAuthSession(); @@ -38,6 +40,37 @@ export default function TabLayout() { WebBrowser.maybeCompleteAuthSession(); const user = useSupabaseUserHook(); + const router = useRouter(); + + const profile = useUserFullProfile(); + + const [usernameIsFine, setUsernameIsFine] = useState(true); + + useEffect(() => { + if (!profile) return; + setUsernameIsFine(!!profile?.username); + }, [profile]); + + /** + * If the user is logged in, we check if the user has a username + * If the user not have a username, we re-fetch the user profile to check if he not comming from the ask-name page + */ + useEffect(() => { + if (!user) return; + if (usernameIsFine) return; + + supabase + .from("user_profile") + .select("username") + .eq("account_id", user.id) + .single() + .then(({ data }) => { + if (data?.username) { + return setUsernameIsFine(true); + } + router.push("/ask-name/"); + }); + }, [usernameIsFine]); if (user === undefined) return ; diff --git a/expo/app/(tabs)/friends.tsx b/expo/app/(tabs)/friends.tsx index a06b57d1..752eb5cc 100644 --- a/expo/app/(tabs)/friends.tsx +++ b/expo/app/(tabs)/friends.tsx @@ -1,31 +1,7 @@ -import Hammer from "phosphor-react-native/src/icons/Hammer"; import React from "react"; -import { View } from "react-native"; -import { Text } from "../../components/Themed"; +import InDeveloppement from "../../components/InDeveloppement"; export default function TabsFriends() { - return ( - - - - Cette page est en cours de développement. Merci de revenir plus tard. - - - ); + return ; } diff --git a/expo/app/(tabs)/profile/account/_layout.tsx b/expo/app/(tabs)/profile/account/_layout.tsx index cd529f97..1f05b836 100644 --- a/expo/app/(tabs)/profile/account/_layout.tsx +++ b/expo/app/(tabs)/profile/account/_layout.tsx @@ -2,12 +2,19 @@ import { Stack } from "expo-router"; import ErrorBoundary from "../../../../components/ErrorBoundary"; import ProfileErrorBoundary from "../../../../components/ErrorComponent/ProfileError"; +import { AccountHeader } from "../../../../components/headers/AccountHeader"; export default function AccountLayout() { return ( }> - + , + }} + /> + + + ); diff --git a/expo/app/(tabs)/profile/account/edit.tsx b/expo/app/(tabs)/profile/account/edit.tsx index 0b428d7d..70b4ef44 100644 --- a/expo/app/(tabs)/profile/account/edit.tsx +++ b/expo/app/(tabs)/profile/account/edit.tsx @@ -9,18 +9,23 @@ import ErrorBoundary from "../../../../components/ErrorBoundary"; import { View } from "../../../../components/Themed"; import Warning from "../../../../components/Warning"; import AvatarForm from "../../../../components/profile/AvatarForm"; +import { + displayNameRules, + emailRules, + usernameRules, +} from "../../../../constants/InputRules"; import { AuthErrorMessage, SupabaseErrorCode, } from "../../../../constants/SupabaseErrorCode"; -import { emailRules, usernameRules } from "../../../../lib/inputRestriction"; import { supabase } from "../../../../lib/supabase"; import { useSupabaseUserHook } from "../../../../lib/useSupabaseUser"; -import { getUserProfile } from "../../../../lib/userProfile"; +import { useUserFullProfile } from "../../../../lib/userProfile"; type EditForm = { email: string; username: string; + displayName: string; }; export default function PersonalInfo() { @@ -34,12 +39,17 @@ export default function PersonalInfo() { const scrollViewRef = useRef(null); const [initialUsername, setInitialUsername] = useState(null); + const [initialDisplayName, setInitialDisplayname] = useState( + null + ); const [profilePictureChanged, setProfilePictureChanged] = useState(false); const [submittable, setSubmittable] = useState(false); const [successMessage, setSuccessMessage] = useState(null); const [emailDisabled, setEmailDisabled] = useState(false); + const profile = useUserFullProfile(); + const { control, handleSubmit, @@ -51,28 +61,43 @@ export default function PersonalInfo() { defaultValues: { email: "", username: "", + displayName: "", }, shouldFocusError: true, }); const inputsChange = watch(); + useEffect(() => { if (user && user.email) { if (user.app_metadata.provider !== "email") setEmailDisabled(true); setValue("email", user.email); - getUserProfile(user.id).then((profile) => { - if (profile && profile.username) { - setValue("username", profile.username); - setInitialUsername(profile.username); - } - }); } }, [user]); + useEffect(() => { + if (!profile) return; + + if (!profile.profile || !profile.username) { + return setError("root", { + message: "Impossible de récupérer vos données", + }); + } + const displayName: string = profile.profile.nickname; + setValue("displayName", displayName); + setInitialDisplayname(displayName); + + const username: string = profile.username; + setValue("username", username); + setInitialUsername(username); + }, [profile]); + useEffect(() => { if (inputsChange.email !== user?.email) return setSubmittable(true); if (inputsChange.username !== initialUsername) return setSubmittable(true); if (profilePictureChanged) return setSubmittable(true); + if (inputsChange.displayName !== initialDisplayName) + return setSubmittable(true); setSubmittable(false); }, [inputsChange]); @@ -130,7 +155,40 @@ export default function PersonalInfo() { return "Pseudo mis à jour"; }; - const onSubmit = async ({ email, username }: EditForm) => { + /** + * Update display name + * @param displayName + * @returns string if success, undefined if error + */ + const updateDisplayName = async ( + displayName: string + ): Promise => { + if (!profile || !profile.profile?.id) return; + const { error } = await supabase + .from("profile") + .update({ + nickname: inputsChange.displayName, + }) + .eq("id", profile.profile?.id); + + if (error) { + setError("displayName", { + message: "Impossible de mettre à jour le nom d'affichage", + }); + return; + } + setInitialDisplayname(displayName); + setValue("displayName", displayName); + + return "Nom public"; + }; + + /** + * Possible to upgrade with parallel requests + * @param inputs + * @returns + */ + const onSubmit = async ({ email, username, displayName }: EditForm) => { const validationResum: string[] = []; if (inputsChange.email !== user?.email) { @@ -143,6 +201,11 @@ export default function PersonalInfo() { if (res) validationResum.push(res); } + if (inputsChange.displayName !== initialDisplayName) { + const res = await updateDisplayName(displayName); + if (res) validationResum.push(res); + } + if (profilePictureChanged) { if (!avatarRef.current) return; setProfilePictureChanged(false); @@ -193,6 +256,15 @@ export default function PersonalInfo() { placeholder="@nouveau_nom" rules={usernameRules} errorMessage={errors.username && errors.username.message} + info="Le nom d'utilisateur doit être unique" + /> + Alert.alert("Not implemented")} + disabled > Supprimer mon compte diff --git a/expo/app/(tabs)/profile/account/help.tsx b/expo/app/(tabs)/profile/account/help.tsx new file mode 100644 index 00000000..a8aef717 --- /dev/null +++ b/expo/app/(tabs)/profile/account/help.tsx @@ -0,0 +1,5 @@ +import InDeveloppement from "../../../../components/InDeveloppement"; + +export default function Help() { + return ; +} diff --git a/expo/app/(tabs)/profile/account/index.tsx b/expo/app/(tabs)/profile/account/index.tsx index e84efdb2..79329cb2 100644 --- a/expo/app/(tabs)/profile/account/index.tsx +++ b/expo/app/(tabs)/profile/account/index.tsx @@ -22,13 +22,13 @@ export default function Account() { } title="Sécurité" - href="/account/security" + href="/(tabs)/profile/account/security" /> } title="Notifications" - href="/manage-account/notifications" + href="/(tabs)/profile/account/notifications" /> } title="Assistance" - href="/manage-account/help" + href="/(tabs)/profile/account/help" /> + ); } diff --git a/expo/app/(tabs)/profile/account/notifications.tsx b/expo/app/(tabs)/profile/account/notifications.tsx new file mode 100644 index 00000000..ad580350 --- /dev/null +++ b/expo/app/(tabs)/profile/account/notifications.tsx @@ -0,0 +1,5 @@ +import InDeveloppement from "../../../../components/InDeveloppement"; + +export default function Notifications() { + return ; +} diff --git a/expo/app/(tabs)/profile/account/security.tsx b/expo/app/(tabs)/profile/account/security.tsx new file mode 100644 index 00000000..c786afa5 --- /dev/null +++ b/expo/app/(tabs)/profile/account/security.tsx @@ -0,0 +1,5 @@ +import InDeveloppement from "../../../../components/InDeveloppement"; + +export default function Security() { + return ; +} diff --git a/expo/app/(tabs)/rooms/[id]/_layout.tsx b/expo/app/(tabs)/rooms/[id]/_layout.tsx index 849904d9..03949da5 100644 --- a/expo/app/(tabs)/rooms/[id]/_layout.tsx +++ b/expo/app/(tabs)/rooms/[id]/_layout.tsx @@ -1,4 +1,4 @@ -import { Stack, useLocalSearchParams } from "expo-router"; +import { Stack, router, useLocalSearchParams } from "expo-router"; import { ReactNode, createContext, @@ -8,6 +8,7 @@ import { } from "react"; import { Socket } from "socket.io-client"; +import Alert from "../../../../components/Alert"; import ErrorBoundary from "../../../../components/ErrorBoundary"; import RoomErrorBoundary from "../../../../components/ErrorComponent/RoomError"; import WebsocketError from "../../../../components/ErrorComponent/WebsocketError"; @@ -54,6 +55,13 @@ const WebSocketProvider = ({ setSocketError(null); }); + socketInstance.on("room:end", () => { + Alert.alert( + "Fermeture de la salle, redirection vers la liste des salles" + ); + router.push("/rooms"); + }); + return () => { socketInstance.disconnect(); }; diff --git a/expo/app/(tabs)/rooms/_layout.tsx b/expo/app/(tabs)/rooms/_layout.tsx index d45c1b9b..78f5ff00 100644 --- a/expo/app/(tabs)/rooms/_layout.tsx +++ b/expo/app/(tabs)/rooms/_layout.tsx @@ -1,9 +1,11 @@ import { Stack } from "expo-router"; +import RoomsHeader from "../../../components/headers/RoomsHeader"; + export default function RoomsTabLayout() { return ( - + }} /> -

Salles d'écoute

- - - - ); @@ -29,7 +18,4 @@ const styles = StyleSheet.create({ marginVertical: 21, gap: 36, }, - buttonContainer: { - gap: 8, - }, }); diff --git a/expo/app/_layout.tsx b/expo/app/_layout.tsx index 4737980f..0dd42562 100644 --- a/expo/app/_layout.tsx +++ b/expo/app/_layout.tsx @@ -7,6 +7,7 @@ import { MenuProvider } from "react-native-popup-menu"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { Text, View } from "../components/Themed"; +import { AccountHeader } from "../components/headers/AccountHeader"; import Colors from "../constants/Colors"; import { supabase } from "../lib/supabase"; @@ -97,7 +98,7 @@ function RootLayoutNav() { }} />
diff --git a/expo/app/ask-name.tsx b/expo/app/ask-name.tsx index 465a9bc4..e55960b1 100644 --- a/expo/app/ask-name.tsx +++ b/expo/app/ask-name.tsx @@ -1,91 +1,97 @@ -import { router } from "expo-router"; -import { useState } from "react"; -import { StyleSheet, Text, TextInput, View } from "react-native"; +import { useRouter } from "expo-router"; +import { useForm } from "react-hook-form"; import { Screen } from "react-native-screens"; -import Alert from "../components/Alert"; import Button from "../components/Button"; +import ControlledInput from "../components/ControlledInput"; +import { Text } from "../components/Themed"; +import Font from "../constants/Font"; +import { usernameRules } from "../constants/InputRules"; import { SupabaseErrorCode } from "../constants/SupabaseErrorCode"; import { supabase } from "../lib/supabase"; -import useSupabaseUser from "../lib/useSupabaseUser"; +import { useSupabaseUserHook } from "../lib/useSupabaseUser"; + +type UsernameForm = { + username: string; +}; export default function AskName() { - const [username, setUsername] = useState(""); + const user = useSupabaseUserHook(); + const router = useRouter(); - const handleSubmitUsername = async () => { - const user = await useSupabaseUser(); - if (!user?.id) return; + const { + control, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + defaultValues: { + username: "", + }, + shouldFocusError: true, + }); - if (username.length < 5) { - Alert.alert("Le pseudo doit faire au moins 5 caractères"); - return; - } + /** + * Possible to upgrade with parallel requests + * @param inputs + * @returns + */ + const onSubmit = async ({ username }: UsernameForm) => { + console.log("submitting", username); + console.log("profile", user); + if (!user) return; + + console.log("submitting", username); const { error } = await supabase .from("user_profile") .update({ username }) - .eq("account_id", user?.id); - if (error) { - if (error.code === SupabaseErrorCode.CONSTRAINT_VIOLATION) { - Alert.alert("Attention, Ce pseudo est déjà pris"); - return; - } - Alert.alert("Erreur, Une erreur est survenue"); + .eq("account_id", user.id); + + if (!error) { + router.push("/"); + } + if (error?.code === SupabaseErrorCode.CONSTRAINT_VIOLATION) { + setError("username", { + type: "manual", + message: "Ce nom d'utilisateur est déjà pris", + }); } - router.replace("/(tabs)"); }; return ( - - - Choisi ton pseudo - - - - - + + + Ici, tu peux choisir un nom d'utilisateur pour que tes amis puissent te + trouver plus facilement. Ce nom est unique sur DATSMYSONG ! + + + ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - // justifyContent: "center", - alignItems: "center", - gap: 20, - width: "100%", - }, - form: { - padding: 20, - alignItems: "center", - gap: 30, - }, - buttonContainer: { - alignItems: "center", - }, - title: { - fontSize: 30, - fontWeight: "bold", - textAlign: "center", - }, - titleContainer: { - marginTop: 100, - marginBottom: 100, - }, - containerWithDivider: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - }, -}); diff --git a/expo/app/auth/login.tsx b/expo/app/auth/login.tsx index 40cab5b2..613d166f 100644 --- a/expo/app/auth/login.tsx +++ b/expo/app/auth/login.tsx @@ -6,8 +6,8 @@ import { StyleSheet, View } from "react-native"; import Button from "../../components/Button"; import ControlledInput from "../../components/ControlledInput"; import Alert from "../../components/Warning"; +import { emailRules } from "../../constants/InputRules"; import { AuthErrorMessage } from "../../constants/SupabaseErrorCode"; -import { emailRules } from "../../lib/inputRestriction"; import { supabase } from "../../lib/supabase"; type LoginForm = { diff --git a/expo/app/auth/register.tsx b/expo/app/auth/register.tsx index 395f6070..e340993e 100644 --- a/expo/app/auth/register.tsx +++ b/expo/app/auth/register.tsx @@ -5,12 +5,12 @@ import { ScrollView, StyleSheet, Text, View } from "react-native"; import Alert from "../../components/Alert"; import Button from "../../components/Button"; import ControlledInput from "../../components/ControlledInput"; -import { getApiUrl } from "../../lib/apiUrl"; import { emailRules, passwordRules, usernameRules, -} from "../../lib/inputRestriction"; +} from "../../constants/InputRules"; +import { getApiUrl } from "../../lib/apiUrl"; type FormData = { username: string; diff --git a/expo/components/ControlledInput.tsx b/expo/components/ControlledInput.tsx index 96ab5d36..bf701a24 100644 --- a/expo/components/ControlledInput.tsx +++ b/expo/components/ControlledInput.tsx @@ -3,6 +3,7 @@ import { StyleSheet, Text, TextInputProps, View } from "react-native"; import CustomPasswordInput from "./CustomPasswordInput"; import CustomTextInput, { CustomTextInputProps } from "./CustomTextInput"; +import Subtitle from "./text/Subtitle"; interface ControlledInputProps extends CustomTextInputProps { control: any; @@ -14,6 +15,7 @@ interface ControlledInputProps extends CustomTextInputProps { secureTextEntry?: boolean; errorMessage?: string | undefined; onSubmitEditing?: () => void; + info?: string; } export default function ControlledInput({ @@ -23,6 +25,7 @@ export default function ControlledInput({ rules, secureTextEntry, errorMessage, + info, ...props }: ControlledInputProps) { return ( @@ -57,6 +60,7 @@ export default function ControlledInput({ } name={name} /> + {info && {info}} {errorMessage && ( {errorMessage ?? "Le champ est invalide"} diff --git a/expo/components/InDeveloppement.tsx b/expo/components/InDeveloppement.tsx new file mode 100644 index 00000000..9a076034 --- /dev/null +++ b/expo/components/InDeveloppement.tsx @@ -0,0 +1,31 @@ +import Hammer from "phosphor-react-native/src/icons/Hammer"; +import React from "react"; +import { View } from "react-native"; + +import { Text } from "./Themed"; + +export default function InDeveloppement() { + return ( + + + + Cette page est en cours de développement. Merci de revenir plus tard. + + + ); +} diff --git a/expo/components/RoomHistory.tsx b/expo/components/RoomHistory.tsx index edbbd9c0..f5885f18 100644 --- a/expo/components/RoomHistory.tsx +++ b/expo/components/RoomHistory.tsx @@ -107,7 +107,7 @@ const RoomHistory: React.FC = ({ roomId }) => { {participant.profile.nickname} diff --git a/expo/components/UserRoomHistory.tsx b/expo/components/UserRoomHistory.tsx index 96ea6df1..f06a62d2 100644 --- a/expo/components/UserRoomHistory.tsx +++ b/expo/components/UserRoomHistory.tsx @@ -1,6 +1,6 @@ import { QueryData } from "@supabase/supabase-js/dist/module/lib/types"; import { useEffect, useState } from "react"; -import { FlatList, StyleSheet } from "react-native"; +import { FlatList } from "react-native"; import RoomHistoryInfoCard from "./RoomHistoryInfoCard"; import { View } from "./Themed"; diff --git a/expo/components/headers/AccountHeader.tsx b/expo/components/headers/AccountHeader.tsx new file mode 100644 index 00000000..6d2188a6 --- /dev/null +++ b/expo/components/headers/AccountHeader.tsx @@ -0,0 +1,29 @@ +import { View } from "react-native"; + +import H1 from "../text/H1"; + +export const AccountHeader = () => { + return ( + + +

Gérer mon compte

+
+
+ ); +}; diff --git a/expo/components/headers/RoomsHeader.tsx b/expo/components/headers/RoomsHeader.tsx new file mode 100644 index 00000000..9259283f --- /dev/null +++ b/expo/components/headers/RoomsHeader.tsx @@ -0,0 +1,33 @@ +import { StyleSheet, View } from "react-native"; + +import Button from "../Button"; +import H1 from "../text/H1"; + +export default function RoomsHeader() { + return ( + +

Salles d'écoute

+ + + + +
+ ); +} + +const styles = StyleSheet.create({ + headerContainer: { + flex: 1, + paddingHorizontal: 24, + paddingVertical: 21, + gap: 36, + backgroundColor: "#E6E6E6", + }, + buttonContainer: { + gap: 8, + }, +}); diff --git a/expo/components/profile/AvatarForm.tsx b/expo/components/profile/AvatarForm.tsx index ffaa74dc..3f80fc2d 100644 --- a/expo/components/profile/AvatarForm.tsx +++ b/expo/components/profile/AvatarForm.tsx @@ -88,12 +88,21 @@ const AvatarForm = forwardRef((props: AvatarProps, ref) => { return ( Photo de profil - +
@@ -71,13 +50,13 @@ export const ProfileHeader = () => { }} > - + - {userProfile ? userProfile.username : "chargement"} + {profile?.profile ? profile.profile?.nickname : "chargement"} - @{profile?.nickname} + @{profile?.username} diff --git a/expo/components/profile/SettingsOptions.tsx b/expo/components/profile/SettingsOptions.tsx index 0f92ede4..29b46dae 100644 --- a/expo/components/profile/SettingsOptions.tsx +++ b/expo/components/profile/SettingsOptions.tsx @@ -46,7 +46,6 @@ export default SettingsOptions; const styles = StyleSheet.create({ container: { - backgroundColor: "white", gap: 24, paddingVertical: 16, paddingHorizontal: 20, diff --git a/expo/lib/inputRestriction.ts b/expo/constants/InputRules.ts similarity index 67% rename from expo/lib/inputRestriction.ts rename to expo/constants/InputRules.ts index 1f0805bb..74704a05 100644 --- a/expo/lib/inputRestriction.ts +++ b/expo/constants/InputRules.ts @@ -3,6 +3,7 @@ import { RegisterOptions } from "react-hook-form"; /** * This file contains all the rules for the inputs with library react-hook-form */ + const usernameRules: RegisterOptions = { required: "Un nom d'utilisateur est requis", minLength: { @@ -13,6 +14,12 @@ const usernameRules: RegisterOptions = { value: 15, message: "Le nom d'utilisateur doit contenir au plus 15 caractères", }, + // pas d'espace ni de caractères spéciaux + pattern: { + value: /^[a-zA-Z0-9]*$/, + message: + "Le nom d'utilisateur ne doit contenir que des lettres et des chiffres", + }, }; const emailRules: RegisterOptions = { @@ -39,4 +46,16 @@ const passwordRules: RegisterOptions = { }, }; -export { emailRules, passwordRules, usernameRules }; +const displayNameRules: RegisterOptions = { + required: "Veuillez saisir votre nom d'affichage", + minLength: { + value: 3, + message: "Le nom d'affichage doit contenir au moins 3 caractères", + }, + maxLength: { + value: 15, + message: "Le nom d'affichage doit contenir au plus 15 caractères", + }, +}; + +export { emailRules, passwordRules, usernameRules, displayNameRules }; diff --git a/expo/lib/userProfile.ts b/expo/lib/userProfile.ts index e16771e1..1b38f88b 100644 --- a/expo/lib/userProfile.ts +++ b/expo/lib/userProfile.ts @@ -52,6 +52,40 @@ export function useUserProfile() { return profile; } +const fullProfileRequest = supabase + .from("user_profile") + .select("*, profile(*)") + .single(); + +type FullProfile = QueryData; + +export function useUserFullProfile() { + const [profile, setProfile] = useState(); + const user = useSupabaseUserHook(); + + useEffect(() => { + const fetchProfile = async () => { + if (!user) return setProfile(null); + + const { data, error } = await supabase + .from("user_profile") + .select("*, profile(*)") + .eq("account_id", user.id) + .single(); + + if (error) { + return setProfile(null); + } + + setProfile(data); + }; + + fetchProfile(); + }, [user]); + + return profile; +} + export const getUsernameFromUser = async ( user: User ): Promise<{