Merge pull request #814 from hydralauncher/hyd-270-create-a-section-under-library-games-in-profile-page-for

feat: add friends
This commit is contained in:
Zamitto 2024-07-20 19:11:39 -03:00 committed by GitHub
commit 3952f106fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 971 additions and 137 deletions

View File

@ -40,6 +40,7 @@
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0", "@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2", "@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.1",
"@vanilla-extract/recipes": "^0.5.2", "@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2", "aria2": "^4.1.2",
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",

View File

@ -241,6 +241,15 @@
"successfully_signed_out": "Successfully signed out", "successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out", "sign_out": "Sign out",
"playing_for": "Playing for {{amount}}", "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"
} }
} }

View File

@ -241,6 +241,15 @@
"sign_out": "Sair da conta", "sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?", "sign_out_modal_title": "Tem certeza?",
"playing_for": "Jogando por {{amount}}", "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"
} }
} }

View File

@ -43,8 +43,11 @@ import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
import "./user/get-user"; import "./user/get-user";
import "./profile/get-friend-requests";
import "./profile/get-me"; import "./profile/get-me";
import "./profile/update-friend-request";
import "./profile/update-profile"; import "./profile/update-profile";
import "./profile/send-friend-request";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -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<FriendRequest[]> => {
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
};
registerEvent("getFriendRequests", getFriendRequests);

View File

@ -9,9 +9,7 @@ const getMe = async (
_event: Electron.IpcMainInvokeEvent _event: Electron.IpcMainInvokeEvent
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
return HydraApi.get(`/profile/me`) return HydraApi.get(`/profile/me`)
.then((response) => { .then((me) => {
const me = response.data;
userAuthRepository.upsert( userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -26,12 +24,18 @@ const getMe = async (
return me; return me;
}) })
.catch((err) => { .catch(async (err) => {
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
return null; 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;
}); });
}; };

View File

@ -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);

View File

@ -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);

View File

@ -26,11 +26,9 @@ const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null
) => { ): Promise<UserProfile> => {
if (!newProfileImagePath) { if (!newProfileImagePath) {
return patchUserProfile(displayName).then( return patchUserProfile(displayName);
(response) => response.data as UserProfile
);
} }
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
@ -42,7 +40,7 @@ const updateProfile = async (
imageLength: fileSizeInBytes, imageLength: fileSizeInBytes,
}) })
.then(async (preSignedResponse) => { .then(async (preSignedResponse) => {
const { presignedUrl, profileImageUrl } = preSignedResponse.data; const { presignedUrl, profileImageUrl } = preSignedResponse;
const mimeType = await fileTypeFromFile(newProfileImagePath); const mimeType = await fileTypeFromFile(newProfileImagePath);
@ -51,13 +49,11 @@ const updateProfile = async (
"Content-Type": mimeType?.mime, "Content-Type": mimeType?.mime,
}, },
}); });
return profileImageUrl; return profileImageUrl as string;
}) })
.catch(() => undefined); .catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl).then( return patchUserProfile(displayName, profileImageUrl);
(response) => response.data as UserProfile
);
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View File

