Merge branch 'main' into patch-2

This commit is contained in:
Zamitto 2024-08-05 10:42:18 -03:00 committed by GitHub
commit 49ed55abfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 765 additions and 226 deletions

View File

@ -250,6 +250,17 @@
"friend_request_sent": "Friend request sent", "friend_request_sent": "Friend request sent",
"friends": "Friends", "friends": "Friends",
"friends_list": "Friends list", "friends_list": "Friends list",
"user_not_found": "User not found" "user_not_found": "User not found",
"block_user": "Block user",
"add_friend": "Add friend",
"request_sent": "Request sent",
"request_received": "Request received",
"accept_request": "Accept request",
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}"
} }
} }

View File

@ -241,6 +241,15 @@
"successfully_signed_out": "Sesión cerrada exitosamente", "successfully_signed_out": "Sesión cerrada exitosamente",
"sign_out": "Cerrar sesión", "sign_out": "Cerrar sesión",
"playing_for": "Jugando por {{amount}}", "playing_for": "Jugando por {{amount}}",
"sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?" "sign_out_modal_text": "Tu biblioteca se ha vinculado con tu cuenta. Cuando cierres sesión, tú biblioteca ya no será visible y cualquier progreso no se guardará. ¿Continuar con el cierre de sesión?",
"add_friends": "Añadir amigos",
"add": "Añadir",
"friend_code": "Código de amigo",
"see_profile": "Ver perfil",
"sending": "Enviando",
"friend_request_sent": "Solicitud de amistad enviada",
"friends": "Amigos",
"friends_list": "Lista de amigos",
"user_not_found": "Usuario no encontrado"
} }
} }

View File

@ -250,6 +250,17 @@
"add": "Adicionar", "add": "Adicionar",
"sending": "Enviando", "sending": "Enviando",
"friends_list": "Lista de amigos", "friends_list": "Lista de amigos",
"user_not_found": "Usuário não encontrado" "user_not_found": "Usuário não encontrado",
"block_user": "Bloquear",
"add_friend": "Adicionar amigo",
"request_sent": "Pedido enviado",
"request_received": "Pedido recebido",
"accept_request": "Aceitar pedido",
"ignore_request": "Ignorar pedido",
"cancel_request": "Cancelar pedido",
"undo_friendship": "Desfazer amizade",
"request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}"
} }
} }

View File

@ -43,8 +43,12 @@ 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 "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./profile/get-friend-requests"; import "./profile/get-friend-requests";
import "./profile/get-me"; import "./profile/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request"; import "./profile/update-friend-request";
import "./profile/update-profile"; import "./profile/update-profile";
import "./profile/send-friend-request"; import "./profile/send-friend-request";

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const undoFriendship = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.delete(`/profile/friends/${userId}`);
};
registerEvent("undoFriendship", undoFriendship);

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const blockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/user/${userId}/block`);
};
registerEvent("blockUser", blockUser);

View File

@ -0,0 +1,29 @@
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserFriends } from "@types";
export const getUserFriends = async (
userId: string,
take: number,
skip: number
): Promise<UserFriends> => {
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
if (loggedUser?.userId === userId) {
return HydraApi.get(`/profile/friends`, { take, skip });
}
return HydraApi.get(`/user/${userId}/friends`, { take, skip });
};
const getUserFriendsEvent = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
take: number,
skip: number
) => {
return getUserFriends(userId, take, skip);
};
registerEvent("getUserFriends", getUserFriendsEvent);

View File

@ -4,13 +4,19 @@ import { steamGamesWorker } from "@main/workers";
import { UserProfile } from "@types"; import { UserProfile } from "@types";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { getSteamAppAsset } from "@main/helpers"; import { getSteamAppAsset } from "@main/helpers";
import { getUserFriends } from "./get-user-friends";
const getUser = async ( const getUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { try {
const profile = await HydraApi.get(`/user/${userId}`); const [profile, friends] = await Promise.all([
HydraApi.get(`/user/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
]);
const recentGames = await Promise.all( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { profile.recentGames.map(async (game) => {
@ -46,7 +52,13 @@ const getUser = async (
}) })
); );
return { ...profile, libraryGames, recentGames }; return {
...profile,
libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
};
} catch (err) { } catch (err) {
return null; return null;
} }

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const unblockUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
await HydraApi.post(`/user/${userId}/unblock`);
};
registerEvent("unblockUser", unblockUser);

