diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3f1bcdcd..b24509d3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -250,6 +250,17 @@ "friend_request_sent": "Friend request sent", "friends": "Friends", "friends_list": "Friends list", - "user_not_found": "User not found" + "user_not_found": "User not found", + "block_user": "Block user", + "add_friend": "Add friend", + "request_sent": "Request sent", + "request_received": "Request received", + "accept_request": "Accept request", + "ignore_request": "Ignore request", + "cancel_request": "Cancel request", + "undo_friendship": "Undo friendship", + "request_accepted": "Request accepted", + "user_blocked_successfully": "User blocked successfully", + "user_block_modal_text": "This will block {{displayName}}" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 5e016d34..fcb2b099 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -241,6 +241,15 @@ "successfully_signed_out": "Sesión cerrada exitosamente", "sign_out": "Cerrar sesión", "playing_for": "Jugando por {{amount}}", - "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?" + "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?", + "add_friends": "Añadir amigos", + "add": "Añadir", + "friend_code": "Código de amigo", + "see_profile": "Ver perfil", + "sending": "Enviando", + "friend_request_sent": "Solicitud de amistad enviada", + "friends": "Amigos", + "friends_list": "Lista de amigos", + "user_not_found": "Usuario no encontrado" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 568116f8..ef94c31f 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -250,6 +250,17 @@ "add": "Adicionar", "sending": "Enviando", "friends_list": "Lista de amigos", - "user_not_found": "Usuário não encontrado" + "user_not_found": "Usuário não encontrado", + "block_user": "Bloquear", + "add_friend": "Adicionar amigo", + "request_sent": "Pedido enviado", + "request_received": "Pedido recebido", + "accept_request": "Aceitar pedido", + "ignore_request": "Ignorar pedido", + "cancel_request": "Cancelar pedido", + "undo_friendship": "Desfazer amizade", + "request_accepted": "Pedido de amizade aceito", + "user_blocked_successfully": "Usuário bloqueado com sucesso", + "user_block_modal_text": "Bloquear {{displayName}}" } } diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dd5e3263..57daf51c 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -43,8 +43,12 @@ import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; import "./user/get-user"; +import "./user/block-user"; +import "./user/unblock-user"; +import "./user/get-user-friends"; import "./profile/get-friend-requests"; import "./profile/get-me"; +import "./profile/undo-friendship"; import "./profile/update-friend-request"; import "./profile/update-profile"; import "./profile/send-friend-request"; diff --git a/src/main/events/profile/undo-friendship.ts b/src/main/events/profile/undo-friendship.ts new file mode 100644 index 00000000..371bc5cc --- /dev/null +++ b/src/main/events/profile/undo-friendship.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const undoFriendship = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.delete(`/profile/friends/${userId}`); +}; + +registerEvent("undoFriendship", undoFriendship); diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts new file mode 100644 index 00000000..8003f478 --- /dev/null +++ b/src/main/events/user/block-user.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const blockUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.post(`/user/${userId}/block`); +}; + +registerEvent("blockUser", blockUser); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts new file mode 100644 index 00000000..28783459 --- /dev/null +++ b/src/main/events/user/get-user-friends.ts @@ -0,0 +1,29 @@ +import { userAuthRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +import { UserFriends } from "@types"; + +export const getUserFriends = async ( + userId: string, + take: number, + skip: number +): Promise => { + const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + + if (loggedUser?.userId === userId) { + return HydraApi.get(`/profile/friends`, { take, skip }); + } + + return HydraApi.get(`/user/${userId}/friends`, { take, skip }); +}; + +const getUserFriendsEvent = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string, + take: number, + skip: number +) => { + return getUserFriends(userId, take, skip); +}; + +registerEvent("getUserFriends", getUserFriendsEvent); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 7b4c0aa8..eb4f0619 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -4,13 +4,19 @@ import { steamGamesWorker } from "@main/workers"; import { UserProfile } from "@types"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { getSteamAppAsset } from "@main/helpers"; +import { getUserFriends } from "./get-user-friends"; const getUser = async ( _event: Electron.IpcMainInvokeEvent, userId: string ): Promise => { try { - const profile = await HydraApi.get(`/user/${userId}`); + const [profile, friends] = await Promise.all([ + HydraApi.get(`/user/${userId}`), + getUserFriends(userId, 12, 0).catch(() => { + return { totalFriends: 0, friends: [] }; + }), + ]); const recentGames = await Promise.all( profile.recentGames.map(async (game) => { @@ -46,7 +52,13 @@ const getUser = async ( }) ); - return { ...profile, libraryGames, recentGames }; + return { + ...profile, + libraryGames, + recentGames, + friends: friends.friends, + totalFriends: friends.totalFriends, + }; } catch (err) { return null; } diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts new file mode 100644 index 00000000..ac678dbd --- /dev/null +++ b/src/main/events/user/unblock-user.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const unblockUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.post(`/user/${userId}/unblock`); +}; + +registerEvent("unblockUser", unblockUser); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 97517b5b..5365bd9e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -72,7 +72,7 @@ export class HydraApi { this.instance.interceptors.request.use( (request) => { logger.log(" ---- REQUEST -----"); - logger.log(request.method, request.url, request.data); + logger.log(request.method, request.url, request.params, request.data); return request; }, (error) => { @@ -196,52 +196,52 @@ export class HydraApi { throw err; }; - static async get(url: string) { + static async get(url: string, params?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .get(url, this.getAxiosConfig()) + .get(url, { params, ...this.getAxiosConfig() }) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async post(url: string, data?: any) { + static async post(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .post(url, data, this.getAxiosConfig()) + .post(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async put(url: string, data?: any) { + static async put(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .put(url, data, this.getAxiosConfig()) + .put(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async patch(url: string, data?: any) { + static async patch(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .patch(url, data, this.getAxiosConfig()) + .patch(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async delete(url: string) { + static async delete(url: string) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .delete(url, this.getAxiosConfig()) + .delete(url, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 91722606..3350a340 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,6 +135,8 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), + undoFriendship: (userId: string) => + ipcRenderer.invoke("undoFriendship", userId), updateProfile: (displayName: string, newProfileImagePath: string | null) => ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), @@ -145,6 +147,10 @@ contextBridge.exposeInMainWorld("electron", { /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), + blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), + unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), + getUserFriends: (userId: string, take: number, skip: number) => + ipcRenderer.invoke("getUserFriends", userId, take, skip), /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), diff --git a/src/renderer/index.html b/src/renderer/index.html index 52276268..543b85a9 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 24c7bed6..8c6f7604 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -42,11 +42,12 @@ export function App() { const { isFriendsModalVisible, friendRequetsModalTab, - updateFriendRequests, + friendModalUserId, + fetchFriendRequests, hideFriendsModal, } = useUserDetails(); - const { fetchUserDetails, updateUserDetails, clearUserDetails } = + const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } = useUserDetails(); const dispatch = useAppDispatch(); @@ -104,7 +105,7 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - updateFriendRequests(); + fetchFriendRequests(); } }); }, [fetchUserDetails, updateUserDetails, dispatch]); @@ -113,7 +114,7 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - updateFriendRequests(); + fetchFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -218,11 +219,14 @@ export function App() { onClose={handleToastClose} /> - + {userDetails && ( + + )}
diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 66c4d82d..81736e37 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -3,10 +3,11 @@ import { PersonAddIcon, PersonIcon } from "@primer/octicons-react"; import * as styles from "./sidebar-profile.css"; import { assignInlineVars } from "@vanilla-extract/dynamic"; import { useAppSelector, useUserDetails } from "@renderer/hooks"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { profileContainerBackground } from "./sidebar-profile.css"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; +import { FriendRequest } from "@types"; export function SidebarProfile() { const navigate = useNavigate(); @@ -16,6 +17,14 @@ export function SidebarProfile() { const { userDetails, profileBackground, friendRequests, showFriendsModal } = useUserDetails(); + const [receivedRequests, setReceivedRequests] = useState([]); + + useEffect(() => { + setReceivedRequests( + friendRequests.filter((request) => request.type === "RECEIVED") + ); + }, [friendRequests]); + const { gameRunning } = useAppSelector((state) => state.gameRunning); const handleButtonClick = () => { @@ -79,15 +88,17 @@ export function SidebarProfile() { )} - {userDetails && friendRequests.length > 0 && !gameRunning && ( + {userDetails && receivedRequests.length > 0 && !gameRunning && (
)} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index bb89f84e..e022cffe 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -16,6 +16,7 @@ import type { UserProfile, FriendRequest, FriendRequestAction, + UserFriends, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -127,9 +128,17 @@ declare global { /* User */ getUser: (userId: string) => Promise; + blockUser: (userId: string) => Promise; + unblockUser: (userId: string) => Promise; + getUserFriends: ( + userId: string, + take: number, + skip: number + ) => Promise; /* Profile */ getMe: () => Promise; + undoFriendship: (userId: string) => Promise; updateProfile: ( displayName: string, newProfileImagePath: string | null diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 0df88695..d559de09 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -8,6 +8,7 @@ export interface UserDetailsState { friendRequests: FriendRequest[]; isFriendsModalVisible: boolean; friendRequetsModalTab: UserFriendModalTab | null; + friendModalUserId: string; } const initialState: UserDetailsState = { @@ -16,6 +17,7 @@ const initialState: UserDetailsState = { friendRequests: [], isFriendsModalVisible: false, friendRequetsModalTab: null, + friendModalUserId: "", }; export const userDetailsSlice = createSlice({ @@ -33,10 +35,11 @@ export const userDetailsSlice = createSlice({ }, setFriendsModalVisible: ( state, - action: PayloadAction + action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }> ) => { state.isFriendsModalVisible = true; - state.friendRequetsModalTab = action.payload; + state.friendRequetsModalTab = action.payload.initialTab; + state.friendModalUserId = action.payload.userId; }, setFriendsModalHidden: (state) => { state.isFriendsModalVisible = false; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index d37612d4..182aef25 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,6 +1,7 @@ import type { GameShop } from "@types"; import Color from "color"; +import { average } from "color.js"; export const steamUrlBuilder = { library: (objectID: string) => @@ -45,3 +46,14 @@ export const buildGameDetailsPath = ( export const darkenColor = (color: string, amount: number, alpha: number = 1) => new Color(color).darken(amount).alpha(alpha).toString(); + +export const profileBackgroundFromProfileImage = async ( + profileImageUrl: string +) => { + const output = await average(profileImageUrl, { + amount: 1, + format: "hex", + }); + + return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; +}; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index a0da950a..21690e7e 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -1,6 +1,4 @@ import { useCallback } from "react"; -import { average } from "color.js"; - import { useAppDispatch, useAppSelector } from "./redux"; import { setProfileBackground, @@ -9,7 +7,7 @@ import { setFriendsModalVisible, setFriendsModalHidden, } from "@renderer/features"; -import { darkenColor } from "@renderer/helpers"; +import { profileBackgroundFromProfileImage } from "@renderer/helpers"; import { FriendRequestAction, UserDetails } from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; @@ -21,6 +19,7 @@ export function useUserDetails() { profileBackground, friendRequests, isFriendsModalVisible, + friendModalUserId, friendRequetsModalTab, } = useAppSelector((state) => state.userDetails); @@ -42,12 +41,9 @@ export function useUserDetails() { dispatch(setUserDetails(userDetails)); if (userDetails.profileImageUrl) { - const output = await average(userDetails.profileImageUrl, { - amount: 1, - format: "hex", - }); - - const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; + const profileBackground = await profileBackgroundFromProfileImage( + userDetails.profileImageUrl + ); dispatch(setProfileBackground(profileBackground)); window.localStorage.setItem( @@ -89,15 +85,19 @@ export function useUserDetails() { [updateUserDetails] ); - const updateFriendRequests = useCallback(async () => { - const friendRequests = await window.electron.getFriendRequests(); - dispatch(setFriendRequests(friendRequests)); + const fetchFriendRequests = useCallback(() => { + return window.electron + .getFriendRequests() + .then((friendRequests) => { + dispatch(setFriendRequests(friendRequests)); + }) + .catch(() => {}); }, [dispatch]); const showFriendsModal = useCallback( - (tab: UserFriendModalTab) => { - dispatch(setFriendsModalVisible(tab)); - updateFriendRequests(); + (initialTab: UserFriendModalTab, userId: string) => { + dispatch(setFriendsModalVisible({ initialTab, userId })); + fetchFriendRequests(); }, [dispatch] ); @@ -110,26 +110,39 @@ export function useUserDetails() { async (userId: string) => { return window.electron .sendFriendRequest(userId) - .then(() => updateFriendRequests()); + .then(() => fetchFriendRequests()); }, - [updateFriendRequests] + [fetchFriendRequests] ); const updateFriendRequestState = useCallback( async (userId: string, action: FriendRequestAction) => { return window.electron .updateFriendRequest(userId, action) - .then(() => updateFriendRequests()); + .then(() => fetchFriendRequests()); }, - [updateFriendRequests] + [fetchFriendRequests] ); + const undoFriendship = (userId: string) => { + return window.electron.undoFriendship(userId); + }; + + const blockUser = (userId: string) => { + return window.electron.blockUser(userId); + }; + + const unblockUser = (userId: string) => { + return window.electron.unblockUser(userId); + }; + return { userDetails, profileBackground, friendRequests, friendRequetsModalTab, isFriendsModalVisible, + friendModalUserId, showFriendsModal, hideFriendsModal, fetchUserDetails, @@ -138,7 +151,10 @@ export function useUserDetails() { updateUserDetails, patchUser, sendFriendRequest, - updateFriendRequests, + fetchFriendRequests, updateFriendRequestState, + blockUser, + unblockUser, + undoFriendship, }; } diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx new file mode 100644 index 00000000..7c34d040 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx @@ -0,0 +1,136 @@ +import { + CheckCircleIcon, + PersonIcon, + XCircleIcon, +} from "@primer/octicons-react"; +import * as styles from "./user-friend-modal.css"; +import cn from "classnames"; +import { SPACING_UNIT } from "@renderer/theme.css"; +import { useTranslation } from "react-i18next"; + +export type UserFriendItemProps = { + userId: string; + profileImageUrl: string | null; + displayName: string; + onClickItem: (userId: string) => void; +} & ( + | { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void } + | { + type: "SENT" | "RECEIVED"; + onClickCancelRequest: (userId: string) => void; + onClickAcceptRequest: (userId: string) => void; + onClickRefuseRequest: (userId: string) => void; + } + | { type: null } +); + +export const UserFriendItem = (props: UserFriendItemProps) => { + const { t } = useTranslation("user_profile"); + const { userId, profileImageUrl, displayName, type, onClickItem } = props; + + const getRequestDescription = () => { + if (type === "ACCEPTED" || type === null) return null; + + return ( + + {type == "SENT" ? t("request_sent") : t("request_received")} + + ); + }; + + const getRequestActions = () => { + if (type === null) return null; + + if (type === "SENT") { + return ( + + ); + } + + if (type === "RECEIVED") { + return ( + <> + + + + ); + } + + if (type === "ACCEPTED") { + return ( + + ); + } + + return null; + }; + + return ( +
+ + +
+ {getRequestActions()} +
+
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx index bf4879b2..0725674e 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -4,7 +4,7 @@ import { SPACING_UNIT } from "@renderer/theme.css"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { UserFriendRequest } from "./user-friend-request"; +import { UserFriendItem } from "./user-friend-item"; export interface UserFriendModalAddFriendProps { closeModal: () => void; @@ -23,7 +23,7 @@ export const UserFriendModalAddFriend = ({ const { sendFriendRequest, updateFriendRequestState, friendRequests } = useUserDetails(); - const { showErrorToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); const handleClickAddFriend = () => { setIsAddingFriend(true); @@ -56,21 +56,25 @@ export const UserFriendModalAddFriend = ({ navigate(`/user/${friendCode}`); }; - const handleClickCancelFriendRequest = (userId: string) => { + const handleCancelFriendRequest = (userId: string) => { updateFriendRequestState(userId, "CANCEL").catch(() => { - showErrorToast("Falha ao cancelar convite"); + showErrorToast(t("try_again")); }); }; - const handleClickAcceptFriendRequest = (userId: string) => { - updateFriendRequestState(userId, "ACCEPTED").catch(() => { - showErrorToast("Falha ao aceitar convite"); - }); + const handleAcceptFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "ACCEPTED") + .then(() => { + showSuccessToast(t("request_accepted")); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); }; - const handleClickRefuseFriendRequest = (userId: string) => { + const handleRefuseFriendRequest = (userId: string) => { updateFriendRequestState(userId, "REFUSED").catch(() => { - showErrorToast("Falha ao recusar convite"); + showErrorToast(t("try_again")); }); }; @@ -121,16 +125,16 @@ export const UserFriendModalAddFriend = ({

Pendentes

{friendRequests.map((request) => { return ( - ); })} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx new file mode 100644 index 00000000..52e646e0 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx @@ -0,0 +1,95 @@ +import { SPACING_UNIT } from "@renderer/theme.css"; +import { UserFriend } from "@types"; +import { useEffect, useState } from "react"; +import { UserFriendItem } from "./user-friend-item"; +import { useNavigate } from "react-router-dom"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { useTranslation } from "react-i18next"; + +export interface UserFriendModalListProps { + userId: string; + closeModal: () => void; +} + +const pageSize = 12; + +export const UserFriendModalList = ({ + userId, + closeModal, +}: UserFriendModalListProps) => { + const { t } = useTranslation("user_profile"); + const { showErrorToast } = useToast(); + const navigate = useNavigate(); + + const [page, setPage] = useState(0); + const [maxPage, setMaxPage] = useState(0); + const [friends, setFriends] = useState([]); + + const { userDetails, undoFriendship } = useUserDetails(); + const isMe = userDetails?.id == userId; + + const loadNextPage = () => { + if (page > maxPage) return; + window.electron + .getUserFriends(userId, pageSize, page * pageSize) + .then((newPage) => { + if (page === 0) { + setMaxPage(newPage.totalFriends / pageSize); + } + + setFriends([...friends, ...newPage.friends]); + setPage(page + 1); + }) + .catch(() => {}); + }; + + const reloadList = () => { + setPage(0); + setMaxPage(0); + setFriends([]); + loadNextPage(); + }; + + useEffect(() => { + reloadList(); + }, [userId]); + + const handleClickFriend = (userId: string) => { + closeModal(); + navigate(`/user/${userId}`); + }; + + const handleUndoFriendship = (userId: string) => { + undoFriendship(userId) + .then(() => { + reloadList(); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); + }; + + return ( +
+ {friends.map((friend) => { + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx index 88cf4a6c..abc26270 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx @@ -3,23 +3,27 @@ import { SPACING_UNIT } from "@renderer/theme.css"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend"; +import { useUserDetails } from "@renderer/hooks"; +import { UserFriendModalList } from "./user-friend-modal-list"; export enum UserFriendModalTab { FriendsList, AddFriend, } -export interface UserAddFriendsModalProps { +export interface UserFriendsModalProps { visible: boolean; onClose: () => void; initialTab: UserFriendModalTab | null; + userId: string; } export const UserFriendModal = ({ visible, onClose, initialTab, -}: UserAddFriendsModalProps) => { + userId, +}: UserFriendsModalProps) => { const { t } = useTranslation("user_profile"); const tabs = [t("friends_list"), t("add_friends")]; @@ -28,6 +32,9 @@ export const UserFriendModal = ({ initialTab || UserFriendModalTab.FriendsList ); + const { userDetails } = useUserDetails(); + const isMe = userDetails?.id == userId; + useEffect(() => { if (initialTab != null) { setCurrentTab(initialTab); @@ -36,7 +43,7 @@ export const UserFriendModal = ({ const renderTab = () => { if (currentTab == UserFriendModalTab.FriendsList) { - return <>; + return ; } if (currentTab == UserFriendModalTab.AddFriend) { @@ -56,20 +63,21 @@ export const UserFriendModal = ({ gap: `${SPACING_UNIT * 2}px`, }} > -
- {tabs.map((tab, index) => { - return ( - - ); - })} -
-

{tabs[currentTab]}

+ {isMe && ( +
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ )} {renderTab()} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx deleted file mode 100644 index 022807d5..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { - CheckCircleIcon, - PersonIcon, - XCircleIcon, -} from "@primer/octicons-react"; -import * as styles from "./user-friend-modal.css"; -import cn from "classnames"; -import { SPACING_UNIT } from "@renderer/theme.css"; - -export interface UserFriendRequestProps { - userId: string; - profileImageUrl: string | null; - displayName: string; - isRequestSent: boolean; - onClickCancelRequest: (userId: string) => void; - onClickAcceptRequest: (userId: string) => void; - onClickRefuseRequest: (userId: string) => void; - onClickRequest: (userId: string) => void; -} - -export const UserFriendRequest = ({ - userId, - profileImageUrl, - displayName, - isRequestSent, - onClickCancelRequest, - onClickAcceptRequest, - onClickRefuseRequest, - onClickRequest, -}: UserFriendRequestProps) => { - return ( -
- - -
- {isRequestSent ? ( - - ) : ( - <> - - - - )} -
-
- ); -}; diff --git a/src/renderer/src/pages/user/user-block-modal.tsx b/src/renderer/src/pages/user/user-block-modal.tsx new file mode 100644 index 00000000..e179e4da --- /dev/null +++ b/src/renderer/src/pages/user/user-block-modal.tsx @@ -0,0 +1,44 @@ +import { Button, Modal } from "@renderer/components"; +import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; + +export interface UserBlockModalProps { + visible: boolean; + displayName: string; + onConfirm: () => void; + onClose: () => void; +} + +export const UserBlockModal = ({ + visible, + displayName, + onConfirm, + onClose, +}: UserBlockModalProps) => { + const { t } = useTranslation("user_profile"); + + return ( + <> + +
+

+ {t("user_block_modal_text", { displayName })} +

+
+ + + +
+
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index e70335d8..81db119d 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -1,4 +1,4 @@ -import { UserGame, UserProfile } from "@types"; +import { FriendRequestAction, UserGame, UserProfile } from "@types"; import cn from "classnames"; import * as styles from "./user.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; @@ -12,12 +12,23 @@ import { useUserDetails, } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; -import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react"; +import { + buildGameDetailsPath, + profileBackgroundFromProfileImage, + steamUrlBuilder, +} from "@renderer/helpers"; +import { + CheckCircleIcon, + PersonIcon, + PlusIcon, + TelescopeIcon, + XCircleIcon, +} from "@primer/octicons-react"; import { Button, Link } from "@renderer/components"; import { UserEditProfileModal } from "./user-edit-modal"; -import { UserSignOutModal } from "./user-signout-modal"; +import { UserSignOutModal } from "./user-sign-out-modal"; import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; +import { UserBlockModal } from "./user-block-modal"; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; @@ -26,6 +37,8 @@ export interface ProfileContentProps { updateUserProfile: () => Promise; } +type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND"); + export function UserContent({ userProfile, updateUserProfile, @@ -36,13 +49,20 @@ export function UserContent({ userDetails, profileBackground, signOut, - updateFriendRequests, + sendFriendRequest, + fetchFriendRequests, showFriendsModal, + updateFriendRequestState, + undoFriendship, + blockUser, } = useUserDetails(); - const { showSuccessToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); + const [profileContentBoxBackground, setProfileContentBoxBackground] = + useState(); const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false); + const [showUserBlockModal, setShowUserBlockModal] = useState(false); const { gameRunning } = useAppSelector((state) => state.gameRunning); @@ -93,14 +113,141 @@ export function UserContent({ const isMe = userDetails?.id == userProfile.id; useEffect(() => { - if (isMe) updateFriendRequests(); + if (isMe) fetchFriendRequests(); }, [isMe]); - const profileContentBoxBackground = useMemo(() => { - if (profileBackground) return profileBackground; - /* TODO: Render background colors for other users */ - return undefined; - }, [profileBackground]); + useEffect(() => { + if (isMe && profileBackground) { + setProfileContentBoxBackground(profileBackground); + } + + if (userProfile.profileImageUrl) { + profileBackgroundFromProfileImage(userProfile.profileImageUrl).then( + (profileBackground) => { + setProfileContentBoxBackground(profileBackground); + } + ); + } + }, [profileBackground, isMe]); + + const handleFriendAction = (userId: string, action: FriendAction) => { + try { + if (action === "UNDO") { + undoFriendship(userId).then(updateUserProfile); + return; + } + + if (action === "BLOCK") { + blockUser(userId).then(() => { + setShowUserBlockModal(false); + showSuccessToast(t("user_blocked_successfully")); + navigate(-1); + }); + + return; + } + + if (action === "SEND") { + sendFriendRequest(userProfile.id).then(updateUserProfile); + return; + } + + updateFriendRequestState(userId, action).then(updateUserProfile); + } catch (err) { + showErrorToast(t("try_again")); + } + }; + + const showFriends = isMe || userProfile.totalFriends > 0; + + const getProfileActions = () => { + if (isMe) { + return ( + <> + + + + + ); + } + + if (userProfile.relation == null) { + return ( + <> + + + + + ); + } + + if (userProfile.relation.status === "ACCEPTED") { + const userId = + userProfile.relation.AId === userDetails?.id + ? userProfile.relation.BId + : userProfile.relation.AId; + + return ( + <> + + + ); + } + + if (userProfile.relation.BId === userProfile.id) { + return ( + + ); + } + + return ( + <> + + + + ); + }; return ( <> @@ -117,6 +264,13 @@ export function UserContent({ onConfirm={handleConfirmSignout} /> + setShowUserBlockModal(false)} + onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")} + displayName={userProfile.displayName} + /> +
- {isMe && ( +
-
- <> - - - - -
+ {getProfileActions()}
- )} +
@@ -327,12 +468,16 @@ export function UserContent({
- {(isMe || - (userProfile.friends && userProfile.friends.length > 0)) && ( + {showFriends && (
@@ -388,7 +533,10 @@ export function UserContent({