@ -10,8 +10,7 @@ const getUser = async (
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { try {
const response = await HydraApi.get(`/user/${userId}`); const profile = await HydraApi.get(`/user/${userId}`);
const profile = response.data;
const recentGames = await Promise.all( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { profile.recentGames.map(async (game) => {

View File

@ -20,6 +20,8 @@ autoUpdater.setFeedURL({
autoUpdater.logger = logger; autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit(); if (!gotTheLock) app.quit();
@ -121,6 +123,7 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => { app.on("before-quit", () => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.kill(); PythonInstance.kill();
logger.log("Quit Hydra");
}); });
app.on("activate", () => { app.on("activate", () => {

View File

@ -10,7 +10,7 @@ import { UserNotLoggedInError } from "@shared";
export class HydraApi { export class HydraApi {
private static instance: AxiosInstance; 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; private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
@ -45,6 +45,8 @@ export class HydraApi {
expirationTimestamp: tokenExpirationTimestamp, expirationTimestamp: tokenExpirationTimestamp,
}; };
logger.log("Sign in received", this.userAuth);
await userAuthRepository.upsert( await userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -74,7 +76,7 @@ export class HydraApi {
return request; return request;
}, },
(error) => { (error) => {
logger.log("request error", error); logger.error("request error", error);
return Promise.reject(error); return Promise.reject(error);
} }
); );
@ -95,12 +97,18 @@ export class HydraApi {
const { config } = error; 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) { if (error.response) {
logger.error(error.response.status, error.response.data); logger.error("Response", error.response.status, error.response.data);
} else if (error.request) { } else if (error.request) {
logger.error(error.request); logger.error("Request", error.request);
} else { } else {
logger.error("Error", error.message); logger.error("Error", error.message);
} }
@ -146,6 +154,8 @@ export class HydraApi {
this.userAuth.authToken = accessToken; this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp; this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
logger.log("Token refreshed", this.userAuth);
userAuthRepository.upsert( userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -170,6 +180,8 @@ export class HydraApi {
private static handleUnauthorizedError = (err) => { private static handleUnauthorizedError = (err) => {
if (err instanceof AxiosError && err.response?.status === 401) { if (err instanceof AxiosError && err.response?.status === 401) {
logger.error("401 - Current credentials:", this.userAuth);
this.userAuth = { this.userAuth = {
authToken: "", authToken: "",
expirationTimestamp: 0, expirationTimestamp: 0,
@ -190,6 +202,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.get(url, this.getAxiosConfig()) .get(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -199,6 +212,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.post(url, data, this.getAxiosConfig()) .post(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -208,6 +222,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.put(url, data, this.getAxiosConfig()) .put(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -217,6 +232,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.patch(url, data, this.getAxiosConfig()) .patch(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -226,6 +242,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.delete(url, this.getAxiosConfig()) .delete(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
} }

View File

@ -10,11 +10,7 @@ export const createGame = async (game: Game) => {
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
}) })
.then((response) => { .then((response) => {
const { const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update( gameRepository.update(
{ objectID: game.objectID }, { objectID: game.objectID },

View File

@ -6,7 +6,7 @@ import { getSteamAppAsset } from "@main/helpers";
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/games") return HydraApi.get("/games")
.then(async (response) => { .then(async (response) => {
for (const game of response.data) { for (const game of response) {
const localGame = await gameRepository.findOne({ const localGame = await gameRepository.findOne({
where: { where: {
objectID: game.objectId, objectID: game.objectId,

View File

@ -9,6 +9,7 @@ import type {
AppUpdaterEvent, AppUpdaterEvent,
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
FriendRequestAction,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@ -136,6 +137,11 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (displayName: string, newProfileImagePath: string | null) => updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), 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 */ /* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),

View File

@ -6,7 +6,7 @@
<title>Hydra</title> <title>Hydra</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://cdn.discordapp.com https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/> />
</head> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -25,6 +25,7 @@ import {
setGameRunning, setGameRunning,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
export interface AppProps { export interface AppProps {
children: React.ReactNode; children: React.ReactNode;
@ -38,6 +39,13 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const {
isFriendsModalVisible,
friendRequetsModalTab,
updateFriendRequests,
hideFriendsModal,
} = useUserDetails();
const { fetchUserDetails, updateUserDetails, clearUserDetails } = const { fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails(); useUserDetails();
@ -94,7 +102,10 @@ export function App() {
} }
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) updateUserDetails(response); if (response) {
updateUserDetails(response);
updateFriendRequests();
}
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);
@ -102,6 +113,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@ -206,6 +218,12 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
/>
<main> <main>
<Sidebar /> <Sidebar />

View File

@ -1,7 +1,18 @@
import { style } from "@vanilla-extract/css"; import { createVar, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.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({ export const profileButton = style({
display: "flex", display: "flex",
cursor: "pointer", cursor: "pointer",
@ -10,9 +21,8 @@ export const profileButton = style({
color: vars.color.muted, color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`, borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)", boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
":hover": { width: "100%",
backgroundColor: "rgba(255, 255, 255, 0.15)", zIndex: "10",
},
}); });
export const profileButtonContent = style({ export const profileButtonContent = style({
@ -64,3 +74,25 @@ export const profileButtonTitle = style({
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", 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,
},
});

View File

@ -1,17 +1,20 @@
import { useNavigate } from "react-router-dom"; 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 * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function SidebarProfile() { export function SidebarProfile() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("sidebar"); const { t } = useTranslation("sidebar");
const { userDetails, profileBackground } = useUserDetails(); const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -30,46 +33,64 @@ export function SidebarProfile() {
}, [profileBackground]); }, [profileBackground]);
return ( return (
<button <div
type="button" className={styles.profileContainer}
className={styles.profileButton} style={assignInlineVars({
style={{ background: profileButtonBackground }} [profileContainerBackground]: profileButtonBackground,
onClick={handleButtonClick} })}
> >
<div className={styles.profileButtonContent}> <button
<div className={styles.profileAvatar}> type="button"
{userDetails?.profileImageUrl ? ( className={styles.profileButton}
<img onClick={handleButtonClick}
className={styles.profileAvatar} >
src={userDetails.profileImageUrl} <div className={styles.profileButtonContent}>
alt={userDetails.displayName} <div className={styles.profileAvatar}>
/> {userDetails?.profileImageUrl ? (
) : ( <img
<PersonIcon /> className={styles.profileAvatar}
)} src={userDetails.profileImageUrl}
</div> alt={userDetails.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div className={styles.profileButtonInformation}> <div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}> <p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")} {userDetails ? userDetails.displayName : t("sign_in")}
</p> </p>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning && ( {userDetails && gameRunning && (
<div> <img
<small>{gameRunning.title}</small> alt={gameRunning.title}
</div> width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)} )}
</div> </div>
</button>
{userDetails && gameRunning && ( {userDetails && friendRequests.length > 0 && !gameRunning && (
<img <div className={styles.friendRequestContainer}>
alt={gameRunning.title} <button
width={24} type="button"
style={{ borderRadius: 4 }} className={styles.friendRequestButton}
src={gameRunning.iconUrl} onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)}
/> >
)} <PersonAddIcon size={24} />
</div> {friendRequests.length}
</button> </button>
</div>
)}
</div>
); );
} }

View File

@ -14,6 +14,8 @@ import type {
RealDebridUser, RealDebridUser,
DownloadSource, DownloadSource,
UserProfile, UserProfile,
FriendRequest,
FriendRequestAction,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -132,6 +134,12 @@ declare global {
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null
) => Promise<UserProfile>; ) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
} }
interface Window { interface Window {

View File

@ -1,14 +1,21 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; 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 { export interface UserDetailsState {
userDetails: UserDetails | null; userDetails: UserDetails | null;
profileBackground: null | string; profileBackground: null | string;
friendRequests: FriendRequest[];
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
userDetails: null, userDetails: null,
profileBackground: null, profileBackground: null,
friendRequests: [],
isFriendsModalVisible: false,
friendRequetsModalTab: null,
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@ -21,8 +28,27 @@ export const userDetailsSlice = createSlice({
setProfileBackground: (state, action: PayloadAction<string | null>) => { setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload; state.profileBackground = action.payload;
}, },
setFriendRequests: (state, action: PayloadAction<FriendRequest[]>) => {
state.friendRequests = action.payload;
},
setFriendsModalVisible: (
state,
action: PayloadAction<UserFriendModalTab>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
}, },
}); });
export const { setUserDetails, setProfileBackground } = export const {
userDetailsSlice.actions; setUserDetails,
setProfileBackground,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@ -2,16 +2,27 @@ import { useCallback } from "react";
import { average } from "color.js"; import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux"; 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 { 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() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector( const {
(state) => state.userDetails userDetails,
); profileBackground,
friendRequests,
isFriendsModalVisible,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
@ -78,13 +89,56 @@ export function useUserDetails() {
[updateUserDetails] [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 { return {
userDetails, userDetails,
profileBackground,
friendRequests,
friendRequetsModalTab,
isFriendsModalVisible,
showFriendsModal,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
signOut, signOut,
clearUserDetails, clearUserDetails,
updateUserDetails, updateUserDetails,
patchUser, patchUser,
profileBackground, sendFriendRequest,
updateFriendRequests,
updateFriendRequestState,
}; };
} }

View File

@ -0,0 +1 @@
export * from "./user-friend-modal";

View File

@ -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 (
<>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<TextField
label={t("friend_code")}
value={friendCode}
minLength={8}
maxLength={8}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setFriendCode(e.target.value)}
/>
<Button
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
onClick={handleClickAddFriend}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
onClick={handleClickSeeProfile}
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
>
{t("see_profile")}
</Button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h3>Pendentes</h3>
{friendRequests.map((request) => {
return (
<UserFriendRequest
key={request.id}
displayName={request.displayName}
isRequestSent={request.type === "SENT"}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleClickAcceptFriendRequest}
onClickCancelRequest={handleClickCancelFriendRequest}
onClickRefuseRequest={handleClickRefuseFriendRequest}
onClickRequest={handleClickRequest}
/>
);
})}
</div>
</>
);
};

View File

@ -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,
},
});

View File

@ -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 <UserFriendModalAddFriend closeModal={onClose} />;
}
return <></>;
};
return (
<Modal visible={visible} title={t("friends")} onClose={onClose}>
<div
style={{
display: "flex",
width: "500px",
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 === currentTab ? "primary" : "outline"}
onClick={() => setCurrentTab(index)}
>
{tab}
</Button>
);
})}
</section>
<h2>{tabs[currentTab]}</h2>
{renderTab()}
</div>
</Modal>
);
};

View File

@ -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 (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
<button
type="button"
className={styles.friendListButton}
onClick={() => onClickRequest(userId)}
>
<div className={styles.friendAvatarContainer}>
{profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={displayName}
src={profileImageUrl}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
}}
>
<p className={styles.friendListDisplayName}>{displayName}</p>
<small>{isRequestSent ? "Pedido enviado" : "Pedido recebido"}</small>
</div>
</button>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{isRequestSent ? (
<button
className={styles.cancelRequestButton}
onClick={() => onClickCancelRequest(userId)}
>
<XCircleIcon size={28} />
</button>
) : (
<>
<button
className={styles.acceptRequestButton}
onClick={() => onClickAcceptRequest(userId)}
>
<CheckCircleIcon size={28} />
</button>
<button
className={styles.cancelRequestButton}
onClick={() => onClickRefuseRequest(userId)}
>
<XCircleIcon size={28} />
</button>
</>
)}
</div>
</div>
);
};

View File

@ -1,9 +1,8 @@
import { UserGame, UserProfile } from "@types"; import { UserGame, UserProfile } from "@types";
import cn from "classnames"; import cn from "classnames";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.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 { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { import {
@ -14,10 +13,11 @@ import {
} from "@renderer/hooks"; } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; 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 { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal"; import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal"; import { UserSignOutModal } from "./user-signout-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -32,7 +32,13 @@ export function UserContent({
}: ProfileContentProps) { }: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile"); const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails(); const {
userDetails,
profileBackground,
signOut,
updateFriendRequests,
showFriendsModal,
} = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
@ -72,6 +78,10 @@ export function UserContent({
setShowEditProfileModal(true); setShowEditProfileModal(true);
}; };
const handleOnClickFriend = (userId: string) => {
navigate(`/user/${userId}`);
};
const handleConfirmSignout = async () => { const handleConfirmSignout = async () => {
await signOut(); await signOut();
@ -82,6 +92,10 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id; const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe) updateFriendRequests();
}, [isMe]);
const profileContentBoxBackground = useMemo(() => { const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground; if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */ /* TODO: Render background colors for other users */
@ -216,9 +230,11 @@ export function UserContent({
<TelescopeIcon size={24} /> <TelescopeIcon size={24} />
</div> </div>
<h2>{t("no_recent_activity_title")}</h2> <h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}> {isMe && (
{t("no_recent_activity_description")} <p style={{ fontFamily: "Fira Sans" }}>
</p> {t("no_recent_activity_description")}
</p>
)}
</div> </div>
) : ( ) : (
<div <div
@ -259,55 +275,128 @@ export function UserContent({
)} )}
</div> </div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}> <div className={styles.contentSidebar}>
<div <div className={styles.profileGameSection}>
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div <div
style={{ style={{
flex: 1, display: "flex",
backgroundColor: vars.color.border, alignItems: "center",
height: "1px", justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}} }}
/> >
<h3 style={{ fontWeight: "400" }}> <h2>{t("library")}</h2>
{userProfile.libraryGames.length}
</h3> <div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div> </div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div {(isMe ||
style={{ (userProfile.friends && userProfile.friends.length > 0)) && (
display: "grid", <div className={styles.friendsSection}>
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button <button
key={game.objectID} className={styles.friendsSectionHeader}
className={cn(styles.gameListItem, styles.profileContentBox)} onClick={() => showFriendsModal(UserFriendModalTab.FriendsList)}
onClick={() => handleGameClick(game)}
title={game.title}
> >
{game.iconUrl ? ( <h2>{t("friends")}</h2>
<img
className={styles.libraryGameIcon} <div
src={game.iconUrl} style={{
alt={game.title} flex: 1,
/> backgroundColor: vars.color.border,
) : ( height: "1px",
<SteamLogo className={styles.libraryGameIcon} /> }}
)} />
<h3 style={{ fontWeight: "400" }}>
{userProfile.friends.length}
</h3>
</button> </button>
))}
</div> <div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.friends.map((friend) => {
return (
<button
key={friend.id}
className={cn(
styles.profileContentBox,
styles.friendListContainer
)}
onClick={() => handleOnClickFriend(friend.id)}
>
<div className={styles.friendAvatarContainer}>
{friend.profileImageUrl ? (
<img
className={styles.friendProfileIcon}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<p className={styles.friendListDisplayName}>
{friend.displayName}
</p>
</button>
);
})}
{isMe && (
<Button
theme="outline"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend)
}
>
<PlusIcon /> {t("add")}
</Button>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
</> </>

View File

@ -11,6 +11,7 @@ export const wrapper = style({
export const profileContentBox = style({ export const profileContentBox = style({
display: "flex", display: "flex",
cursor: "pointer",
gap: `${SPACING_UNIT * 3}px`, gap: `${SPACING_UNIT * 3}px`,
alignItems: "center", alignItems: "center",
borderRadius: "4px", borderRadius: "4px",
@ -35,6 +36,29 @@ export const profileAvatarContainer = style({
zIndex: 1, 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({ export const profileAvatarEditContainer = style({
width: "128px", width: "128px",
height: "128px", height: "128px",
@ -53,8 +77,6 @@ export const profileAvatarEditContainer = style({
export const profileAvatar = style({ export const profileAvatar = style({
height: "100%", height: "100%",
width: "100%", width: "100%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover", objectFit: "cover",
}); });
@ -86,14 +108,36 @@ export const profileContent = style({
export const profileGameSection = style({ export const profileGameSection = style({
width: "100%", width: "100%",
height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`, 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({ export const contentSidebar = style({
width: "100%", width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": { "@media": {
"(min-width: 768px)": { "(min-width: 768px)": {
width: "100%", width: "100%",
@ -116,12 +160,17 @@ export const libraryGameIcon = style({
borderRadius: "4px", borderRadius: "4px",
}); });
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({ export const feedItem = style({
color: vars.color.body, color: vars.color.body,
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
width: "100%", width: "100%",
overflow: "hidden",
height: "72px", height: "72px",
transition: "all ease 0.2s", transition: "all ease 0.2s",
cursor: "pointer", 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({ export const gameInformation = style({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",

View File

@ -2,18 +2,23 @@ import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch, useToast } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton"; import { UserSkeleton } from "./user-skeleton";
import { UserContent } from "./user-content"; import { UserContent } from "./user-content";
import { SkeletonTheme } from "react-loading-skeleton"; import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css"; import { vars } from "@renderer/theme.css";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export const User = () => { export const User = () => {
const { userId } = useParams(); const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>(); const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getUserProfile = useCallback(() => { const getUserProfile = useCallback(() => {
@ -22,10 +27,11 @@ export const User = () => {
dispatch(setHeaderTitle(userProfile.displayName)); dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile); setUserProfile(userProfile);
} else { } else {
showErrorToast(t("user_not_found"));
navigate(-1); navigate(-1);
} }
}); });
}, [dispatch, userId]); }, [dispatch, userId, t]);
useEffect(() => { useEffect(() => {
getUserProfile(); getUserProfile();

View File

@ -10,6 +10,8 @@ export type GameStatus =
export type GameShop = "steam" | "epic"; export type GameShop = "steam" | "epic";
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface SteamGenre { export interface SteamGenre {
id: string; id: string;
name: string; name: string;
@ -269,14 +271,27 @@ export interface UserDetails {
profileImageUrl: string | null; 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 { export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
username: string;
profileImageUrl: string | null; profileImageUrl: string | null;
totalPlayTimeInSeconds: number; totalPlayTimeInSeconds: number;
libraryGames: UserGame[]; libraryGames: UserGame[];
recentGames: UserGame[]; recentGames: UserGame[];
friends: UserFriend[];
} }
export interface DownloadSource { export interface DownloadSource {

View File

@ -2433,6 +2433,13 @@
modern-ahocorasick "^1.0.0" modern-ahocorasick "^1.0.0"
picocolors "^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": "@vanilla-extract/integration@^7.1.3":
version "7.1.4" version "7.1.4"
resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz" 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" resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz"
integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg== 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": "@vanilla-extract/recipes@^0.5.2":
version "0.5.2" version "0.5.2"
resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz" resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"