mirror of
				https://github.com/hydralauncher/hydra.git
				synced 2025-03-09 15:40:26 +00:00 
			
		
		
		
	feat: set profile visibility
This commit is contained in:
		
							parent
							
								
									42a78802a6
								
							
						
					
					
						commit
						6806787ca0
					
				
					 15 changed files with 276 additions and 194 deletions
				
			
		
							
								
								
									
										5
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								src/renderer/src/declaration.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -139,10 +139,7 @@ declare global { | |||
|     /* Profile */ | ||||
|     getMe: () => Promise<UserProfile | null>; | ||||
|     undoFriendship: (userId: string) => Promise<void>; | ||||
|     updateProfile: ( | ||||
|       displayName: string, | ||||
|       newProfileImagePath: string | null | ||||
|     ) => Promise<UserProfile>; | ||||
|     updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>; | ||||
|     getFriendRequests: () => Promise<FriendRequest[]>; | ||||
|     updateFriendRequest: ( | ||||
|       userId: string, | ||||
|  |  | |||
|  | @ -8,8 +8,9 @@ import { | |||
|   setFriendsModalHidden, | ||||
| } from "@renderer/features"; | ||||
| import { profileBackgroundFromProfileImage } from "@renderer/helpers"; | ||||
| import { FriendRequestAction, UserDetails } from "@types"; | ||||
| import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types"; | ||||
| import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; | ||||
| import { logger } from "@renderer/logger"; | ||||
| 
 | ||||
| export function useUserDetails() { | ||||
|   const dispatch = useAppDispatch(); | ||||
|  | @ -43,7 +44,10 @@ export function useUserDetails() { | |||
|       if (userDetails.profileImageUrl) { | ||||
|         const profileBackground = await profileBackgroundFromProfileImage( | ||||
|           userDetails.profileImageUrl | ||||
|         ); | ||||
|         ).catch((err) => { | ||||
|           logger.error("profileBackgroundFromProfileImage", err); | ||||
|           return `#151515B3`; | ||||
|         }); | ||||
|         dispatch(setProfileBackground(profileBackground)); | ||||
| 
 | ||||
|         window.localStorage.setItem( | ||||
|  | @ -74,12 +78,8 @@ export function useUserDetails() { | |||
|   }, [clearUserDetails]); | ||||
| 
 | ||||
|   const patchUser = useCallback( | ||||
|     async (displayName: string, imageProfileUrl: string | null) => { | ||||
|       const response = await window.electron.updateProfile( | ||||
|         displayName, | ||||
|         imageProfileUrl | ||||
|       ); | ||||
| 
 | ||||
|     async (props: UpdateProfileProps) => { | ||||
|       const response = await window.electron.updateProfile(props); | ||||
|       return updateUserDetails(response); | ||||
|     }, | ||||
|     [updateUserDetails] | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ import { | |||
|   XCircleIcon, | ||||
| } from "@primer/octicons-react"; | ||||
| import { Button, Link } from "@renderer/components"; | ||||
| import { UserEditProfileModal } from "./user-edit-modal"; | ||||
| import { UserProfileSettingsModal } from "./user-profile-settings-modal"; | ||||
| import { UserSignOutModal } from "./user-sign-out-modal"; | ||||
| import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; | ||||
| import { UserBlockModal } from "./user-block-modal"; | ||||
|  | @ -60,7 +60,8 @@ export function UserContent({ | |||
| 
 | ||||
|   const [profileContentBoxBackground, setProfileContentBoxBackground] = | ||||
|     useState<string | undefined>(); | ||||
|   const [showEditProfileModal, setShowEditProfileModal] = useState(false); | ||||
|   const [showProfileSettingsModal, setShowProfileSettingsModal] = | ||||
|     useState(false); | ||||
|   const [showSignOutModal, setShowSignOutModal] = useState(false); | ||||
|   const [showUserBlockModal, setShowUserBlockModal] = useState(false); | ||||
| 
 | ||||
|  | @ -95,7 +96,7 @@ export function UserContent({ | |||
|   }; | ||||
| 
 | ||||
|   const handleEditProfile = () => { | ||||
|     setShowEditProfileModal(true); | ||||
|     setShowProfileSettingsModal(true); | ||||
|   }; | ||||
| 
 | ||||
|   const handleOnClickFriend = (userId: string) => { | ||||
|  | @ -165,7 +166,7 @@ export function UserContent({ | |||
|       return ( | ||||
|         <> | ||||
|           <Button theme="outline" onClick={handleEditProfile}> | ||||
|             {t("edit_profile")} | ||||
|             {t("settings")} | ||||
|           </Button> | ||||
| 
 | ||||
|           <Button theme="danger" onClick={() => setShowSignOutModal(true)}> | ||||
|  | @ -251,9 +252,9 @@ export function UserContent({ | |||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <UserEditProfileModal | ||||
|         visible={showEditProfileModal} | ||||
|         onClose={() => setShowEditProfileModal(false)} | ||||
|       <UserProfileSettingsModal | ||||
|         visible={showProfileSettingsModal} | ||||
|         onClose={() => setShowProfileSettingsModal(false)} | ||||
|         updateUserProfile={updateUserProfile} | ||||
|         userProfile={userProfile} | ||||
|       /> | ||||
|  |  | |||
|  | @ -1,147 +0,0 @@ | |||
| import { Button, Modal, TextField } from "@renderer/components"; | ||||
| import { UserProfile } from "@types"; | ||||
| import * as styles from "./user.css"; | ||||
| import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; | ||||
| import { SPACING_UNIT } from "@renderer/theme.css"; | ||||
| import { useEffect, useMemo, useState } from "react"; | ||||
| import { useToast, useUserDetails } from "@renderer/hooks"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| 
 | ||||
| export interface UserEditProfileModalProps { | ||||
|   userProfile: UserProfile; | ||||
|   visible: boolean; | ||||
|   onClose: () => void; | ||||
|   updateUserProfile: () => Promise<void>; | ||||
| } | ||||
| 
 | ||||
| export const UserEditProfileModal = ({ | ||||
|   userProfile, | ||||
|   visible, | ||||
|   onClose, | ||||
|   updateUserProfile, | ||||
| }: UserEditProfileModalProps) => { | ||||
|   const { t } = useTranslation("user_profile"); | ||||
| 
 | ||||
|   const [displayName, setDisplayName] = useState(""); | ||||
|   const [newImagePath, setNewImagePath] = useState<string | null>(null); | ||||
|   const [isSaving, setIsSaving] = useState(false); | ||||
| 
 | ||||
|   const { patchUser } = useUserDetails(); | ||||
| 
 | ||||
|   const { showSuccessToast, showErrorToast } = useToast(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setDisplayName(userProfile.displayName); | ||||
|   }, [userProfile.displayName]); | ||||
| 
 | ||||
|   const handleChangeProfileAvatar = async () => { | ||||
|     const { filePaths } = await window.electron.showOpenDialog({ | ||||
|       properties: ["openFile"], | ||||
|       filters: [ | ||||
|         { | ||||
|           name: "Image", | ||||
|           extensions: ["jpg", "jpeg", "png", "webp"], | ||||
|         }, | ||||
|       ], | ||||
|     }); | ||||
| 
 | ||||
|     if (filePaths && filePaths.length > 0) { | ||||
|       const path = filePaths[0]; | ||||
| 
 | ||||
|       setNewImagePath(path); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async ( | ||||
|     event | ||||
|   ) => { | ||||
|     event.preventDefault(); | ||||
|     setIsSaving(true); | ||||
| 
 | ||||
|     patchUser(displayName, newImagePath) | ||||
|       .then(async () => { | ||||
|         await updateUserProfile(); | ||||
|         showSuccessToast(t("saved_successfully")); | ||||
|         cleanFormAndClose(); | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         showErrorToast(t("try_again")); | ||||
|       }) | ||||
|       .finally(() => { | ||||
|         setIsSaving(false); | ||||
|       }); | ||||
|   }; | ||||
| 
 | ||||
|   const resetModal = () => { | ||||
|     setDisplayName(userProfile.displayName); | ||||
|     setNewImagePath(null); | ||||
|   }; | ||||
| 
 | ||||
|   const cleanFormAndClose = () => { | ||||
|     resetModal(); | ||||
|     onClose(); | ||||
|   }; | ||||
| 
 | ||||
|   const avatarUrl = useMemo(() => { | ||||
|     if (newImagePath) return `local:${newImagePath}`; | ||||
|     if (userProfile.profileImageUrl) return userProfile.profileImageUrl; | ||||
|     return null; | ||||
|   }, [newImagePath, userProfile.profileImageUrl]); | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Modal | ||||
|         visible={visible} | ||||
|         title={t("edit_profile")} | ||||
|         onClose={cleanFormAndClose} | ||||
|       > | ||||
|         <form | ||||
|           onSubmit={handleSaveProfile} | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             justifyContent: "center", | ||||
|             alignItems: "center", | ||||
|             gap: `${SPACING_UNIT * 3}px`, | ||||
|             width: "350px", | ||||
|           }} | ||||
|         > | ||||
|           <button | ||||
|             type="button" | ||||
|             className={styles.profileAvatarEditContainer} | ||||
|             onClick={handleChangeProfileAvatar} | ||||
|           > | ||||
|             {avatarUrl ? ( | ||||
|               <img | ||||
|                 className={styles.profileAvatar} | ||||
|                 alt={userProfile.displayName} | ||||
|                 src={avatarUrl} | ||||
|               /> | ||||
|             ) : ( | ||||
|               <PersonIcon size={96} /> | ||||
|             )} | ||||
|             <div className={styles.editProfileImageBadge}> | ||||
|               <DeviceCameraIcon size={16} /> | ||||
|             </div> | ||||
|           </button> | ||||
| 
 | ||||
|           <TextField | ||||
|             label={t("display_name")} | ||||
|             value={displayName} | ||||
|             required | ||||
|             minLength={3} | ||||
|             containerProps={{ style: { width: "100%" } }} | ||||
|             onChange={(e) => setDisplayName(e.target.value)} | ||||
|           /> | ||||
|           <Button | ||||
|             disabled={isSaving} | ||||
|             style={{ alignSelf: "end" }} | ||||
|             type="submit" | ||||
|           > | ||||
|             {isSaving ? t("saving") : t("save")} | ||||
|           </Button> | ||||
|         </form> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1 @@ | |||
| export * from "./user-profile-settings-modal"; | ||||
|  | @ -0,0 +1,150 @@ | |||
| import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react"; | ||||
| import { Button, SelectField, TextField } from "@renderer/components"; | ||||
| import { useToast, useUserDetails } from "@renderer/hooks"; | ||||
| import { UserProfile } from "@types"; | ||||
| import { useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import * as styles from "../user.css"; | ||||
| import { SPACING_UNIT } from "@renderer/theme.css"; | ||||
| 
 | ||||
| export interface UserEditProfileProps { | ||||
|   userProfile: UserProfile; | ||||
|   updateUserProfile: () => Promise<void>; | ||||
| } | ||||
| 
 | ||||
| export const UserEditProfile = ({ | ||||
|   userProfile, | ||||
|   updateUserProfile, | ||||
| }: UserEditProfileProps) => { | ||||
|   const { t } = useTranslation("user_profile"); | ||||
| 
 | ||||
|   const [form, setForm] = useState({ | ||||
|     displayName: userProfile.displayName, | ||||
|     profileVisibility: userProfile.profileVisibility, | ||||
|     imageProfileUrl: null as string | null, | ||||
|   }); | ||||
|   const [isSaving, setIsSaving] = useState(false); | ||||
| 
 | ||||
|   const { patchUser } = useUserDetails(); | ||||
| 
 | ||||
|   const { showSuccessToast, showErrorToast } = useToast(); | ||||
| 
 | ||||
|   const [profileVisibilityOptions, setProfileVisibilityOptions] = useState< | ||||
|     { value: string; label: string }[] | ||||
|   >([]); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     setProfileVisibilityOptions([ | ||||
|       { value: "PUBLIC", label: t("public") }, | ||||
|       { value: "FRIENDS", label: t("friends_only") }, | ||||
|       { value: "PRIVATE", label: t("private") }, | ||||
|     ]); | ||||
|   }, [t]); | ||||
| 
 | ||||
|   const handleChangeProfileAvatar = async () => { | ||||
|     const { filePaths } = await window.electron.showOpenDialog({ | ||||
|       properties: ["openFile"], | ||||
|       filters: [ | ||||
|         { | ||||
|           name: "Image", | ||||
|           extensions: ["jpg", "jpeg", "png", "webp"], | ||||
|         }, | ||||
|       ], | ||||
|     }); | ||||
| 
 | ||||
|     if (filePaths && filePaths.length > 0) { | ||||
|       const path = filePaths[0]; | ||||
| 
 | ||||
|       setForm({ ...form, imageProfileUrl: path }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleProfileVisibilityChange = (event) => { | ||||
|     setForm({ | ||||
|       ...form, | ||||
|       profileVisibility: event.target.value, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async ( | ||||
|     event | ||||
|   ) => { | ||||
|     event.preventDefault(); | ||||
|     setIsSaving(true); | ||||
| 
 | ||||
|     patchUser(form) | ||||
|       .then(async () => { | ||||
|         await updateUserProfile(); | ||||
|         showSuccessToast(t("saved_successfully")); | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         showErrorToast(t("try_again")); | ||||
|       }) | ||||
|       .finally(() => { | ||||
|         setIsSaving(false); | ||||
|       }); | ||||
|   }; | ||||
| 
 | ||||
|   const avatarUrl = useMemo(() => { | ||||
|     if (form.imageProfileUrl) return `local:${form.imageProfileUrl}`; | ||||
|     if (userProfile.profileImageUrl) return userProfile.profileImageUrl; | ||||
|     return null; | ||||
|   }, [form, userProfile]); | ||||
| 
 | ||||
|   return ( | ||||
|     <form | ||||
|       onSubmit={handleSaveProfile} | ||||
|       style={{ | ||||
|         display: "flex", | ||||
|         flexDirection: "column", | ||||
|         justifyContent: "center", | ||||
|         alignItems: "center", | ||||
|         gap: `${SPACING_UNIT * 3}px`, | ||||
|         width: "350px", | ||||
|       }} | ||||
|     > | ||||
|       <button | ||||
|         type="button" | ||||
|         className={styles.profileAvatarEditContainer} | ||||
|         onClick={handleChangeProfileAvatar} | ||||
|       > | ||||
|         {avatarUrl ? ( | ||||
|           <img | ||||
|             className={styles.profileAvatar} | ||||
|             alt={userProfile.displayName} | ||||
|             src={avatarUrl} | ||||
|           /> | ||||
|         ) : ( | ||||
|           <PersonIcon size={96} /> | ||||
|         )} | ||||
|         <div className={styles.editProfileImageBadge}> | ||||
|           <DeviceCameraIcon size={16} /> | ||||
|         </div> | ||||
|       </button> | ||||
| 
 | ||||
|       <TextField | ||||
|         label={t("display_name")} | ||||
|         value={form.displayName} | ||||
|         required | ||||
|         minLength={3} | ||||
|         containerProps={{ style: { width: "100%" } }} | ||||
|         onChange={(e) => setForm({ ...form, displayName: e.target.value })} | ||||
|       /> | ||||
| 
 | ||||
|       <SelectField | ||||
|         label={t("privacy")} | ||||
|         value={form.profileVisibility} | ||||
|         onChange={handleProfileVisibilityChange} | ||||
|         options={profileVisibilityOptions.map((visiblity) => ({ | ||||
|           key: visiblity.value, | ||||
|           value: visiblity.value, | ||||
|           label: visiblity.label, | ||||
|         }))} | ||||
|       /> | ||||
| 
 | ||||
|       <Button disabled={isSaving} style={{ alignSelf: "end" }} type="submit"> | ||||
|         {isSaving ? t("saving") : t("save")} | ||||
|       </Button> | ||||
|     </form> | ||||
|   ); | ||||
| }; | ||||
|  | @ -0,0 +1,72 @@ | |||
| import { Button, Modal } from "@renderer/components"; | ||||
| import { UserProfile } from "@types"; | ||||
| import { SPACING_UNIT } from "@renderer/theme.css"; | ||||
| import { useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { UserEditProfile } from "./user-edit-profile"; | ||||
| 
 | ||||
| export interface UserEditProfileModalProps { | ||||
|   userProfile: UserProfile; | ||||
|   visible: boolean; | ||||
|   onClose: () => void; | ||||
|   updateUserProfile: () => Promise<void>; | ||||
| } | ||||
| 
 | ||||
| export const UserProfileSettingsModal = ({ | ||||
|   userProfile, | ||||
|   visible, | ||||
|   onClose, | ||||
|   updateUserProfile, | ||||
| }: UserEditProfileModalProps) => { | ||||
|   const { t } = useTranslation("user_profile"); | ||||
| 
 | ||||
|   const tabs = [t("edit_profile"), "Ban list"]; | ||||
| 
 | ||||
|   const [currentTabIndex, setCurrentTabIndex] = useState(0); | ||||
| 
 | ||||
|   const renderTab = () => { | ||||
|     if (currentTabIndex == 0) { | ||||
|       return ( | ||||
|         <UserEditProfile | ||||
|           userProfile={userProfile} | ||||
|           updateUserProfile={updateUserProfile} | ||||
|         /> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     if (currentTabIndex == 1) { | ||||
|       return <></>; | ||||
|     } | ||||
| 
 | ||||
|     return <></>; | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Modal visible={visible} title={t("settings")} onClose={onClose}> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             gap: `${SPACING_UNIT * 2}px`, | ||||
|           }} | ||||
|         > | ||||
|           <section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}> | ||||
|             {tabs.map((tab, index) => { | ||||
|               return ( | ||||
|                 <Button | ||||
|                   key={tab} | ||||
|                   theme={index === currentTabIndex ? "primary" : "outline"} | ||||
|                   onClick={() => setCurrentTabIndex(index)} | ||||
|                 > | ||||
|                   {tab} | ||||
|                 </Button> | ||||
|               ); | ||||
|             })} | ||||
|           </section> | ||||
|           {renderTab()} | ||||
|         </div> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue