diff --git a/package.json b/package.json index c99a9bac..1b99734d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@reduxjs/toolkit": "^2.2.3", "@sentry/electron": "^5.1.0", "@vanilla-extract/css": "^1.14.2", + "@vanilla-extract/dynamic": "^2.1.1", "@vanilla-extract/recipes": "^0.5.2", "aria2": "^4.1.2", "auto-launch": "^5.0.6", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e44dc7ea..3f1bcdcd 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -241,6 +241,15 @@ "successfully_signed_out": "Successfully signed out", "sign_out": "Sign out", "playing_for": "Playing for {{amount}}", - "sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?" + "sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?", + "add_friends": "Add Friends", + "add": "Add", + "friend_code": "Friend code", + "see_profile": "See profile", + "sending": "Sending", + "friend_request_sent": "Friend request sent", + "friends": "Friends", + "friends_list": "Friends list", + "user_not_found": "User not found" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 02c0879f..92a3a4d7 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -241,6 +241,15 @@ "sign_out": "Sair da conta", "sign_out_modal_title": "Tem certeza?", "playing_for": "Jogando por {{amount}}", - "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?" + "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?", + "add_friends": "Adicionar Amigos", + "friend_code": "Código de amigo", + "see_profile": "Ver perfil", + "friend_request_sent": "Pedido de amizade enviado", + "friends": "Amigos", + "add": "Adicionar", + "sending": "Enviando", + "friends_list": "Lista de amigos", + "user_not_found": "Usuário não encontrado" } } diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 1b500be9..dd5e3263 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -43,8 +43,11 @@ import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; import "./user/get-user"; +import "./profile/get-friend-requests"; import "./profile/get-me"; +import "./profile/update-friend-request"; import "./profile/update-profile"; +import "./profile/send-friend-request"; ipcMain.handle("ping", () => "pong"); ipcMain.handle("getVersion", () => app.getVersion()); diff --git a/src/main/events/profile/get-friend-requests.ts b/src/main/events/profile/get-friend-requests.ts new file mode 100644 index 00000000..11d8a884 --- /dev/null +++ b/src/main/events/profile/get-friend-requests.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +import { FriendRequest } from "@types"; + +const getFriendRequests = async ( + _event: Electron.IpcMainInvokeEvent +): Promise => { + return HydraApi.get(`/profile/friend-requests`).catch(() => []); +}; + +registerEvent("getFriendRequests", getFriendRequests); diff --git a/src/main/events/profile/get-me.ts b/src/main/events/profile/get-me.ts index 83463680..1626125b 100644 --- a/src/main/events/profile/get-me.ts +++ b/src/main/events/profile/get-me.ts @@ -9,9 +9,7 @@ const getMe = async ( _event: Electron.IpcMainInvokeEvent ): Promise => { return HydraApi.get(`/profile/me`) - .then((response) => { - const me = response.data; - + .then((me) => { userAuthRepository.upsert( { id: 1, @@ -26,12 +24,18 @@ const getMe = async ( return me; }) - .catch((err) => { + .catch(async (err) => { if (err instanceof UserNotLoggedInError) { return null; } - return userAuthRepository.findOne({ where: { id: 1 } }); + const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + + if (loggedUser) { + return { ...loggedUser, id: loggedUser.userId }; + } + + return null; }); }; diff --git a/src/main/events/profile/send-friend-request.ts b/src/main/events/profile/send-friend-request.ts new file mode 100644 index 00000000..d696606f --- /dev/null +++ b/src/main/events/profile/send-friend-request.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const sendFriendRequest = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + return HydraApi.post("/profile/friend-requests", { friendCode: userId }); +}; + +registerEvent("sendFriendRequest", sendFriendRequest); diff --git a/src/main/events/profile/update-friend-request.ts b/src/main/events/profile/update-friend-request.ts new file mode 100644 index 00000000..24929544 --- /dev/null +++ b/src/main/events/profile/update-friend-request.ts @@ -0,0 +1,19 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +import { FriendRequestAction } from "@types"; + +const updateFriendRequest = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string, + action: FriendRequestAction +) => { + if (action == "CANCEL") { + return HydraApi.delete(`/profile/friend-requests/${userId}`); + } + + return HydraApi.patch(`/profile/friend-requests/${userId}`, { + requestState: action, + }); +}; + +registerEvent("updateFriendRequest", updateFriendRequest); diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index fe79d345..8620eaa1 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -26,11 +26,9 @@ const updateProfile = async ( _event: Electron.IpcMainInvokeEvent, displayName: string, newProfileImagePath: string | null -) => { +): Promise => { if (!newProfileImagePath) { - return patchUserProfile(displayName).then( - (response) => response.data as UserProfile - ); + return patchUserProfile(displayName); } const stats = fs.statSync(newProfileImagePath); @@ -42,7 +40,7 @@ const updateProfile = async ( imageLength: fileSizeInBytes, }) .then(async (preSignedResponse) => { - const { presignedUrl, profileImageUrl } = preSignedResponse.data; + const { presignedUrl, profileImageUrl } = preSignedResponse; const mimeType = await fileTypeFromFile(newProfileImagePath); @@ -51,13 +49,11 @@ const updateProfile = async ( "Content-Type": mimeType?.mime, }, }); - return profileImageUrl; + return profileImageUrl as string; }) .catch(() => undefined); - return patchUserProfile(displayName, profileImageUrl).then( - (response) => response.data as UserProfile - ); + return patchUserProfile(displayName, profileImageUrl); }; registerEvent("updateProfile", updateProfile); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 596df084..7b4c0aa8 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -10,8 +10,7 @@ const getUser = async ( userId: string ): Promise => { try { - const response = await HydraApi.get(`/user/${userId}`); - const profile = response.data; + const profile = await HydraApi.get(`/user/${userId}`); const recentGames = await Promise.all( profile.recentGames.map(async (game) => { diff --git a/src/main/index.ts b/src/main/index.ts index e288302b..9ff74bf6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,8 @@ autoUpdater.setFeedURL({ autoUpdater.logger = logger; +logger.log("Init Hydra"); + const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.quit(); @@ -121,6 +123,7 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { /* Disconnects libtorrent */ PythonInstance.kill(); + logger.log("Quit Hydra"); }); app.on("activate", () => { diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 98b783f3..97517b5b 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -10,7 +10,7 @@ import { UserNotLoggedInError } from "@shared"; export class HydraApi { private static instance: AxiosInstance; - private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; + private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes private static secondsToMilliseconds = (seconds: number) => seconds * 1000; @@ -45,6 +45,8 @@ export class HydraApi { expirationTimestamp: tokenExpirationTimestamp, }; + logger.log("Sign in received", this.userAuth); + await userAuthRepository.upsert( { id: 1, @@ -74,7 +76,7 @@ export class HydraApi { return request; }, (error) => { - logger.log("request error", error); + logger.error("request error", error); return Promise.reject(error); } ); @@ -95,12 +97,18 @@ export class HydraApi { const { config } = error; - logger.error(config.method, config.baseURL, config.url, config.headers); + logger.error( + config.method, + config.baseURL, + config.url, + config.headers, + config.data + ); if (error.response) { - logger.error(error.response.status, error.response.data); + logger.error("Response", error.response.status, error.response.data); } else if (error.request) { - logger.error(error.request); + logger.error("Request", error.request); } else { logger.error("Error", error.message); } @@ -146,6 +154,8 @@ export class HydraApi { this.userAuth.authToken = accessToken; this.userAuth.expirationTimestamp = tokenExpirationTimestamp; + logger.log("Token refreshed", this.userAuth); + userAuthRepository.upsert( { id: 1, @@ -170,6 +180,8 @@ export class HydraApi { private static handleUnauthorizedError = (err) => { if (err instanceof AxiosError && err.response?.status === 401) { + logger.error("401 - Current credentials:", this.userAuth); + this.userAuth = { authToken: "", expirationTimestamp: 0, @@ -190,6 +202,7 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); return this.instance .get(url, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } @@ -199,6 +212,7 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); return this.instance .post(url, data, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } @@ -208,6 +222,7 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); return this.instance .put(url, data, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } @@ -217,6 +232,7 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); return this.instance .patch(url, data, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } @@ -226,6 +242,7 @@ export class HydraApi { await this.revalidateAccessTokenIfExpired(); return this.instance .delete(url, this.getAxiosConfig()) + .then((response) => response.data) .catch(this.handleUnauthorizedError); } } diff --git a/src/main/services/library-sync/create-game.ts b/src/main/services/library-sync/create-game.ts index c0e8b1f8..b66a1897 100644 --- a/src/main/services/library-sync/create-game.ts +++ b/src/main/services/library-sync/create-game.ts @@ -10,11 +10,7 @@ export const createGame = async (game: Game) => { lastTimePlayed: game.lastTimePlayed, }) .then((response) => { - const { - id: remoteId, - playTimeInMilliseconds, - lastTimePlayed, - } = response.data; + const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; gameRepository.update( { objectID: game.objectID }, diff --git a/src/main/services/library-sync/merge-with-remote-games.ts b/src/main/services/library-sync/merge-with-remote-games.ts index 2162ea58..2a6b5bb5 100644 --- a/src/main/services/library-sync/merge-with-remote-games.ts +++ b/src/main/services/library-sync/merge-with-remote-games.ts @@ -6,7 +6,7 @@ import { getSteamAppAsset } from "@main/helpers"; export const mergeWithRemoteGames = async () => { return HydraApi.get("/games") .then(async (response) => { - for (const game of response.data) { + for (const game of response) { const localGame = await gameRepository.findOne({ where: { objectID: game.objectId, diff --git a/src/preload/index.ts b/src/preload/index.ts index 0cadbc03..91722606 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { AppUpdaterEvent, StartGameDownloadPayload, GameRunning, + FriendRequestAction, } from "@types"; contextBridge.exposeInMainWorld("electron", { @@ -136,6 +137,11 @@ contextBridge.exposeInMainWorld("electron", { getMe: () => ipcRenderer.invoke("getMe"), updateProfile: (displayName: string, newProfileImagePath: string | null) => ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), + getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), + updateFriendRequest: (userId: string, action: FriendRequestAction) => + ipcRenderer.invoke("updateFriendRequest", userId, action), + sendFriendRequest: (userId: string) => + ipcRenderer.invoke("sendFriendRequest", userId), /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), diff --git a/src/renderer/index.html b/src/renderer/index.html index 543b85a9..52276268 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 afce9622..24c7bed6 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -25,6 +25,7 @@ import { setGameRunning, } from "@renderer/features"; import { useTranslation } from "react-i18next"; +import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; export interface AppProps { children: React.ReactNode; @@ -38,6 +39,13 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); + const { + isFriendsModalVisible, + friendRequetsModalTab, + updateFriendRequests, + hideFriendsModal, + } = useUserDetails(); + const { fetchUserDetails, updateUserDetails, clearUserDetails } = useUserDetails(); @@ -94,7 +102,10 @@ export function App() { } fetchUserDetails().then((response) => { - if (response) updateUserDetails(response); + if (response) { + updateUserDetails(response); + updateFriendRequests(); + } }); }, [fetchUserDetails, updateUserDetails, dispatch]); @@ -102,6 +113,7 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); + updateFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -206,6 +218,12 @@ export function App() { onClose={handleToastClose} /> + +
diff --git a/src/renderer/src/components/sidebar/sidebar-profile.css.ts b/src/renderer/src/components/sidebar/sidebar-profile.css.ts index d01b07f1..ba29c850 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.css.ts +++ b/src/renderer/src/components/sidebar/sidebar-profile.css.ts @@ -1,7 +1,18 @@ -import { style } from "@vanilla-extract/css"; +import { createVar, style } from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "../../theme.css"; +export const profileContainerBackground = createVar(); + +export const profileContainer = style({ + background: profileContainerBackground, + position: "relative", + cursor: "pointer", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + export const profileButton = style({ display: "flex", cursor: "pointer", @@ -10,9 +21,8 @@ export const profileButton = style({ color: vars.color.muted, borderBottom: `solid 1px ${vars.color.border}`, boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)", - ":hover": { - backgroundColor: "rgba(255, 255, 255, 0.15)", - }, + width: "100%", + zIndex: "10", }); export const profileButtonContent = style({ @@ -64,3 +74,25 @@ export const profileButtonTitle = style({ textOverflow: "ellipsis", whiteSpace: "nowrap", }); + +export const friendRequestContainer = style({ + position: "absolute", + padding: "8px", + right: `${SPACING_UNIT}px`, + display: "flex", + top: 0, + bottom: 0, + alignItems: "center", +}); + +export const friendRequestButton = style({ + color: vars.color.success, + cursor: "pointer", + borderRadius: "50%", + overflow: "hidden", + width: "40px", + height: "40px", + ":hover": { + color: vars.color.muted, + }, +}); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 914481b0..66c4d82d 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -1,17 +1,20 @@ import { useNavigate } from "react-router-dom"; -import { PersonIcon } from "@primer/octicons-react"; +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 { useTranslation } from "react-i18next"; +import { profileContainerBackground } from "./sidebar-profile.css"; +import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; export function SidebarProfile() { const navigate = useNavigate(); const { t } = useTranslation("sidebar"); - const { userDetails, profileBackground } = useUserDetails(); + const { userDetails, profileBackground, friendRequests, showFriendsModal } = + useUserDetails(); const { gameRunning } = useAppSelector((state) => state.gameRunning); @@ -30,46 +33,64 @@ export function SidebarProfile() { }, [profileBackground]); return ( - + + {userDetails && friendRequests.length > 0 && !gameRunning && ( +
+ +
+ )} + ); } diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 48fa7aae..bb89f84e 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -14,6 +14,8 @@ import type { RealDebridUser, DownloadSource, UserProfile, + FriendRequest, + FriendRequestAction, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -132,6 +134,12 @@ declare global { displayName: string, newProfileImagePath: string | null ) => Promise; + getFriendRequests: () => Promise; + updateFriendRequest: ( + userId: string, + action: FriendRequestAction + ) => Promise; + sendFriendRequest: (userId: string) => Promise; } interface Window { diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 0cc395b0..0df88695 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -1,14 +1,21 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -import type { UserDetails } from "@types"; +import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; +import type { FriendRequest, UserDetails } from "@types"; export interface UserDetailsState { userDetails: UserDetails | null; profileBackground: null | string; + friendRequests: FriendRequest[]; + isFriendsModalVisible: boolean; + friendRequetsModalTab: UserFriendModalTab | null; } const initialState: UserDetailsState = { userDetails: null, profileBackground: null, + friendRequests: [], + isFriendsModalVisible: false, + friendRequetsModalTab: null, }; export const userDetailsSlice = createSlice({ @@ -21,8 +28,27 @@ export const userDetailsSlice = createSlice({ setProfileBackground: (state, action: PayloadAction) => { state.profileBackground = action.payload; }, + setFriendRequests: (state, action: PayloadAction) => { + state.friendRequests = action.payload; + }, + setFriendsModalVisible: ( + state, + action: PayloadAction + ) => { + state.isFriendsModalVisible = true; + state.friendRequetsModalTab = action.payload; + }, + setFriendsModalHidden: (state) => { + state.isFriendsModalVisible = false; + state.friendRequetsModalTab = null; + }, }, }); -export const { setUserDetails, setProfileBackground } = - userDetailsSlice.actions; +export const { + setUserDetails, + setProfileBackground, + setFriendRequests, + setFriendsModalVisible, + setFriendsModalHidden, +} = userDetailsSlice.actions; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index e87f8ff6..a0da950a 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -2,16 +2,27 @@ import { useCallback } from "react"; import { average } from "color.js"; import { useAppDispatch, useAppSelector } from "./redux"; -import { setProfileBackground, setUserDetails } from "@renderer/features"; +import { + setProfileBackground, + setUserDetails, + setFriendRequests, + setFriendsModalVisible, + setFriendsModalHidden, +} from "@renderer/features"; import { darkenColor } from "@renderer/helpers"; -import { UserDetails } from "@types"; +import { FriendRequestAction, UserDetails } from "@types"; +import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; export function useUserDetails() { const dispatch = useAppDispatch(); - const { userDetails, profileBackground } = useAppSelector( - (state) => state.userDetails - ); + const { + userDetails, + profileBackground, + friendRequests, + isFriendsModalVisible, + friendRequetsModalTab, + } = useAppSelector((state) => state.userDetails); const clearUserDetails = useCallback(async () => { dispatch(setUserDetails(null)); @@ -78,13 +89,56 @@ export function useUserDetails() { [updateUserDetails] ); + const updateFriendRequests = useCallback(async () => { + const friendRequests = await window.electron.getFriendRequests(); + dispatch(setFriendRequests(friendRequests)); + }, [dispatch]); + + const showFriendsModal = useCallback( + (tab: UserFriendModalTab) => { + dispatch(setFriendsModalVisible(tab)); + updateFriendRequests(); + }, + [dispatch] + ); + + const hideFriendsModal = useCallback(() => { + dispatch(setFriendsModalHidden()); + }, [dispatch]); + + const sendFriendRequest = useCallback( + async (userId: string) => { + return window.electron + .sendFriendRequest(userId) + .then(() => updateFriendRequests()); + }, + [updateFriendRequests] + ); + + const updateFriendRequestState = useCallback( + async (userId: string, action: FriendRequestAction) => { + return window.electron + .updateFriendRequest(userId, action) + .then(() => updateFriendRequests()); + }, + [updateFriendRequests] + ); + return { userDetails, + profileBackground, + friendRequests, + friendRequetsModalTab, + isFriendsModalVisible, + showFriendsModal, + hideFriendsModal, fetchUserDetails, signOut, clearUserDetails, updateUserDetails, patchUser, - profileBackground, + sendFriendRequest, + updateFriendRequests, + updateFriendRequestState, }; } diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts b/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts new file mode 100644 index 00000000..c7484512 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts @@ -0,0 +1 @@ +export * from "./user-friend-modal"; 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 new file mode 100644 index 00000000..bf4879b2 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -0,0 +1,140 @@ +import { Button, TextField } from "@renderer/components"; +import { useToast, useUserDetails } from "@renderer/hooks"; +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"; + +export interface UserFriendModalAddFriendProps { + closeModal: () => void; +} + +export const UserFriendModalAddFriend = ({ + closeModal, +}: UserFriendModalAddFriendProps) => { + const { t } = useTranslation("user_profile"); + + const [friendCode, setFriendCode] = useState(""); + const [isAddingFriend, setIsAddingFriend] = useState(false); + + const navigate = useNavigate(); + + const { sendFriendRequest, updateFriendRequestState, friendRequests } = + useUserDetails(); + + const { showErrorToast } = useToast(); + + const handleClickAddFriend = () => { + setIsAddingFriend(true); + sendFriendRequest(friendCode) + .then(() => { + // TODO: add validation for this input? + setFriendCode(""); + }) + .catch(() => { + showErrorToast("Não foi possível enviar o pedido de amizade"); + }) + .finally(() => { + setIsAddingFriend(false); + }); + }; + + const resetAndClose = () => { + setFriendCode(""); + closeModal(); + }; + + const handleClickRequest = (userId: string) => { + resetAndClose(); + navigate(`/user/${userId}`); + }; + + const handleClickSeeProfile = () => { + resetAndClose(); + // TODO: add validation for this input? + navigate(`/user/${friendCode}`); + }; + + const handleClickCancelFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "CANCEL").catch(() => { + showErrorToast("Falha ao cancelar convite"); + }); + }; + + const handleClickAcceptFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "ACCEPTED").catch(() => { + showErrorToast("Falha ao aceitar convite"); + }); + }; + + const handleClickRefuseFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "REFUSED").catch(() => { + showErrorToast("Falha ao recusar convite"); + }); + }; + + return ( + <> +
+ setFriendCode(e.target.value)} + /> + + +
+ +
+

Pendentes

+ {friendRequests.map((request) => { + return ( + + ); + })} +
+ + ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts new file mode 100644 index 00000000..0d6e8643 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.css.ts @@ -0,0 +1,92 @@ +import { SPACING_UNIT, vars } from "../../../theme.css"; +import { style } from "@vanilla-extract/css"; + +export const profileContentBox = style({ + display: "flex", + gap: `${SPACING_UNIT * 3}px`, + alignItems: "center", + borderRadius: "4px", + border: `solid 1px ${vars.color.border}`, + width: "100%", + boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)", + transition: "all ease 0.3s", +}); + +export const friendAvatarContainer = style({ + width: "35px", + minWidth: "35px", + height: "35px", + borderRadius: "50%", + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor: vars.color.background, + overflow: "hidden", + border: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", +}); + +export const friendListDisplayName = style({ + fontWeight: "bold", + fontSize: vars.size.body, + textAlign: "left", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +export const profileAvatar = style({ + height: "100%", + width: "100%", + objectFit: "cover", +}); + +export const friendListContainer = style({ + width: "100%", + height: "54px", + transition: "all ease 0.2s", + position: "relative", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + +export const friendListButton = style({ + display: "flex", + alignItems: "center", + position: "absolute", + cursor: "pointer", + height: "100%", + width: "100%", + flexDirection: "row", + color: vars.color.body, + gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, + padding: `0 ${SPACING_UNIT}px`, +}); + +export const friendRequestItem = style({ + color: vars.color.body, + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + +export const acceptRequestButton = style({ + cursor: "pointer", + color: vars.color.body, + width: "28px", + height: "28px", + ":hover": { + color: vars.color.success, + }, +}); + +export const cancelRequestButton = style({ + cursor: "pointer", + color: vars.color.body, + width: "28px", + height: "28px", + ":hover": { + color: vars.color.danger, + }, +}); 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 new file mode 100644 index 00000000..88cf4a6c --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx @@ -0,0 +1,77 @@ +import { Button, Modal } from "@renderer/components"; +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"; + +export enum UserFriendModalTab { + FriendsList, + AddFriend, +} + +export interface UserAddFriendsModalProps { + visible: boolean; + onClose: () => void; + initialTab: UserFriendModalTab | null; +} + +export const UserFriendModal = ({ + visible, + onClose, + initialTab, +}: UserAddFriendsModalProps) => { + const { t } = useTranslation("user_profile"); + + const tabs = [t("friends_list"), t("add_friends")]; + + const [currentTab, setCurrentTab] = useState( + initialTab || UserFriendModalTab.FriendsList + ); + + useEffect(() => { + if (initialTab != null) { + setCurrentTab(initialTab); + } + }, [initialTab]); + + const renderTab = () => { + if (currentTab == UserFriendModalTab.FriendsList) { + return <>; + } + + if (currentTab == UserFriendModalTab.AddFriend) { + return ; + } + + return <>; + }; + + return ( + +
+
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+

{tabs[currentTab]}

+ {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 new file mode 100644 index 00000000..022807d5 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx @@ -0,0 +1,97 @@ +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-content.tsx b/src/renderer/src/pages/user/user-content.tsx index 6f897238..e70335d8 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -1,9 +1,8 @@ import { UserGame, UserProfile } from "@types"; import cn from "classnames"; - import * as styles from "./user.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { @@ -14,10 +13,11 @@ import { } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; -import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; +import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react"; import { Button, Link } from "@renderer/components"; import { UserEditProfileModal } from "./user-edit-modal"; import { UserSignOutModal } from "./user-signout-modal"; +import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; @@ -32,7 +32,13 @@ export function UserContent({ }: ProfileContentProps) { const { t, i18n } = useTranslation("user_profile"); - const { userDetails, profileBackground, signOut } = useUserDetails(); + const { + userDetails, + profileBackground, + signOut, + updateFriendRequests, + showFriendsModal, + } = useUserDetails(); const { showSuccessToast } = useToast(); const [showEditProfileModal, setShowEditProfileModal] = useState(false); @@ -72,6 +78,10 @@ export function UserContent({ setShowEditProfileModal(true); }; + const handleOnClickFriend = (userId: string) => { + navigate(`/user/${userId}`); + }; + const handleConfirmSignout = async () => { await signOut(); @@ -82,6 +92,10 @@ export function UserContent({ const isMe = userDetails?.id == userProfile.id; + useEffect(() => { + if (isMe) updateFriendRequests(); + }, [isMe]); + const profileContentBoxBackground = useMemo(() => { if (profileBackground) return profileBackground; /* TODO: Render background colors for other users */ @@ -216,9 +230,11 @@ export function UserContent({

{t("no_recent_activity_title")}

-

- {t("no_recent_activity_description")} -

+ {isMe && ( +

+ {t("no_recent_activity_description")} +

+ )} ) : (
-
-
-

{t("library")}

- +
+
-

- {userProfile.libraryGames.length} -

+ > +

{t("library")}

+ +
+

+ {userProfile.libraryGames.length} +

+
+ {t("total_play_time", { amount: formatPlayTime() })} +
+ {userProfile.libraryGames.map((game) => ( + + ))} +
- {t("total_play_time", { amount: formatPlayTime() })} -
- {userProfile.libraryGames.map((game) => ( + + {(isMe || + (userProfile.friends && userProfile.friends.length > 0)) && ( +
- ))} -
+ +
+ {userProfile.friends.map((friend) => { + return ( + + ); + })} + + {isMe && ( + + )} +
+
+ )}
diff --git a/src/renderer/src/pages/user/user.css.ts b/src/renderer/src/pages/user/user.css.ts index 299aa393..fb0aee21 100644 --- a/src/renderer/src/pages/user/user.css.ts +++ b/src/renderer/src/pages/user/user.css.ts @@ -11,6 +11,7 @@ export const wrapper = style({ export const profileContentBox = style({ display: "flex", + cursor: "pointer", gap: `${SPACING_UNIT * 3}px`, alignItems: "center", borderRadius: "4px", @@ -35,6 +36,29 @@ export const profileAvatarContainer = style({ zIndex: 1, }); +export const friendAvatarContainer = style({ + width: "35px", + minWidth: "35px", + height: "35px", + borderRadius: "50%", + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor: vars.color.background, + overflow: "hidden", + border: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", +}); + +export const friendListDisplayName = style({ + fontWeight: "bold", + fontSize: vars.size.body, + textAlign: "left", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + export const profileAvatarEditContainer = style({ width: "128px", height: "128px", @@ -53,8 +77,6 @@ export const profileAvatarEditContainer = style({ export const profileAvatar = style({ height: "100%", width: "100%", - borderRadius: "50%", - overflow: "hidden", objectFit: "cover", }); @@ -86,14 +108,36 @@ export const profileContent = style({ export const profileGameSection = style({ width: "100%", - height: "100%", display: "flex", flexDirection: "column", gap: `${SPACING_UNIT * 2}px`, }); +export const friendsSection = style({ + width: "100%", + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT * 2}px`, +}); + +export const friendsSectionHeader = style({ + fontSize: vars.size.body, + color: vars.color.body, + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: `${SPACING_UNIT * 2}px`, + ":hover": { + color: vars.color.muted, + }, +}); + export const contentSidebar = style({ width: "100%", + display: "flex", + flexDirection: "column", + gap: `${SPACING_UNIT * 3}px`, "@media": { "(min-width: 768px)": { width: "100%", @@ -116,12 +160,17 @@ export const libraryGameIcon = style({ borderRadius: "4px", }); +export const friendProfileIcon = style({ + height: "100%", +}); + export const feedItem = style({ color: vars.color.body, display: "flex", flexDirection: "row", gap: `${SPACING_UNIT * 2}px`, width: "100%", + overflow: "hidden", height: "72px", transition: "all ease 0.2s", cursor: "pointer", @@ -143,6 +192,19 @@ export const gameListItem = style({ }, }); +export const friendListContainer = style({ + color: vars.color.body, + width: "100%", + height: "54px", + padding: `0 ${SPACING_UNIT}px`, + gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`, + transition: "all ease 0.2s", + position: "relative", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, +}); + export const gameInformation = style({ display: "flex", flexDirection: "column", diff --git a/src/renderer/src/pages/user/user.tsx b/src/renderer/src/pages/user/user.tsx index 501a61d8..4c45f789 100644 --- a/src/renderer/src/pages/user/user.tsx +++ b/src/renderer/src/pages/user/user.tsx @@ -2,18 +2,23 @@ import { UserProfile } from "@types"; import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { setHeaderTitle } from "@renderer/features"; -import { useAppDispatch } from "@renderer/hooks"; +import { useAppDispatch, useToast } from "@renderer/hooks"; import { UserSkeleton } from "./user-skeleton"; import { UserContent } from "./user-content"; import { SkeletonTheme } from "react-loading-skeleton"; import { vars } from "@renderer/theme.css"; import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; export const User = () => { const { userId } = useParams(); const [userProfile, setUserProfile] = useState(); const navigate = useNavigate(); + const { t } = useTranslation("user_profile"); + + const { showErrorToast } = useToast(); + const dispatch = useAppDispatch(); const getUserProfile = useCallback(() => { @@ -22,10 +27,11 @@ export const User = () => { dispatch(setHeaderTitle(userProfile.displayName)); setUserProfile(userProfile); } else { + showErrorToast(t("user_not_found")); navigate(-1); } }); - }, [dispatch, userId]); + }, [dispatch, userId, t]); useEffect(() => { getUserProfile(); diff --git a/src/types/index.ts b/src/types/index.ts index 71071620..891c75f9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,6 +10,8 @@ export type GameStatus = export type GameShop = "steam" | "epic"; +export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; + export interface SteamGenre { id: string; name: string; @@ -269,14 +271,27 @@ export interface UserDetails { profileImageUrl: string | null; } +export interface UserFriend { + id: string; + displayName: string; + profileImageUrl: string | null; +} + +export interface FriendRequest { + id: string; + displayName: string; + profileImageUrl: string | null; + type: "SENT" | "RECEIVED"; +} + export interface UserProfile { id: string; displayName: string; - username: string; profileImageUrl: string | null; totalPlayTimeInSeconds: number; libraryGames: UserGame[]; recentGames: UserGame[]; + friends: UserFriend[]; } export interface DownloadSource { diff --git a/yarn.lock b/yarn.lock index 00172038..e6b91b9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,6 +2433,13 @@ modern-ahocorasick "^1.0.0" picocolors "^1.0.0" +"@vanilla-extract/dynamic@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@vanilla-extract/dynamic/-/dynamic-2.1.1.tgz#bc93a577b127a7dcb6f254973d13a863029a7faf" + integrity sha512-iqf736036ujEIKsIq28UsBEMaLC2vR2DhwKyrG3NDb/fRy9qL9FKl1TqTtBV4daU30Uh3saeik4vRzN8bzQMbw== + dependencies: + "@vanilla-extract/private" "^1.0.5" + "@vanilla-extract/integration@^7.1.3": version "7.1.4" resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz" @@ -2456,6 +2463,11 @@ resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz" integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg== +"@vanilla-extract/private@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.5.tgz#8c08ac4851f4cc89a3dcdb858d8938e69b1481c4" + integrity sha512-6YXeOEKYTA3UV+RC8DeAjFk+/okoNz/h88R+McnzA2zpaVqTR/Ep+vszkWYlGBcMNO7vEkqbq5nT/JMMvhi+tw== + "@vanilla-extract/recipes@^0.5.2": version "0.5.2" resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"