View File

@ -72,7 +72,7 @@ export class HydraApi {
this.instance.interceptors.request.use( this.instance.interceptors.request.use(
(request) => { (request) => {
logger.log(" ---- REQUEST -----"); logger.log(" ---- REQUEST -----");
logger.log(request.method, request.url, request.data); logger.log(request.method, request.url, request.params, request.data);
return request; return request;
}, },
(error) => { (error) => {
@ -196,52 +196,52 @@ export class HydraApi {
throw err; throw err;
}; };
static async get(url: string) { static async get<T = any>(url: string, params?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.get(url, this.getAxiosConfig()) .get<T>(url, { params, ...this.getAxiosConfig() })
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async post(url: string, data?: any) { static async post<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.post(url, data, this.getAxiosConfig()) .post<T>(url, data, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async put(url: string, data?: any) { static async put<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.put(url, data, this.getAxiosConfig()) .put<T>(url, data, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async patch(url: string, data?: any) { static async patch<T = any>(url: string, data?: any) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.patch(url, data, this.getAxiosConfig()) .patch<T>(url, data, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
static async delete(url: string) { static async delete<T = any>(url: string) {
if (!this.isLoggedIn()) throw new UserNotLoggedInError(); if (!this.isLoggedIn()) throw new UserNotLoggedInError();
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.delete(url, this.getAxiosConfig()) .delete<T>(url, this.getAxiosConfig())
.then((response) => response.data) .then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }

View File

@ -135,6 +135,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Profile */ /* Profile */
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId),
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"), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
@ -145,6 +147,10 @@ contextBridge.exposeInMainWorld("electron", {
/* User */ /* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip),
/* Auth */ /* Auth */
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),

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://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;" 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;"
/> />
</head> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -42,11 +42,12 @@ export function App() {
const { const {
isFriendsModalVisible, isFriendsModalVisible,
friendRequetsModalTab, friendRequetsModalTab,
updateFriendRequests, friendModalUserId,
fetchFriendRequests,
hideFriendsModal, hideFriendsModal,
} = useUserDetails(); } = useUserDetails();
const { fetchUserDetails, updateUserDetails, clearUserDetails } = const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails(); useUserDetails();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -104,7 +105,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests(); fetchFriendRequests();
} }
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);
@ -113,7 +114,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests(); fetchFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@ -218,11 +219,14 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
<UserFriendModal {userDetails && (
visible={isFriendsModalVisible} <UserFriendModal
initialTab={friendRequetsModalTab} visible={isFriendsModalVisible}
onClose={hideFriendsModal} initialTab={friendRequetsModalTab}
/> onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main> <main>
<Sidebar /> <Sidebar />

View File

@ -3,10 +3,11 @@ 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 { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css"; import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { FriendRequest } from "@types";
export function SidebarProfile() { export function SidebarProfile() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -16,6 +17,14 @@ export function SidebarProfile() {
const { userDetails, profileBackground, friendRequests, showFriendsModal } = const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails(); useUserDetails();
const [receivedRequests, setReceivedRequests] = useState<FriendRequest[]>([]);
useEffect(() => {
setReceivedRequests(
friendRequests.filter((request) => request.type === "RECEIVED")
);
}, [friendRequests]);
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
const handleButtonClick = () => { const handleButtonClick = () => {
@ -79,15 +88,17 @@ export function SidebarProfile() {
)} )}
</div> </div>
</button> </button>
{userDetails && friendRequests.length > 0 && !gameRunning && ( {userDetails && receivedRequests.length > 0 && !gameRunning && (
<div className={styles.friendRequestContainer}> <div className={styles.friendRequestContainer}>
<button <button
type="button" type="button"
className={styles.friendRequestButton} className={styles.friendRequestButton}
onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)} onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
> >
<PersonAddIcon size={24} /> <PersonAddIcon size={24} />
{friendRequests.length} {receivedRequests.length}
</button> </button>
</div> </div>
)} )}

View File

@ -16,6 +16,7 @@ import type {
UserProfile, UserProfile,
FriendRequest, FriendRequest,
FriendRequestAction, FriendRequestAction,
UserFriends,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -127,9 +128,17 @@ declare global {
/* User */ /* User */
getUser: (userId: string) => Promise<UserProfile | null>; getUser: (userId: string) => Promise<UserProfile | null>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
getUserFriends: (
userId: string,
take: number,
skip: number
) => Promise<UserFriends>;
/* Profile */ /* Profile */
getMe: () => Promise<UserProfile | null>; getMe: () => Promise<UserProfile | null>;
undoFriendship: (userId: string) => Promise<void>;
updateProfile: ( updateProfile: (
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null

View File

@ -8,6 +8,7 @@ export interface UserDetailsState {
friendRequests: FriendRequest[]; friendRequests: FriendRequest[];
isFriendsModalVisible: boolean; isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null; friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
@ -16,6 +17,7 @@ const initialState: UserDetailsState = {
friendRequests: [], friendRequests: [],
isFriendsModalVisible: false, isFriendsModalVisible: false,
friendRequetsModalTab: null, friendRequetsModalTab: null,
friendModalUserId: "",
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@ -33,10 +35,11 @@ export const userDetailsSlice = createSlice({
}, },
setFriendsModalVisible: ( setFriendsModalVisible: (
state, state,
action: PayloadAction<UserFriendModalTab> action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => { ) => {
state.isFriendsModalVisible = true; state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload; state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
}, },
setFriendsModalHidden: (state) => { setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false; state.isFriendsModalVisible = false;

View File

@ -1,6 +1,7 @@
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import Color from "color"; import Color from "color";
import { average } from "color.js";
export const steamUrlBuilder = { export const steamUrlBuilder = {
library: (objectID: string) => library: (objectID: string) =>
@ -45,3 +46,14 @@ export const buildGameDetailsPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) => export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString(); new Color(color).darken(amount).alpha(alpha).toString();
export const profileBackgroundFromProfileImage = async (
profileImageUrl: string
) => {
const output = await average(profileImageUrl, {
amount: 1,
format: "hex",
});
return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
};

View File

@ -1,6 +1,4 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { import {
setProfileBackground, setProfileBackground,
@ -9,7 +7,7 @@ import {
setFriendsModalVisible, setFriendsModalVisible,
setFriendsModalHidden, setFriendsModalHidden,
} from "@renderer/features"; } from "@renderer/features";
import { darkenColor } from "@renderer/helpers"; import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UserDetails } from "@types"; import { FriendRequestAction, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
@ -21,6 +19,7 @@ export function useUserDetails() {
profileBackground, profileBackground,
friendRequests, friendRequests,
isFriendsModalVisible, isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab, friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails); } = useAppSelector((state) => state.userDetails);
@ -42,12 +41,9 @@ export function useUserDetails() {
dispatch(setUserDetails(userDetails)); dispatch(setUserDetails(userDetails));
if (userDetails.profileImageUrl) { if (userDetails.profileImageUrl) {
const output = await average(userDetails.profileImageUrl, { const profileBackground = await profileBackgroundFromProfileImage(
amount: 1, userDetails.profileImageUrl
format: "hex", );
});
const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`;
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem( window.localStorage.setItem(
@ -89,15 +85,19 @@ export function useUserDetails() {
[updateUserDetails] [updateUserDetails]
); );
const updateFriendRequests = useCallback(async () => { const fetchFriendRequests = useCallback(() => {
const friendRequests = await window.electron.getFriendRequests(); return window.electron
dispatch(setFriendRequests(friendRequests)); .getFriendRequests()
.then((friendRequests) => {
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]); }, [dispatch]);
const showFriendsModal = useCallback( const showFriendsModal = useCallback(
(tab: UserFriendModalTab) => { (initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible(tab)); dispatch(setFriendsModalVisible({ initialTab, userId }));
updateFriendRequests(); fetchFriendRequests();
}, },
[dispatch] [dispatch]
); );
@ -110,26 +110,39 @@ export function useUserDetails() {
async (userId: string) => { async (userId: string) => {
return window.electron return window.electron
.sendFriendRequest(userId) .sendFriendRequest(userId)
.then(() => updateFriendRequests()); .then(() => fetchFriendRequests());
}, },
[updateFriendRequests] [fetchFriendRequests]
); );
const updateFriendRequestState = useCallback( const updateFriendRequestState = useCallback(
async (userId: string, action: FriendRequestAction) => { async (userId: string, action: FriendRequestAction) => {
return window.electron return window.electron
.updateFriendRequest(userId, action) .updateFriendRequest(userId, action)
.then(() => updateFriendRequests()); .then(() => fetchFriendRequests());
}, },
[updateFriendRequests] [fetchFriendRequests]
); );
const undoFriendship = (userId: string) => {
return window.electron.undoFriendship(userId);
};
const blockUser = (userId: string) => {
return window.electron.blockUser(userId);
};
const unblockUser = (userId: string) => {
return window.electron.unblockUser(userId);
};
return { return {
userDetails, userDetails,
profileBackground, profileBackground,
friendRequests, friendRequests,
friendRequetsModalTab, friendRequetsModalTab,
isFriendsModalVisible, isFriendsModalVisible,
friendModalUserId,
showFriendsModal, showFriendsModal,
hideFriendsModal, hideFriendsModal,
fetchUserDetails, fetchUserDetails,
@ -138,7 +151,10 @@ export function useUserDetails() {
updateUserDetails, updateUserDetails,
patchUser, patchUser,
sendFriendRequest, sendFriendRequest,
updateFriendRequests, fetchFriendRequests,
updateFriendRequestState, updateFriendRequestState,
blockUser,
unblockUser,
undoFriendship,
}; };
} }

View File

@ -0,0 +1,136 @@
import {
CheckCircleIcon,
PersonIcon,
XCircleIcon,
} from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next";
export type UserFriendItemProps = {
userId: string;
profileImageUrl: string | null;
displayName: string;
onClickItem: (userId: string) => void;
} & (
| { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void }
| {
type: "SENT" | "RECEIVED";
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
}
| { type: null }
);
export const UserFriendItem = (props: UserFriendItemProps) => {
const { t } = useTranslation("user_profile");
const { userId, profileImageUrl, displayName, type, onClickItem } = props;
const getRequestDescription = () => {
if (type === "ACCEPTED" || type === null) return null;
return (
<small>
{type == "SENT" ? t("request_sent") : t("request_received")}
</small>
);
};
const getRequestActions = () => {
if (type === null) return null;
if (type === "SENT") {
return (
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickCancelRequest(userId)}
title={t("cancel_request")}
>
<XCircleIcon size={28} />
</button>
);
}
if (type === "RECEIVED") {
return (
<>
<button
className={styles.acceptRequestButton}
onClick={() => props.onClickAcceptRequest(userId)}
title={t("accept_request")}
>
<CheckCircleIcon size={28} />
</button>
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickRefuseRequest(userId)}
title={t("ignore_request")}
>
<XCircleIcon size={28} />
</button>
</>
);
}
if (type === "ACCEPTED") {
return (
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickUndoFriendship(userId)}
title={t("undo_friendship")}
>
<XCircleIcon size={28} />
</button>
);
}
return null;
};
return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
<button
type="button"
className={styles.friendListButton}
onClick={() => onClickItem(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>
{getRequestDescription()}
</div>
</button>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{getRequestActions()}
</div>
</div>
);
};

View File

@ -4,7 +4,7 @@ import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { UserFriendRequest } from "./user-friend-request"; import { UserFriendItem } from "./user-friend-item";
export interface UserFriendModalAddFriendProps { export interface UserFriendModalAddFriendProps {
closeModal: () => void; closeModal: () => void;
@ -23,7 +23,7 @@ export const UserFriendModalAddFriend = ({
const { sendFriendRequest, updateFriendRequestState, friendRequests } = const { sendFriendRequest, updateFriendRequestState, friendRequests } =
useUserDetails(); useUserDetails();
const { showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
const handleClickAddFriend = () => { const handleClickAddFriend = () => {
setIsAddingFriend(true); setIsAddingFriend(true);
@ -56,21 +56,25 @@ export const UserFriendModalAddFriend = ({
navigate(`/user/${friendCode}`); navigate(`/user/${friendCode}`);
}; };
const handleClickCancelFriendRequest = (userId: string) => { const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => { updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast("Falha ao cancelar convite"); showErrorToast(t("try_again"));
}); });
}; };
const handleClickAcceptFriendRequest = (userId: string) => { const handleAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED").catch(() => { updateFriendRequestState(userId, "ACCEPTED")
showErrorToast("Falha ao aceitar convite"); .then(() => {
}); showSuccessToast(t("request_accepted"));
})
.catch(() => {
showErrorToast(t("try_again"));
});
}; };
const handleClickRefuseFriendRequest = (userId: string) => { const handleRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => { updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast("Falha ao recusar convite"); showErrorToast(t("try_again"));
}); });
}; };
@ -121,16 +125,16 @@ export const UserFriendModalAddFriend = ({
<h3>Pendentes</h3> <h3>Pendentes</h3>
{friendRequests.map((request) => { {friendRequests.map((request) => {
return ( return (
<UserFriendRequest <UserFriendItem
key={request.id} key={request.id}
displayName={request.displayName} displayName={request.displayName}
isRequestSent={request.type === "SENT"} type={request.type}
profileImageUrl={request.profileImageUrl} profileImageUrl={request.profileImageUrl}
userId={request.id} userId={request.id}
onClickAcceptRequest={handleClickAcceptFriendRequest} onClickAcceptRequest={handleAcceptFriendRequest}
onClickCancelRequest={handleClickCancelFriendRequest} onClickCancelRequest={handleCancelFriendRequest}
onClickRefuseRequest={handleClickRefuseFriendRequest} onClickRefuseRequest={handleRefuseFriendRequest}
onClickRequest={handleClickRequest} onClickItem={handleClickRequest}
/> />
); );
})} })}

View File

@ -0,0 +1,95 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import { UserFriend } from "@types";
import { useEffect, useState } from "react";
import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserFriendModalListProps {
userId: string;
closeModal: () => void;
}
const pageSize = 12;
export const UserFriendModalList = ({
userId,
closeModal,
}: UserFriendModalListProps) => {
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [maxPage, setMaxPage] = useState(0);
const [friends, setFriends] = useState<UserFriend[]>([]);
const { userDetails, undoFriendship } = useUserDetails();
const isMe = userDetails?.id == userId;
const loadNextPage = () => {
if (page > maxPage) return;
window.electron
.getUserFriends(userId, pageSize, page * pageSize)
.then((newPage) => {
if (page === 0) {
setMaxPage(newPage.totalFriends / pageSize);
}
setFriends([...friends, ...newPage.friends]);
setPage(page + 1);
})
.catch(() => {});
};
const reloadList = () => {
setPage(0);
setMaxPage(0);
setFriends([]);
loadNextPage();
};
useEffect(() => {
reloadList();
}, [userId]);
const handleClickFriend = (userId: string) => {
closeModal();
navigate(`/user/${userId}`);
};
const handleUndoFriendship = (userId: string) => {
undoFriendship(userId)
.then(() => {
reloadList();
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
{friends.map((friend) => {
return (
<UserFriendItem
userId={friend.id}
displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickItem={handleClickFriend}
onClickUndoFriendship={handleUndoFriendship}
type={isMe ? "ACCEPTED" : null}
key={friend.id}
/>
);
})}
</div>
);
};

View File

@ -3,23 +3,27 @@ import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend"; import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
import { useUserDetails } from "@renderer/hooks";
import { UserFriendModalList } from "./user-friend-modal-list";
export enum UserFriendModalTab { export enum UserFriendModalTab {
FriendsList, FriendsList,
AddFriend, AddFriend,
} }
export interface UserAddFriendsModalProps { export interface UserFriendsModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
initialTab: UserFriendModalTab | null; initialTab: UserFriendModalTab | null;
userId: string;
} }
export const UserFriendModal = ({ export const UserFriendModal = ({
visible, visible,
onClose, onClose,
initialTab, initialTab,
}: UserAddFriendsModalProps) => { userId,
}: UserFriendsModalProps) => {
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const tabs = [t("friends_list"), t("add_friends")]; const tabs = [t("friends_list"), t("add_friends")];
@ -28,6 +32,9 @@ export const UserFriendModal = ({
initialTab || UserFriendModalTab.FriendsList initialTab || UserFriendModalTab.FriendsList
); );
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
useEffect(() => { useEffect(() => {
if (initialTab != null) { if (initialTab != null) {
setCurrentTab(initialTab); setCurrentTab(initialTab);
@ -36,7 +43,7 @@ export const UserFriendModal = ({
const renderTab = () => { const renderTab = () => {
if (currentTab == UserFriendModalTab.FriendsList) { if (currentTab == UserFriendModalTab.FriendsList) {
return <></>; return <UserFriendModalList userId={userId} closeModal={onClose} />;
} }
if (currentTab == UserFriendModalTab.AddFriend) { if (currentTab == UserFriendModalTab.AddFriend) {
@ -56,20 +63,21 @@ export const UserFriendModal = ({
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
}} }}
> >
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}> {isMe && (
{tabs.map((tab, index) => { <section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
return ( {tabs.map((tab, index) => {
<Button return (
key={tab} <Button
theme={index === currentTab ? "primary" : "outline"} key={tab}
onClick={() => setCurrentTab(index)} theme={index === currentTab ? "primary" : "outline"}
> onClick={() => setCurrentTab(index)}
{tab} >
</Button> {tab}
); </Button>
})} );
</section> })}
<h2>{tabs[currentTab]}</h2> </section>
)}
{renderTab()} {renderTab()}
</div> </div>
</Modal> </Modal>

View File

@ -1,97 +0,0 @@
import {
CheckCircleIcon,
PersonIcon,
XCircleIcon,
} from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface UserFriendRequestProps {
userId: string;
profileImageUrl: string | null;
displayName: string;
isRequestSent: boolean;
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
onClickRequest: (userId: string) => void;
}
export const UserFriendRequest = ({
userId,
profileImageUrl,
displayName,
isRequestSent,
onClickCancelRequest,
onClickAcceptRequest,
onClickRefuseRequest,
onClickRequest,
}: UserFriendRequestProps) => {
return (
<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

@ -0,0 +1,44 @@
import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export interface UserBlockModalProps {
visible: boolean;
displayName: string;
onConfirm: () => void;
onClose: () => void;
}
export const UserBlockModal = ({
visible,
displayName,
onConfirm,
onClose,
}: UserBlockModalProps) => {
const { t } = useTranslation("user_profile");
return (
<>
<Modal
visible={visible}
title={t("sign_out_modal_title")}
onClose={onClose}
>
<div className={styles.signOutModalContent}>
<p style={{ fontFamily: "Fira Sans" }}>
{t("user_block_modal_text", { displayName })}
</p>
<div className={styles.signOutModalButtonsContainer}>
<Button onClick={onConfirm} theme="danger">
{t("block_user")}
</Button>
<Button onClick={onClose} theme="primary">
{t("cancel")}
</Button>
</div>
</div>
</Modal>
</>
);
};

View File

@ -1,4 +1,4 @@
import { UserGame, UserProfile } from "@types"; import { FriendRequestAction, 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";
@ -12,12 +12,23 @@ import {
useUserDetails, useUserDetails,
} 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 {
import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react"; buildGameDetailsPath,
profileBackgroundFromProfileImage,
steamUrlBuilder,
} from "@renderer/helpers";
import {
CheckCircleIcon,
PersonIcon,
PlusIcon,
TelescopeIcon,
XCircleIcon,
} 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-sign-out-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
import { UserBlockModal } from "./user-block-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -26,6 +37,8 @@ export interface ProfileContentProps {
updateUserProfile: () => Promise<void>; updateUserProfile: () => Promise<void>;
} }
type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND");
export function UserContent({ export function UserContent({
userProfile, userProfile,
updateUserProfile, updateUserProfile,
@ -36,13 +49,20 @@ export function UserContent({
userDetails, userDetails,
profileBackground, profileBackground,
signOut, signOut,
updateFriendRequests, sendFriendRequest,
fetchFriendRequests,
showFriendsModal, showFriendsModal,
updateFriendRequestState,
undoFriendship,
blockUser,
} = useUserDetails(); } = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>();
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -93,14 +113,141 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id; const isMe = userDetails?.id == userProfile.id;
useEffect(() => { useEffect(() => {
if (isMe) updateFriendRequests(); if (isMe) fetchFriendRequests();
}, [isMe]); }, [isMe]);
const profileContentBoxBackground = useMemo(() => { useEffect(() => {
if (profileBackground) return profileBackground; if (isMe && profileBackground) {
/* TODO: Render background colors for other users */ setProfileContentBoxBackground(profileBackground);
return undefined; }
}, [profileBackground]);
if (userProfile.profileImageUrl) {
profileBackgroundFromProfileImage(userProfile.profileImageUrl).then(
(profileBackground) => {
setProfileContentBoxBackground(profileBackground);
}
);
}
}, [profileBackground, isMe]);
const handleFriendAction = (userId: string, action: FriendAction) => {
try {
if (action === "UNDO") {
undoFriendship(userId).then(updateUserProfile);
return;
}
if (action === "BLOCK") {
blockUser(userId).then(() => {
setShowUserBlockModal(false);
showSuccessToast(t("user_blocked_successfully"));
navigate(-1);
});
return;
}
if (action === "SEND") {
sendFriendRequest(userProfile.id).then(updateUserProfile);
return;
}
updateFriendRequestState(userId, action).then(updateUserProfile);
} catch (err) {
showErrorToast(t("try_again"));
}
};
const showFriends = isMe || userProfile.totalFriends > 0;
const getProfileActions = () => {
if (isMe) {
return (
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button theme="danger" onClick={() => setShowSignOutModal(true)}>
{t("sign_out")}
</Button>
</>
);
}
if (userProfile.relation == null) {
return (
<>
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")}
>
{t("add_friend")}
</Button>
<Button theme="danger" onClick={() => setShowUserBlockModal(true)}>
{t("block_user")}
</Button>
</>
);
}
if (userProfile.relation.status === "ACCEPTED") {
const userId =
userProfile.relation.AId === userDetails?.id
? userProfile.relation.BId
: userProfile.relation.AId;
return (
<>
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() => handleFriendAction(userId, "UNDO")}
>
<XCircleIcon size={28} /> {t("undo_friendship")}
</Button>
</>
);
}
if (userProfile.relation.BId === userProfile.id) {
return (
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL")
}
>
<XCircleIcon size={28} /> {t("cancel_request")}
</Button>
);
}
return (
<>
<Button
theme="outline"
className={styles.acceptRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
}
>
<CheckCircleIcon size={28} /> {t("accept_request")}
</Button>
<Button
theme="outline"
className={styles.cancelRequestButton}
onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED")
}
>
<XCircleIcon size={28} /> {t("ignore_request")}
</Button>
</>
);
};
return ( return (
<> <>
@ -117,6 +264,13 @@ export function UserContent({
onConfirm={handleConfirmSignout} onConfirm={handleConfirmSignout}
/> />
<UserBlockModal
visible={showUserBlockModal}
onClose={() => setShowUserBlockModal(false)}
onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")}
displayName={userProfile.displayName}
/>
<section <section
className={styles.profileContentBox} className={styles.profileContentBox}
style={{ style={{
@ -187,37 +341,24 @@ export function UserContent({
)} )}
</div> </div>
{isMe && ( <div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
}}
>
<div <div
style={{ style={{
flex: 1,
display: "flex", display: "flex",
justifyContent: "end", flexDirection: "column",
zIndex: 1, gap: `${SPACING_UNIT}px`,
}} }}
> >
<div {getProfileActions()}
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<>
<Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")}
</Button>
<Button
theme="danger"
onClick={() => setShowSignOutModal(true)}
>
{t("sign_out")}
</Button>
</>
</div>
</div> </div>
)} </div>
</section> </section>
<div className={styles.profileContent}> <div className={styles.profileContent}>
@ -327,12 +468,16 @@ export function UserContent({
</div> </div>
</div> </div>
{(isMe || {showFriends && (
(userProfile.friends && userProfile.friends.length > 0)) && (
<div className={styles.friendsSection}> <div className={styles.friendsSection}>
<button <button
className={styles.friendsSectionHeader} className={styles.friendsSectionHeader}
onClick={() => showFriendsModal(UserFriendModalTab.FriendsList)} onClick={() =>
showFriendsModal(
UserFriendModalTab.FriendsList,
userProfile.id
)
}
> >
<h2>{t("friends")}</h2> <h2>{t("friends")}</h2>
@ -344,7 +489,7 @@ export function UserContent({
}} }}
/> />
<h3 style={{ fontWeight: "400" }}> <h3 style={{ fontWeight: "400" }}>
{userProfile.friends.length} {userProfile.totalFriends}
</h3> </h3>
</button> </button>
@ -388,7 +533,10 @@ export function UserContent({
<Button <Button
theme="outline" theme="outline"
onClick={() => onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend) showFriendsModal(
UserFriendModalTab.AddFriend,
userProfile.id
)
} }
> >
<PlusIcon /> {t("add")} <PlusIcon /> {t("add")}

View File

@ -2,7 +2,7 @@ import { Button, Modal } from "@renderer/components";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps { export interface UserSignOutModalProps {
visible: boolean; visible: boolean;
onConfirm: () => void; onConfirm: () => void;
onClose: () => void; onClose: () => void;
@ -12,7 +12,7 @@ export const UserSignOutModal = ({
visible, visible,
onConfirm, onConfirm,
onClose, onClose,
}: UserEditProfileModalProps) => { }: UserSignOutModalProps) => {
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
return ( return (

View File

@ -78,6 +78,8 @@ export const profileAvatar = style({
height: "100%", height: "100%",
width: "100%", width: "100%",
objectFit: "cover", objectFit: "cover",
borderRadius: "50%",
overflow: "hidden",
}); });
export const profileAvatarEditOverlay = style({ export const profileAvatarEditOverlay = style({
@ -277,3 +279,16 @@ export const profileBackground = style({
top: "0", top: "0",
borderRadius: "4px", borderRadius: "4px",
}); });
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
":hover": {
color: vars.color.danger,
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.success,
});

View File

@ -277,6 +277,11 @@ export interface UserFriend {
profileImageUrl: string | null; profileImageUrl: string | null;
} }
export interface UserFriends {
totalFriends: number;
friends: UserFriend[];
}
export interface FriendRequest { export interface FriendRequest {
id: string; id: string;
displayName: string; displayName: string;
@ -284,14 +289,25 @@ export interface FriendRequest {
type: "SENT" | "RECEIVED"; type: "SENT" | "RECEIVED";
} }
export interface UserRelation {
AId: string;
BId: string;
status: "ACCEPTED" | "PENDING";
createdAt: string;
updatedAt: string;
}
export interface UserProfile { export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
profileImageUrl: string | null; profileImageUrl: string | null;
profileVisibility: "PUBLIC" | "PRIVATE" | "FRIENDS";
totalPlayTimeInSeconds: number; totalPlayTimeInSeconds: number;
libraryGames: UserGame[]; libraryGames: UserGame[];
recentGames: UserGame[]; recentGames: UserGame[];
friends: UserFriend[]; friends: UserFriend[];
totalFriends: number;
relation: UserRelation | null;
} }
export interface DownloadSource { export interface DownloadSource {