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",
"friends": "Friends",
"friends_list": "Friends list",
"user_not_found": "User not found"
"user_not_found": "User not found",
"block_user": "Block user",
"add_friend": "Add friend",
"request_sent": "Request sent",
"request_received": "Request received",
"accept_request": "Accept request",
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}"
}
}

View File

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

View File

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

View File

@ -43,8 +43,12 @@ import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
import "./user/get-user";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";
import "./profile/update-friend-request";
import "./profile/update-profile";
import "./profile/send-friend-request";

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

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

View File

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

View File

@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
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>
<body style="background-color: #1c1c1c">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UserFriendRequest } from "./user-friend-request";
import { UserFriendItem } from "./user-friend-item";
export interface UserFriendModalAddFriendProps {
closeModal: () => void;
@ -23,7 +23,7 @@ export const UserFriendModalAddFriend = ({
const { sendFriendRequest, updateFriendRequestState, friendRequests } =
useUserDetails();
const { showErrorToast } = useToast();
const { showSuccessToast, showErrorToast } = useToast();
const handleClickAddFriend = () => {
setIsAddingFriend(true);
@ -56,21 +56,25 @@ export const UserFriendModalAddFriend = ({
navigate(`/user/${friendCode}`);
};
const handleClickCancelFriendRequest = (userId: string) => {
const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast("Falha ao cancelar convite");
showErrorToast(t("try_again"));
});
};
const handleClickAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED").catch(() => {
showErrorToast("Falha ao aceitar convite");
});
const handleAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED")
.then(() => {
showSuccessToast(t("request_accepted"));
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
const handleClickRefuseFriendRequest = (userId: string) => {
const handleRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast("Falha ao recusar convite");
showErrorToast(t("try_again"));
});
};
@ -121,16 +125,16 @@ export const UserFriendModalAddFriend = ({
<h3>Pendentes</h3>
{friendRequests.map((request) => {
return (
<UserFriendRequest
<UserFriendItem
key={request.id}
displayName={request.displayName}
isRequestSent={request.type === "SENT"}
type={request.type}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleClickAcceptFriendRequest}
onClickCancelRequest={handleClickCancelFriendRequest}
onClickRefuseRequest={handleClickRefuseFriendRequest}
onClickRequest={handleClickRequest}
onClickAcceptRequest={handleAcceptFriendRequest}
onClickCancelRequest={handleCancelFriendRequest}
onClickRefuseRequest={handleRefuseFriendRequest}
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 { useTranslation } from "react-i18next";
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
import { useUserDetails } from "@renderer/hooks";
import { UserFriendModalList } from "./user-friend-modal-list";
export enum UserFriendModalTab {
FriendsList,
AddFriend,
}
export interface UserAddFriendsModalProps {
export interface UserFriendsModalProps {
visible: boolean;
onClose: () => void;
initialTab: UserFriendModalTab | null;
userId: string;
}
export const UserFriendModal = ({
visible,
onClose,
initialTab,
}: UserAddFriendsModalProps) => {
userId,
}: UserFriendsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("friends_list"), t("add_friends")];
@ -28,6 +32,9 @@ export const UserFriendModal = ({
initialTab || UserFriendModalTab.FriendsList
);
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
useEffect(() => {
if (initialTab != null) {
setCurrentTab(initialTab);
@ -36,7 +43,7 @@ export const UserFriendModal = ({
const renderTab = () => {
if (currentTab == UserFriendModalTab.FriendsList) {
return <></>;
return <UserFriendModalList userId={userId} closeModal={onClose} />;
}
if (currentTab == UserFriendModalTab.AddFriend) {
@ -56,20 +63,21 @@ export const UserFriendModal = ({
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>
{isMe && (
<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>
)}
{renderTab()}
</div>
</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 * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
@ -12,12 +12,23 @@ import {
useUserDetails,
} from "@renderer/hooks";
import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react";
import {
buildGameDetailsPath,
profileBackgroundFromProfileImage,
steamUrlBuilder,
} from "@renderer/helpers";
import {
CheckCircleIcon,
PersonIcon,
PlusIcon,
TelescopeIcon,
XCircleIcon,
} from "@primer/octicons-react";
import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal";
import { UserSignOutModal } from "./user-sign-out-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
import { UserBlockModal } from "./user-block-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -26,6 +37,8 @@ export interface ProfileContentProps {
updateUserProfile: () => Promise<void>;
}
type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND");
export function UserContent({
userProfile,
updateUserProfile,
@ -36,13 +49,20 @@ export function UserContent({
userDetails,
profileBackground,
signOut,
updateFriendRequests,
sendFriendRequest,
fetchFriendRequests,
showFriendsModal,
updateFriendRequestState,
undoFriendship,
blockUser,
} = useUserDetails();
const { showSuccessToast } = useToast();
const { showSuccessToast, showErrorToast } = useToast();
const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>();
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false);
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -93,14 +113,141 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe) updateFriendRequests();
if (isMe) fetchFriendRequests();
}, [isMe]);
const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */
return undefined;
}, [profileBackground]);
useEffect(() => {
if (isMe && profileBackground) {
setProfileContentBoxBackground(profileBackground);
}
if (userProfile.profileImageUrl) {
profileBackgroundFromProfileImage(userProfile.profileImageUrl).then(
(profileBackground) => {
setProfileContentBoxBackground(profileBackground);
}
);
}
}, [profileBackground, isMe]);
const handleFriendAction = (userId: string, action: FriendAction) => {
try {
if (action === "UNDO") {
undoFriendship(userId).then(updateUserProfile);
return;
}
if (action === "BLOCK") {
blockUser(userId).then(() => {
setShowUserBlockModal(false);
showSuccessToast(t("user_blocked_successfully"));
navigate(-1);
});
return;
}
if (action === "SEND") {
sendFriendRequest(userProfile.id).then(updateUserProfile);
return;
}
updateFriendRequestState(userId, action).then(updateUserProfile);
} catch (err) {
showErrorToast(t("try_again"));
}
};
const showFriends = isMe || userProfile.totalFriends > 0;
const getProfileActions = () => {
if (isMe) {
return (
<>
<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 (
<>
@ -117,6 +264,13 @@ export function UserContent({
onConfirm={handleConfirmSignout}
/>
<UserBlockModal
visible={showUserBlockModal}
onClose={() => setShowUserBlockModal(false)}
onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")}
displayName={userProfile.displayName}
/>
<section
className={styles.profileContentBox}
style={{
@ -187,37 +341,24 @@ export function UserContent({
)}
</div>
{isMe && (
<div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
}}
>
<div
style={{
flex: 1,
display: "flex",
justifyContent: "end",
zIndex: 1,
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
<div
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>
{getProfileActions()}
</div>
)}
</div>
</section>
<div className={styles.profileContent}>
@ -327,12 +468,16 @@ export function UserContent({
</div>
</div>
{(isMe ||
(userProfile.friends && userProfile.friends.length > 0)) && (
{showFriends && (
<div className={styles.friendsSection}>
<button
className={styles.friendsSectionHeader}
onClick={() => showFriendsModal(UserFriendModalTab.FriendsList)}
onClick={() =>
showFriendsModal(
UserFriendModalTab.FriendsList,
userProfile.id
)
}
>
<h2>{t("friends")}</h2>
@ -344,7 +489,7 @@ export function UserContent({
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.friends.length}
{userProfile.totalFriends}
</h3>
</button>
@ -388,7 +533,10 @@ export function UserContent({
<Button
theme="outline"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend)
showFriendsModal(
UserFriendModalTab.AddFriend,
userProfile.id
)
}
>
<PlusIcon /> {t("add")}

View File

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

View File

@ -78,6 +78,8 @@ export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
borderRadius: "50%",
overflow: "hidden",
});
export const profileAvatarEditOverlay = style({
@ -277,3 +279,16 @@ export const profileBackground = style({
top: "0",
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;
}
export interface UserFriends {
totalFriends: number;
friends: UserFriend[];
}
export interface FriendRequest {
id: string;
displayName: string;
@ -284,14 +289,25 @@ export interface FriendRequest {
type: "SENT" | "RECEIVED";
}
export interface UserRelation {
AId: string;
BId: string;
status: "ACCEPTED" | "PENDING";
createdAt: string;
updatedAt: string;
}
export interface UserProfile {
id: string;
displayName: string;
profileImageUrl: string | null;
profileVisibility: "PUBLIC" | "PRIVATE" | "FRIENDS";
totalPlayTimeInSeconds: number;
libraryGames: UserGame[];
recentGames: UserGame[];
friends: UserFriend[];
totalFriends: number;
relation: UserRelation | null;
}
export interface DownloadSource {