Merge branch 'fix/http-downloads-duplicate' of github.com:hydralauncher/hydra into fix/http-downloads-duplicate

This commit is contained in:
Chubby Granny Chaser 2024-08-17 21:54:19 +01:00
commit c218070463
No known key found for this signature in database
24 changed files with 803 additions and 418 deletions

View File

@ -261,6 +261,18 @@
"undo_friendship": "Undo friendship", "undo_friendship": "Undo friendship",
"request_accepted": "Request accepted", "request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully", "user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}" "user_block_modal_text": "This will block {{displayName}}",
"settings": "Settings",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
"privacy": "Privacy",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You still don't have added friends",
"pending": "Pending",
"no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users",
"friend_code_copied": "Friend code copied"
} }
} }

View File

@ -261,6 +261,18 @@
"undo_friendship": "Desfazer amizade", "undo_friendship": "Desfazer amizade",
"request_accepted": "Pedido de amizade aceito", "request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso", "user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}" "user_block_modal_text": "Bloquear {{displayName}}",
"settings": "Configurações",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados",
"pending": "Pendentes",
"no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado",
"friend_code_copied": "Código de amigo copiado"
} }
} }

View File

@ -43,6 +43,7 @@ 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/get-user-blocks";
import "./user/block-user"; import "./user/block-user";
import "./user/unblock-user"; import "./user/unblock-user";
import "./user/get-user-friends"; import "./user/get-user-friends";
@ -52,11 +53,9 @@ 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";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle( ipcMain.handle("isPortableVersion", () => isPortableVersion());
"isPortableVersion",
() => process.env.PORTABLE_EXECUTABLE_FILE != null
);
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath); ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View File

@ -4,33 +4,22 @@ import axios from "axios";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileTypeFromFile } from "file-type"; import { fileTypeFromFile } from "file-type";
import { UserProfile } from "@types"; import { UpdateProfileProps, UserProfile } from "@types";
const patchUserProfile = async ( const patchUserProfile = async (updateProfile: UpdateProfileProps) => {
displayName: string, return HydraApi.patch("/profile", updateProfile);
profileImageUrl?: string
) => {
if (profileImageUrl) {
return HydraApi.patch("/profile", {
displayName,
profileImageUrl,
});
} else {
return HydraApi.patch("/profile", {
displayName,
});
}
}; };
const updateProfile = async ( const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
displayName: string, updateProfile: UpdateProfileProps
newProfileImagePath: string | null
): Promise<UserProfile> => { ): Promise<UserProfile> => {
if (!newProfileImagePath) { if (!updateProfile.profileImageUrl) {
return patchUserProfile(displayName); return patchUserProfile(updateProfile);
} }
const newProfileImagePath = updateProfile.profileImageUrl;
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
const fileBuffer = fs.readFileSync(newProfileImagePath); const fileBuffer = fs.readFileSync(newProfileImagePath);
const fileSizeInBytes = stats.size; const fileSizeInBytes = stats.size;
@ -53,7 +42,7 @@ const updateProfile = async (
}) })
.catch(() => undefined); .catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl); return patchUserProfile({ ...updateProfile, profileImageUrl });
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View File

@ -0,0 +1,13 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserBlocks } from "@types";
export const getUserBlocks = async (
_event: Electron.IpcMainInvokeEvent,
take: number,
skip: number
): Promise<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip });
};
registerEvent("getUserBlocks", getUserBlocks);

View File

@ -57,4 +57,7 @@ export const requestWebPage = async (url: string) => {
.then((response) => response.data); .then((response) => response.data);
}; };
export const isPortableVersion = () =>
process.env.PORTABLE_EXECUTABLE_FILE != null;
export * from "./download-source"; export * from "./download-source";

View File

@ -10,6 +10,7 @@ import type {
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
FriendRequestAction, FriendRequestAction,
UpdateProfileProps,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@ -137,8 +138,8 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
undoFriendship: (userId: string) => undoFriendship: (userId: string) =>
ipcRenderer.invoke("undoFriendship", userId), ipcRenderer.invoke("undoFriendship", userId),
updateProfile: (displayName: string, newProfileImagePath: string | null) => updateProfile: (updateProfile: UpdateProfileProps) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), ipcRenderer.invoke("updateProfile", updateProfile),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) => updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action), ipcRenderer.invoke("updateFriendRequest", userId, action),
@ -151,6 +152,8 @@ contextBridge.exposeInMainWorld("electron", {
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
getUserFriends: (userId: string, take: number, skip: number) => getUserFriends: (userId: string, take: number, skip: number) =>
ipcRenderer.invoke("getUserFriends", userId, take, skip), ipcRenderer.invoke("getUserFriends", userId, take, skip),
getUserBlocks: (take: number, skip: number) =>
ipcRenderer.invoke("getUserBlocks", take, skip),
/* Auth */ /* Auth */
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -108,7 +108,7 @@ export function App() {
fetchFriendRequests(); fetchFriendRequests();
} }
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, fetchFriendRequests, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => { const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
@ -118,7 +118,13 @@ export function App() {
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); }, [
fetchUserDetails,
fetchFriendRequests,
t,
showSuccessToast,
updateUserDetails,
]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {

View File

@ -17,6 +17,7 @@ import type {
FriendRequest, FriendRequest,
FriendRequestAction, FriendRequestAction,
UserFriends, UserFriends,
UserBlocks,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -135,14 +136,12 @@ declare global {
take: number, take: number,
skip: number skip: number
) => Promise<UserFriends>; ) => Promise<UserFriends>;
getUserBlocks: (take: number, skip: number) => Promise<UserBlocks>;
/* Profile */ /* Profile */
getMe: () => Promise<UserProfile | null>; getMe: () => Promise<UserProfile | null>;
undoFriendship: (userId: string) => Promise<void>; undoFriendship: (userId: string) => Promise<void>;
updateProfile: ( updateProfile: (updateProfile: UpdateProfileProps) => Promise<UserProfile>;
displayName: string,
newProfileImagePath: string | null
) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>; getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: ( updateFriendRequest: (
userId: string, userId: string,

View File

@ -8,8 +8,9 @@ import {
setFriendsModalHidden, setFriendsModalHidden,
} from "@renderer/features"; } from "@renderer/features";
import { profileBackgroundFromProfileImage } from "@renderer/helpers"; import { profileBackgroundFromProfileImage } from "@renderer/helpers";
import { FriendRequestAction, UserDetails } from "@types"; import { FriendRequestAction, UpdateProfileProps, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import { logger } from "@renderer/logger";
export function useUserDetails() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -43,7 +44,10 @@ export function useUserDetails() {
if (userDetails.profileImageUrl) { if (userDetails.profileImageUrl) {
const profileBackground = await profileBackgroundFromProfileImage( const profileBackground = await profileBackgroundFromProfileImage(
userDetails.profileImageUrl userDetails.profileImageUrl
); ).catch((err) => {
logger.error("profileBackgroundFromProfileImage", err);
return `#151515B3`;
});
dispatch(setProfileBackground(profileBackground)); dispatch(setProfileBackground(profileBackground));
window.localStorage.setItem( window.localStorage.setItem(
@ -74,12 +78,8 @@ export function useUserDetails() {
}, [clearUserDetails]); }, [clearUserDetails]);
const patchUser = useCallback( const patchUser = useCallback(
async (displayName: string, imageProfileUrl: string | null) => { async (props: UpdateProfileProps) => {
const response = await window.electron.updateProfile( const response = await window.electron.updateProfile(props);
displayName,
imageProfileUrl
);
return updateUserDetails(response); return updateUserDetails(response);
}, },
[updateUserDetails] [updateUserDetails]
@ -99,7 +99,7 @@ export function useUserDetails() {
dispatch(setFriendsModalVisible({ initialTab, userId })); dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests(); fetchFriendRequests();
}, },
[dispatch] [dispatch, fetchFriendRequests]
); );
const hideFriendsModal = useCallback(() => { const hideFriendsModal = useCallback(() => {

View File

@ -4,7 +4,6 @@ import {
XCircleIcon, XCircleIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css"; import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -12,21 +11,26 @@ export type UserFriendItemProps = {
userId: string; userId: string;
profileImageUrl: string | null; profileImageUrl: string | null;
displayName: string; displayName: string;
onClickItem: (userId: string) => void;
} & ( } & (
| { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void } | {
type: "ACCEPTED";
onClickUndoFriendship: (userId: string) => void;
onClickItem: (userId: string) => void;
}
| { type: "BLOCKED"; onClickUnblock: (userId: string) => void }
| { | {
type: "SENT" | "RECEIVED"; type: "SENT" | "RECEIVED";
onClickCancelRequest: (userId: string) => void; onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void; onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void; onClickRefuseRequest: (userId: string) => void;
onClickItem: (userId: string) => void;
} }
| { type: null } | { type: null; onClickItem: (userId: string) => void }
); );
export const UserFriendItem = (props: UserFriendItemProps) => { export const UserFriendItem = (props: UserFriendItemProps) => {
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { userId, profileImageUrl, displayName, type, onClickItem } = props; const { userId, profileImageUrl, displayName, type } = props;
const getRequestDescription = () => { const getRequestDescription = () => {
if (type === "ACCEPTED" || type === null) return null; if (type === "ACCEPTED" || type === null) return null;
@ -86,15 +90,69 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
); );
} }
if (type === "BLOCKED") {
return (
<button
className={styles.cancelRequestButton}
onClick={() => props.onClickUnblock(userId)}
title={t("unblock")}
>
<XCircleIcon size={28} />
</button>
);
}
return null; return null;
}; };
if (type === "BLOCKED") {
return ( return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}> <div className={styles.friendListContainer}>
<div className={styles.friendListButton} style={{ cursor: "inherit" }}>
<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>
</div>
</div>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{getRequestActions()}
</div>
</div>
);
}
return (
<div className={styles.friendListContainer}>
<button <button
type="button" type="button"
className={styles.friendListButton} className={styles.friendListButton}
onClick={() => onClickItem(userId)} onClick={() => props.onClickItem(userId)}
> >
<div className={styles.friendAvatarContainer}> <div className={styles.friendAvatarContainer}>
{profileImageUrl ? ( {profileImageUrl ? (

View File

@ -40,20 +40,16 @@ export const UserFriendModalAddFriend = ({
}); });
}; };
const resetAndClose = () => {
setFriendCode("");
closeModal();
};
const handleClickRequest = (userId: string) => { const handleClickRequest = (userId: string) => {
resetAndClose(); closeModal();
navigate(`/user/${userId}`); navigate(`/user/${userId}`);
}; };
const handleClickSeeProfile = () => { const handleClickSeeProfile = () => {
resetAndClose(); closeModal();
// TODO: add validation for this input? if (friendCode.length === 8) {
navigate(`/user/${friendCode}`); navigate(`/user/${friendCode}`);
}
}; };
const handleCancelFriendRequest = (userId: string) => { const handleCancelFriendRequest = (userId: string) => {
@ -122,7 +118,8 @@ export const UserFriendModalAddFriend = ({
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
}} }}
> >
<h3>Pendentes</h3> <h3>{t("pending")}</h3>
{friendRequests.length === 0 && <p>{t("no_pending_invites")}</p>}
{friendRequests.map((request) => { {friendRequests.map((request) => {
return ( return (
<UserFriendItem <UserFriendItem

View File

@ -1,10 +1,11 @@
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { UserFriend } from "@types"; import { UserFriend } from "@types";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { UserFriendItem } from "./user-friend-item"; import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useToast, useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
export interface UserFriendModalListProps { export interface UserFriendModalListProps {
userId: string; userId: string;
@ -22,14 +23,17 @@ export const UserFriendModalList = ({
const navigate = useNavigate(); const navigate = useNavigate();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [maxPage, setMaxPage] = useState(0); const [maxPage, setMaxPage] = useState(0);
const [friends, setFriends] = useState<UserFriend[]>([]); const [friends, setFriends] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { userDetails, undoFriendship } = useUserDetails(); const { userDetails, undoFriendship } = useUserDetails();
const isMe = userDetails?.id == userId; const isMe = userDetails?.id == userId;
const loadNextPage = () => { const loadNextPage = () => {
if (page > maxPage) return; if (page > maxPage) return;
setIsLoading(true);
window.electron window.electron
.getUserFriends(userId, pageSize, page * pageSize) .getUserFriends(userId, pageSize, page * pageSize)
.then((newPage) => { .then((newPage) => {
@ -40,9 +44,29 @@ export const UserFriendModalList = ({
setFriends([...friends, ...newPage.friends]); setFriends([...friends, ...newPage.friends]);
setPage(page + 1); setPage(page + 1);
}) })
.catch(() => {}); .catch(() => {})
.finally(() => setIsLoading(false));
}; };
const handleScroll = () => {
const scrollTop = listContainer.current?.scrollTop || 0;
const scrollHeight = listContainer.current?.scrollHeight || 0;
const clientHeight = listContainer.current?.clientHeight || 0;
const maxScrollTop = scrollHeight - clientHeight;
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
return;
}
loadNextPage();
};
useEffect(() => {
listContainer.current?.addEventListener("scroll", handleScroll);
return () =>
listContainer.current?.removeEventListener("scroll", handleScroll);
}, [isLoading]);
const reloadList = () => { const reloadList = () => {
setPage(0); setPage(0);
setMaxPage(0); setMaxPage(0);
@ -70,13 +94,18 @@ export const UserFriendModalList = ({
}; };
return ( return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div <div
ref={listContainer}
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
maxHeight: "400px",
overflowY: "scroll",
}} }}
> >
{!isLoading && friends.length === 0 && <p>{t("no_friends_added")}</p>}
{friends.map((friend) => { {friends.map((friend) => {
return ( return (
<UserFriendItem <UserFriendItem
@ -86,10 +115,21 @@ export const UserFriendModalList = ({
onClickItem={handleClickFriend} onClickItem={handleClickFriend}
onClickUndoFriendship={handleUndoFriendship} onClickUndoFriendship={handleUndoFriendship}
type={isMe ? "ACCEPTED" : null} type={isMe ? "ACCEPTED" : null}
key={friend.id} key={"modal" + friend.id}
/> />
); );
})} })}
{isLoading && (
<Skeleton
style={{
width: "100%",
height: "54px",
overflow: "hidden",
borderRadius: "4px",
}}
/>
)}
</div> </div>
</SkeletonTheme>
); );
}; };

View File

@ -1,17 +1,6 @@
import { SPACING_UNIT, vars } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/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({ export const friendAvatarContainer = style({
width: "35px", width: "35px",
minWidth: "35px", minWidth: "35px",
@ -42,8 +31,14 @@ export const profileAvatar = style({
}); });
export const friendListContainer = style({ export const friendListContainer = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%", width: "100%",
height: "54px", height: "54px",
minHeight: "54px",
transition: "all ease 0.2s", transition: "all ease 0.2s",
position: "relative", position: "relative",
":hover": { ":hover": {
@ -90,3 +85,15 @@ export const cancelRequestButton = style({
color: vars.color.danger, color: vars.color.danger,
}, },
}); });
export const friendCodeButton = style({
color: vars.color.body,
cursor: "pointer",
display: "flex",
gap: `${SPACING_UNIT / 2}px`,
alignItems: "center",
transition: "all ease 0.2s",
":hover": {
color: vars.color.muted,
},
});

View File

@ -1,10 +1,12 @@
import { Button, Modal } from "@renderer/components"; import { Button, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react"; import { useCallback, 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 { useToast, useUserDetails } from "@renderer/hooks";
import { UserFriendModalList } from "./user-friend-modal-list"; import { UserFriendModalList } from "./user-friend-modal-list";
import { CopyIcon } from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
export enum UserFriendModalTab { export enum UserFriendModalTab {
FriendsList, FriendsList,
@ -32,6 +34,8 @@ export const UserFriendModal = ({
initialTab || UserFriendModalTab.FriendsList initialTab || UserFriendModalTab.FriendsList
); );
const { showSuccessToast } = useToast();
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId; const isMe = userDetails?.id == userId;
@ -53,6 +57,11 @@ export const UserFriendModal = ({
return <></>; return <></>;
}; };
const copyToClipboard = useCallback(() => {
navigator.clipboard.writeText(userDetails!.id);
showSuccessToast(t("friend_code_copied"));
}, [userDetails, showSuccessToast, t]);
return ( return (
<Modal visible={visible} title={t("friends")} onClose={onClose}> <Modal visible={visible} title={t("friends")} onClose={onClose}>
<div <div
@ -64,6 +73,23 @@ export const UserFriendModal = ({
}} }}
> >
{isMe && ( {isMe && (
<>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<p>Seu código de amigo: </p>
<button
className={styles.friendCodeButton}
onClick={copyToClipboard}
>
<h3>{userDetails.id}</h3>
<CopyIcon />
</button>
</div>
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}> <section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{tabs.map((tab, index) => { {tabs.map((tab, index) => {
return ( return (
@ -77,6 +103,7 @@ export const UserFriendModal = ({
); );
})} })}
</section> </section>
</>
)} )}
{renderTab()} {renderTab()}
</div> </div>

View File

@ -25,7 +25,7 @@ import {
XCircleIcon, XCircleIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal"; import { UserProfileSettingsModal } from "./user-profile-settings-modal";
import { UserSignOutModal } from "./user-sign-out-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"; import { UserBlockModal } from "./user-block-modal";
@ -60,7 +60,8 @@ export function UserContent({
const [profileContentBoxBackground, setProfileContentBoxBackground] = const [profileContentBoxBackground, setProfileContentBoxBackground] =
useState<string | undefined>(); useState<string | undefined>();
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showProfileSettingsModal, setShowProfileSettingsModal] =
useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false);
const [showUserBlockModal, setShowUserBlockModal] = useState(false); const [showUserBlockModal, setShowUserBlockModal] = useState(false);
@ -95,7 +96,7 @@ export function UserContent({
}; };
const handleEditProfile = () => { const handleEditProfile = () => {
setShowEditProfileModal(true); setShowProfileSettingsModal(true);
}; };
const handleOnClickFriend = (userId: string) => { const handleOnClickFriend = (userId: string) => {
@ -114,7 +115,7 @@ export function UserContent({
useEffect(() => { useEffect(() => {
if (isMe) fetchFriendRequests(); if (isMe) fetchFriendRequests();
}, [isMe]); }, [isMe, fetchFriendRequests]);
useEffect(() => { useEffect(() => {
if (isMe && profileBackground) { if (isMe && profileBackground) {
@ -128,7 +129,7 @@ export function UserContent({
} }
); );
} }
}, [profileBackground, isMe]); }, [profileBackground, isMe, userProfile.profileImageUrl]);
const handleFriendAction = (userId: string, action: FriendAction) => { const handleFriendAction = (userId: string, action: FriendAction) => {
try { try {
@ -159,13 +160,18 @@ export function UserContent({
}; };
const showFriends = isMe || userProfile.totalFriends > 0; const showFriends = isMe || userProfile.totalFriends > 0;
const showProfileContent =
isMe ||
userProfile.profileVisibility === "PUBLIC" ||
(userProfile.relation?.status === "ACCEPTED" &&
userProfile.profileVisibility === "FRIENDS");
const getProfileActions = () => { const getProfileActions = () => {
if (isMe) { if (isMe) {
return ( return (
<> <>
<Button theme="outline" onClick={handleEditProfile}> <Button theme="outline" onClick={handleEditProfile}>
{t("edit_profile")} {t("settings")}
</Button> </Button>
<Button theme="danger" onClick={() => setShowSignOutModal(true)}> <Button theme="danger" onClick={() => setShowSignOutModal(true)}>
@ -251,9 +257,9 @@ export function UserContent({
return ( return (
<> <>
<UserEditProfileModal <UserProfileSettingsModal
visible={showEditProfileModal} visible={showProfileSettingsModal}
onClose={() => setShowEditProfileModal(false)} onClose={() => setShowProfileSettingsModal(false)}
updateUserProfile={updateUserProfile} updateUserProfile={updateUserProfile}
userProfile={userProfile} userProfile={userProfile}
/> />
@ -361,6 +367,7 @@ export function UserContent({
</div> </div>
</section> </section>
{showProfileContent && (
<div className={styles.profileContent}> <div className={styles.profileContent}>
<div className={styles.profileGameSection}> <div className={styles.profileGameSection}>
<h2>{t("activity")}</h2> <h2>{t("activity")}</h2>
@ -435,7 +442,9 @@ export function UserContent({
{userProfile.libraryGames.length} {userProfile.libraryGames.length}
</h3> </h3>
</div> </div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small> <small>
{t("total_play_time", { amount: formatPlayTime() })}
</small>
<div <div
style={{ style={{
display: "grid", display: "grid",
@ -446,7 +455,10 @@ export function UserContent({
{userProfile.libraryGames.map((game) => ( {userProfile.libraryGames.map((game) => (
<button <button
key={game.objectID} key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)} className={cn(
styles.gameListItem,
styles.profileContentBox
)}
onClick={() => handleGameClick(game)} onClick={() => handleGameClick(game)}
title={game.title} title={game.title}
> >
@ -543,6 +555,7 @@ export function UserContent({
)} )}
</div> </div>
</div> </div>
)}
</> </>
); );
} }

View File

@ -1,147 +0,0 @@
import { Button, Modal, TextField } from "@renderer/components";
import { UserProfile } from "@types";
import * as styles from "./user.css";
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface UserEditProfileModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfileModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserEditProfileModalProps) => {
const { t } = useTranslation("user_profile");
const [displayName, setDisplayName] = useState("");
const [newImagePath, setNewImagePath] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
setDisplayName(userProfile.displayName);
}, [userProfile.displayName]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setNewImagePath(path);
}
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(displayName, newImagePath)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
cleanFormAndClose();
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const resetModal = () => {
setDisplayName(userProfile.displayName);
setNewImagePath(null);
};
const cleanFormAndClose = () => {
resetModal();
onClose();
};
const avatarUrl = useMemo(() => {
if (newImagePath) return `local:${newImagePath}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [newImagePath, userProfile.profileImageUrl]);
return (
<>
<Modal
visible={visible}
title={t("edit_profile")}
onClose={cleanFormAndClose}
>
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setDisplayName(e.target.value)}
/>
<Button
disabled={isSaving}
style={{ alignSelf: "end" }}
type="submit"
>
{isSaving ? t("saving") : t("save")}
</Button>
</form>
</Modal>
</>
);
};

View File

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

View File

@ -0,0 +1,118 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { UserFriend } from "@types";
import { useEffect, useRef, useState } from "react";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
const pageSize = 12;
export const UserEditProfileBlockList = () => {
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [maxPage, setMaxPage] = useState(0);
const [blocks, setBlocks] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { unblockUser } = useUserDetails();
const loadNextPage = () => {
if (page > maxPage) return;
setIsLoading(true);
window.electron
.getUserBlocks(pageSize, page * pageSize)
.then((newPage) => {
if (page === 0) {
setMaxPage(newPage.totalBlocks / pageSize);
}
setBlocks([...blocks, ...newPage.blocks]);
setPage(page + 1);
})
.catch(() => {})
.finally(() => setIsLoading(false));
};
const handleScroll = () => {
const scrollTop = listContainer.current?.scrollTop || 0;
const scrollHeight = listContainer.current?.scrollHeight || 0;
const clientHeight = listContainer.current?.clientHeight || 0;
const maxScrollTop = scrollHeight - clientHeight;
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
return;
}
loadNextPage();
};
useEffect(() => {
listContainer.current?.addEventListener("scroll", handleScroll);
return () =>
listContainer.current?.removeEventListener("scroll", handleScroll);
}, [isLoading]);
const reloadList = () => {
setPage(0);
setMaxPage(0);
setBlocks([]);
loadNextPage();
};
useEffect(() => {
reloadList();
}, []);
const handleUnblock = (userId: string) => {
unblockUser(userId)
.then(() => {
reloadList();
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
<div
ref={listContainer}
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
maxHeight: "400px",
overflowY: "scroll",
}}
>
{!isLoading && blocks.length === 0 && <p>{t("no_blocked_users")}</p>}
{blocks.map((friend) => {
return (
<UserFriendItem
userId={friend.id}
displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickUnblock={handleUnblock}
type={"BLOCKED"}
key={friend.id}
/>
);
})}
{isLoading && (
<Skeleton
style={{
width: "100%",
height: "54px",
overflow: "hidden",
borderRadius: "4px",
}}
/>
)}
</div>
</SkeletonTheme>
);
};

View File

@ -0,0 +1,149 @@
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
import { Button, SelectField, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { UserProfile } from "@types";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "../user.css";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface UserEditProfileProps {
userProfile: UserProfile;
updateUserProfile: () => Promise<void>;
}
export const UserEditProfile = ({
userProfile,
updateUserProfile,
}: UserEditProfileProps) => {
const { t } = useTranslation("user_profile");
const [form, setForm] = useState({
displayName: userProfile.displayName,
profileVisibility: userProfile.profileVisibility,
imageProfileUrl: null as string | null,
});
const [isSaving, setIsSaving] = useState(false);
const { patchUser } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const [profileVisibilityOptions, setProfileVisibilityOptions] = useState<
{ value: string; label: string }[]
>([]);
useEffect(() => {
setProfileVisibilityOptions([
{ value: "PUBLIC", label: t("public") },
{ value: "FRIENDS", label: t("friends_only") },
{ value: "PRIVATE", label: t("private") },
]);
}, [t]);
const handleChangeProfileAvatar = async () => {
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Image",
extensions: ["jpg", "jpeg", "png", "webp"],
},
],
});
if (filePaths && filePaths.length > 0) {
const path = filePaths[0];
setForm({ ...form, imageProfileUrl: path });
}
};
const handleProfileVisibilityChange = (event) => {
setForm({
...form,
profileVisibility: event.target.value,
});
};
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
setIsSaving(true);
patchUser(form)
.then(async () => {
await updateUserProfile();
showSuccessToast(t("saved_successfully"));
})
.catch(() => {
showErrorToast(t("try_again"));
})
.finally(() => {
setIsSaving(false);
});
};
const avatarUrl = useMemo(() => {
if (form.imageProfileUrl) return `local:${form.imageProfileUrl}`;
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
return null;
}, [form, userProfile]);
return (
<form
onSubmit={handleSaveProfile}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: `${SPACING_UNIT * 3}px`,
width: "350px",
}}
>
<button
type="button"
className={styles.profileAvatarEditContainer}
onClick={handleChangeProfileAvatar}
>
{avatarUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile.displayName}
src={avatarUrl}
/>
) : (
<PersonIcon size={96} />
)}
<div className={styles.editProfileImageBadge}>
<DeviceCameraIcon size={16} />
</div>
</button>
<TextField
label={t("display_name")}
value={form.displayName}
required
minLength={3}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
/>
<SelectField
label={t("privacy")}
value={form.profileVisibility}
onChange={handleProfileVisibilityChange}
options={profileVisibilityOptions.map((visiblity) => ({
key: visiblity.value,
value: visiblity.value,
label: visiblity.label,
}))}
/>
<Button disabled={isSaving} style={{ alignSelf: "end" }} type="submit">
{isSaving ? t("saving") : t("save")}
</Button>
</form>
);
};

View File

@ -0,0 +1,73 @@
import { Button, Modal } from "@renderer/components";
import { UserProfile } from "@types";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UserEditProfile } from "./user-edit-profile";
import { UserEditProfileBlockList } from "./user-block-list";
export interface UserProfileSettingsModalProps {
userProfile: UserProfile;
visible: boolean;
onClose: () => void;
updateUserProfile: () => Promise<void>;
}
export const UserProfileSettingsModal = ({
userProfile,
visible,
onClose,
updateUserProfile,
}: UserProfileSettingsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("edit_profile"), t("blocked_users")];
const [currentTabIndex, setCurrentTabIndex] = useState(0);
const renderTab = () => {
if (currentTabIndex == 0) {
return (
<UserEditProfile
userProfile={userProfile}
updateUserProfile={updateUserProfile}
/>
);
}
if (currentTabIndex == 1) {
return <UserEditProfileBlockList />;
}
return <></>;
};
return (
<>
<Modal visible={visible} title={t("settings")} onClose={onClose}>
<div
style={{
display: "flex",
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 === currentTabIndex ? "primary" : "outline"}
onClick={() => setCurrentTabIndex(index)}
>
{tab}
</Button>
);
})}
</section>
{renderTab()}
</div>
</Modal>
</>
);
};

View File

@ -60,6 +60,7 @@ export const friendListDisplayName = style({
}); });
export const profileAvatarEditContainer = style({ export const profileAvatarEditContainer = style({
alignSelf: "center",
width: "128px", width: "128px",
height: "128px", height: "128px",
display: "flex", display: "flex",

View File

@ -31,7 +31,7 @@ export const User = () => {
navigate(-1); navigate(-1);
} }
}); });
}, [dispatch, userId, t]); }, [dispatch, navigate, showErrorToast, userId, t]);
useEffect(() => { useEffect(() => {
getUserProfile(); getUserProfile();

View File

@ -282,6 +282,11 @@ export interface UserFriends {
friends: UserFriend[]; friends: UserFriend[];
} }
export interface UserBlocks {
totalBlocks: number;
blocks: UserFriend[];
}
export interface FriendRequest { export interface FriendRequest {
id: string; id: string;
displayName: string; displayName: string;
@ -310,6 +315,13 @@ export interface UserProfile {
relation: UserRelation | null; relation: UserRelation | null;
} }
export interface UpdateProfileProps {
displayName?: string;
profileVisibility?: "PUBLIC" | "PRIVATE" | "FRIENDS";
profileImageUrl?: string | null;
bio?: string;
}
export interface DownloadSource { export interface DownloadSource {
id: number; id: number;
name: string; name: string;