refactor: remove sync friend requests functionality and related components

This commit is contained in:
Moyasee
2025-12-23 18:47:31 +02:00
parent 45eaef23a9
commit 8751e369da
36 changed files with 529 additions and 1130 deletions

View File

@@ -1,4 +1,3 @@
import "./get-me";
import "./process-profile-image";
import "./sync-friend-requests";
import "./update-profile";

View File

@@ -1,24 +0,0 @@
import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types";
export const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests",
res
);
return res;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequestCount: 0 } as FriendRequestSync;
}
throw err;
});
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

@@ -498,7 +498,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("updateProfile", updateProfile),
processProfileImage: (imagePath: string) =>
ipcRenderer.invoke("processProfileImage", imagePath),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,

View File

@@ -23,7 +23,6 @@ import {
clearExtraction,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
@@ -56,10 +55,6 @@ export function App() {
const {
userDetails,
hasActiveSubscription,
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
hideFriendsModal,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
@@ -135,7 +130,6 @@ export function App() {
.then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
}
})
.finally(() => {
@@ -152,7 +146,6 @@ export function App() {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in"));
}
});
@@ -305,15 +298,6 @@ export function App() {
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
userId={friendModalUserId}
/>
)}
<main>
<Sidebar />

View File

@@ -46,8 +46,7 @@
white-space: nowrap;
}
&__notification-button,
&__friends-button {
&__notification-button {
color: globals.$muted-color;
cursor: pointer;
border-radius: 50%;
@@ -63,8 +62,7 @@
}
}
&__notification-button-badge,
&__friends-button-badge {
&__notification-button-badge {
background-color: globals.$success-color;
display: flex;
justify-content: center;

View File

@@ -1,9 +1,8 @@
import { useNavigate } from "react-router-dom";
import { PeopleIcon, BellIcon } from "@primer/octicons-react";
import { BellIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared";
@@ -16,8 +15,7 @@ export function SidebarProfile() {
const { t } = useTranslation("sidebar");
const { userDetails, friendRequestCount, showFriendsModal } =
useUserDetails();
const { userDetails } = useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@@ -114,29 +112,6 @@ export function SidebarProfile() {
);
}, [t, notificationCount, navigate]);
const friendsButton = useMemo(() => {
if (!userDetails) return null;
return (
<button
type="button"
className="sidebar-profile__friends-button"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
}
title={t("friends")}
>
{friendRequestCount > 0 && (
<small className="sidebar-profile__friends-button-badge">
{friendRequestCount > 99 ? "99+" : friendRequestCount}
</small>
)}
<PeopleIcon size={16} />
</button>
);
}, [userDetails, t, friendRequestCount, showFriendsModal]);
const gameRunningDetails = () => {
if (!userDetails || !gameRunning) return null;
@@ -185,7 +160,6 @@ export function SidebarProfile() {
</button>
{notificationsButton}
{friendsButton}
</div>
);
}

View File

@@ -389,7 +389,6 @@ declare global {
processProfileImage: (
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;

View File

@@ -1,5 +1,4 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState {
@@ -7,9 +6,6 @@ export interface UserDetailsState {
profileBackground: null | string;
friendRequests: FriendRequest[];
friendRequestCount: number;
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
friendModalUserId: string;
}
const initialState: UserDetailsState = {
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
profileBackground: null,
friendRequests: [],
friendRequestCount: 0,
isFriendsModalVisible: false,
friendRequetsModalTab: null,
friendModalUserId: "",
};
export const userDetailsSlice = createSlice({
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
setFriendRequestCount: (state, action: PayloadAction<number>) => {
state.friendRequestCount = action.payload;
},
setFriendsModalVisible: (
state,
action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload.initialTab;
state.friendModalUserId = action.payload.userId;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
},
});
@@ -58,6 +39,4 @@ export const {
setProfileBackground,
setFriendRequests,
setFriendRequestCount,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@@ -4,8 +4,6 @@ import {
setProfileBackground,
setUserDetails,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import type {
FriendRequestAction,
@@ -13,20 +11,12 @@ import type {
UserDetails,
FriendRequest,
} from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() {
const dispatch = useAppDispatch();
const {
userDetails,
profileBackground,
friendRequests,
friendRequestCount,
isFriendsModalVisible,
friendModalUserId,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null));
@@ -85,24 +75,11 @@ export function useUserDetails() {
return window.electron.hydraApi
.get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => {
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));
fetchFriendRequests();
},
[dispatch, fetchFriendRequests]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron.hydraApi
@@ -152,12 +129,7 @@ export function useUserDetails() {
profileBackground,
friendRequests,
friendRequestCount,
friendRequetsModalTab,
isFriendsModalVisible,
friendModalUserId,
hasActiveSubscription,
showFriendsModal,
hideFriendsModal,
fetchUserDetails,
signOut,
clearUserDetails,

View File

@@ -343,13 +343,17 @@ export default function Notifications() {
);
};
return (
<>
{isLoading && mergedNotifications.length === 0 ? (
const renderContent = () => {
if (isLoading && mergedNotifications.length === 0) {
return (
<div className="notifications__loading">
<span>{t("loading")}</span>
</div>
) : mergedNotifications.length === 0 ? (
);
}
if (mergedNotifications.length === 0) {
return (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
@@ -357,36 +361,40 @@ export default function Notifications() {
<h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p>
</div>
) : (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
</div>
);
}
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
{pagination.hasMore && (
<div className="notifications__load-more">
<Button
theme="outline"
onClick={handleLoadMore}
disabled={isLoading}
>
{isLoading ? t("loading") : t("load_more")}
</Button>
</div>
)}
return (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
</div>
)}
</>
);
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
{pagination.hasMore && (
<div className="notifications__load-more">
<Button
theme="outline"
onClick={handleLoadMore}
disabled={isLoading}
>
{isLoading ? t("loading") : t("load_more")}
</Button>
</div>
)}
</div>
);
};
return <>{renderContent()}</>;
}

View File

@@ -51,4 +51,29 @@
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__friend-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__friend-name {
flex: 1;
font-weight: 600;
color: globals.$muted-color;
font-size: globals.$body-font-size;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -1,9 +1,8 @@
import { Button, Modal, TextField } from "@renderer/components";
import { Avatar, Button, Modal, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item";
import "./add-friend-modal.scss";
interface AddFriendModalProps {
@@ -80,22 +79,6 @@ export function AddFriendModal({ visible, onClose }: AddFriendModalProps) {
});
};
const handleAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED")
.then(() => {
showSuccessToast(t("request_accepted"));
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
const handleRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast(t("try_again"));
});
};
const sentRequests = friendRequests.filter((req) => req.type === "SENT");
const currentRequest =
friendCode.length === 8
@@ -139,17 +122,31 @@ export function AddFriendModal({ visible, onClose }: AddFriendModalProps) {
<h3>{t("pending")}</h3>
<div className="add-friend-modal__pending-list">
{sentRequests.map((request) => (
<UserFriendItem
<button
key={request.id}
displayName={request.displayName}
type={request.type}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleAcceptFriendRequest}
onClickCancelRequest={handleCancelFriendRequest}
onClickRefuseRequest={handleRefuseFriendRequest}
onClickItem={handleClickRequest}
/>
type="button"
className="add-friend-modal__friend-item"
onClick={() => handleClickRequest(request.id)}
>
<Avatar
src={request.profileImageUrl}
alt={request.displayName}
size={40}
/>
<span className="add-friend-modal__friend-name">
{request.displayName}
</span>
<Button
theme="outline"
onClick={(e) => {
e.stopPropagation();
handleCancelFriendRequest(request.id);
}}
type="button"
>
{t("cancel_request")}
</Button>
</button>
))}
</div>
</div>

View File

@@ -1,18 +1,14 @@
@use "../../../scss/globals.scss";
.badges-box {
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
&__box {
padding: calc(globals.$spacing-unit * 2);
}
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
&__header {
display: flex;
justify-content: flex-end;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
@@ -24,7 +20,7 @@
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 1.5);
width: 100%;
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(255, 255, 255, 0.05);
@@ -38,8 +34,8 @@
&__item-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
width: 34px;
height: 34px;
border-radius: 8px;
overflow: hidden;
display: flex;
@@ -48,8 +44,8 @@
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
object-fit: contain;
}
}
@@ -63,7 +59,7 @@
}
&__item-title {
font-size: globals.$body-font-size;
font-size: 0.8rem;
font-weight: 600;
color: globals.$body-color;
margin: 0;
@@ -76,7 +72,6 @@
}
&__view-all-container {
background-color: globals.$background-color;
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;

View File

@@ -1,5 +1,4 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { AllBadgesModal } from "./all-badges-modal";
@@ -10,7 +9,6 @@ const MAX_VISIBLE_BADGES = 4;
export function BadgesBox() {
const { userProfile, badges } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const [showAllBadgesModal, setShowAllBadgesModal] = useState(false);
if (!userProfile?.badges.length) return null;
@@ -20,55 +18,44 @@ export function BadgesBox() {
return (
<>
<div>
<div className="badges-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("badges")}</h2>
<span className="profile-content__section-badge">
{numberFormatter.format(userProfile.badges.length)}
</span>
</div>
</div>
<div className="badges-box__box">
<div className="badges-box__list">
{visibleBadges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
<div className="badges-box__box">
<div className="badges-box__list">
{visibleBadges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
if (!badge) return null;
if (!badge) return null;
return (
<div key={badge.name} className="badges-box__item">
<div className="badges-box__item-icon">
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
<div className="badges-box__item-content">
<h3 className="badges-box__item-title">{badge.title}</h3>
<p className="badges-box__item-description">
{badge.description}
</p>
</div>
return (
<div key={badge.name} className="badges-box__item">
<div className="badges-box__item-icon">
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
);
})}
</div>
{hasMoreBadges && (
<div className="badges-box__view-all-container">
<button
type="button"
className="badges-box__view-all"
onClick={() => setShowAllBadgesModal(true)}
>
{t("view_all")}
</button>
</div>
)}
<div className="badges-box__item-content">
<h3 className="badges-box__item-title">{badge.title}</h3>
<p className="badges-box__item-description">
{badge.description}
</p>
</div>
</div>
);
})}
</div>
{hasMoreBadges && (
<div className="badges-box__view-all-container">
<button
type="button"
className="badges-box__view-all"
onClick={() => setShowAllBadgesModal(true)}
>
{t("view_all")}
</button>
</div>
)}
</div>
<AllBadgesModal

View File

@@ -1,17 +1,7 @@
@use "../../../scss/globals.scss";
.friends-box {
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
position: relative;
}
@@ -35,7 +25,6 @@
}
&__view-all-container {
background-color: globals.$background-color;
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;

View File

@@ -1,5 +1,5 @@
import { userProfileContext } from "@renderer/context";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { useUserDetails } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusIcon } from "@primer/octicons-react";
@@ -10,10 +10,9 @@ import { AddFriendModal } from "./add-friend-modal";
import "./friends-box.scss";
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const [showAllFriendsModal, setShowAllFriendsModal] = useState(false);
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
@@ -38,73 +37,50 @@ export function FriendsBox() {
return (
<>
<div>
<div className="friends-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("friends")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.friendsCount)}
</span>
)}
</div>
{isMe && (
<button
type="button"
className="friends-box__add-friend-button"
onClick={() => setShowAddFriendModal(true)}
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
<li
key={friend.id}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
>
<PlusIcon size={16} />
{t("add_friends")}
</button>
)}
</div>
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
<li
key={friend.id}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
<Link
to={`/profile/${friend.id}`}
className="friends-box__list-item"
>
<Link
to={`/profile/${friend.id}`}
className="friends-box__list-item"
>
<Avatar
size={32}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<Avatar
size={32}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div className="friends-box__friend-details">
<span className="friends-box__friend-name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="friends-box__game-info">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</Link>
</li>
))}
</ul>
<div className="friends-box__view-all-container">
<button
type="button"
className="friends-box__view-all"
onClick={() => setShowAllFriendsModal(true)}
>
{t("view_all")}
</button>
</div>
<div className="friends-box__friend-details">
<span className="friends-box__friend-name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="friends-box__game-info">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</Link>
</li>
))}
</ul>
<div className="friends-box__view-all-container">
<button
type="button"
className="friends-box__view-all"
onClick={() => setShowAllFriendsModal(true)}
>
{t("view_all")}
</button>
</div>
</div>
@@ -125,3 +101,31 @@ export function FriendsBox() {
</>
);
}
export function FriendsBoxAddButton() {
const { userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
if (!isMe) return null;
return (
<>
<button
type="button"
className="friends-box__add-friend-button"
onClick={() => setShowAddFriendModal(true)}
>
<PlusIcon size={16} />
{t("add_friends")}
</button>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
</>
);
}

View File

@@ -15,10 +15,11 @@ import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { BadgesBox } from "./badges-box";
import { FriendsBox } from "./friends-box";
import { FriendsBox, FriendsBoxAddButton } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
import { UserKarmaBox } from "./user-karma-box";
import { ProfileSection } from "../profile-section/profile-section";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
@@ -187,8 +188,6 @@ export function ProfileContent() {
);
setReviews(response.reviews);
setReviewsTotalCount(response.totalCount);
} catch (error) {
// Error handling for fetching reviews
} finally {
setIsLoadingReviews(false);
}
@@ -427,11 +426,41 @@ export function ProfileContent() {
{shouldShowRightContent && (
<div className="profile-content__right-content">
<UserStatsBox />
<BadgesBox />
<UserKarmaBox />
<RecentGamesBox />
<FriendsBox />
{userStats && (
<ProfileSection title={t("stats")} defaultOpen={true}>
<UserStatsBox />
</ProfileSection>
)}
{userProfile?.badges.length > 0 && (
<ProfileSection
title={t("badges")}
count={userProfile.badges.length}
defaultOpen={true}
>
<BadgesBox />
</ProfileSection>
)}
{userProfile?.karma !== undefined &&
userProfile?.karma !== null && (
<ProfileSection title={t("karma")} defaultOpen={true}>
<UserKarmaBox />
</ProfileSection>
)}
{userProfile?.recentGames.length > 0 && (
<ProfileSection title={t("activity")} defaultOpen={true}>
<RecentGamesBox />
</ProfileSection>
)}
{userProfile?.friends.length > 0 && (
<ProfileSection
title={t("friends")}
count={userStats?.friendsCount || userProfile.friends.length}
action={<FriendsBoxAddButton />}
defaultOpen={true}
>
<FriendsBox />
</ProfileSection>
)}
<ReportProfile />
</div>
)}

View File

@@ -2,19 +2,9 @@
.recent-games {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;

View File

@@ -42,38 +42,32 @@ export function RecentGamesBox() {
if (!userProfile?.recentGames.length) return null;
return (
<div>
<div className="recent-games__section-header">
<h2>{t("activity")}</h2>
</div>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className="recent-games__game-image"
/>
<div className="recent-games__box">
<ul className="recent-games__list">
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className="recent-games__list-item"
>
<img
src={game.iconUrl!}
alt={game.title}
className="recent-games__game-image"
/>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className="recent-games__game-details">
<span className="recent-games__game-title">{game.title}</span>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
<div className="recent-games__game-description">
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -2,19 +2,9 @@
.user-karma {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__content {
display: flex;
flex-direction: column;

View File

@@ -18,24 +18,18 @@ export function UserKarmaBox() {
if (karma === undefined || karma === null) return null;
return (
<div>
<div className="user-karma__section-header">
<h2>{t("karma")}</h2>
</div>
<div className="user-karma__box">
<div className="user-karma__content">
<div className="user-karma__stats-row">
<p className="user-karma__description">
<Award size={20} /> {numberFormatter.format(karma)}{" "}
{t("karma_count")}
</p>
</div>
<div className="user-karma__info">
<small className="user-karma__info-text">
{t("karma_description")}
</small>
</div>
<div className="user-karma__box">
<div className="user-karma__content">
<div className="user-karma__stats-row">
<p className="user-karma__description">
<Award size={20} /> {numberFormatter.format(karma)}{" "}
{t("karma_count")}
</p>
</div>
<div className="user-karma__info">
<small className="user-karma__info-text">
{t("karma_description")}
</small>
</div>
</div>
</div>

View File

@@ -2,19 +2,9 @@
.user-stats {
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__list {
list-style: none;
margin: 0;

View File

@@ -34,87 +34,81 @@ export function UserStatsBox() {
if (!userStats) return null;
return (
<div>
<div className="user-stats__section-header">
<h2>{t("stats")}</h2>
</div>
<div className="user-stats__box">
<ul className="user-stats__list">
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className="user-stats__link"
>
<small style={{ color: "var(--color-warning)" }}>
{t("show_achievements_on_profile")}
</small>
</button>
)}
</li>
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile:
userStats.achievementsPointsEarnedSum.topPercentile,
})}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className="user-stats__link"
>
<small className="user-stats__link--warning">
{t("show_points_on_profile")}
</small>
</button>
)}
</li>
)}
<div className="user-stats__box">
<ul className="user-stats__list">
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
})}
</p>
</div>
<h3 className="user-stats__list-title">
{t("achievements_unlocked")}
</h3>
{userStats.unlockedAchievementSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
{t("achievements")}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements")}
className="user-stats__link"
>
<small style={{ color: "var(--color-warning)" }}>
{t("show_achievements_on_profile")}
</small>
</button>
)}
</li>
</ul>
</div>
)}
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
{userStats.achievementsPointsEarnedSum !== undefined ? (
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<HydraIcon width={20} height={20} />
{numberFormatter.format(
userStats.achievementsPointsEarnedSum.value
)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile:
userStats.achievementsPointsEarnedSum.topPercentile,
})}
</p>
</div>
) : (
<button
type="button"
onClick={() => showHydraCloudModal("achievements-points")}
className="user-stats__link"
>
<small className="user-stats__link--warning">
{t("show_points_on_profile")}
</small>
</button>
)}
</li>
)}
<li className="user-stats__list-item">
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
<div className="user-stats__stats-row">
<p className="user-stats__list-description">
<ClockIcon />
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
</p>
<p title={t("ranking_updated_weekly")}>
{t("top_percentile", {
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
})}
</p>
</div>
</li>
</ul>
</div>
);
}

View File

@@ -30,7 +30,7 @@
&__copy-button {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
background: none;
border: none;
color: globals.$body-color;
@@ -38,7 +38,11 @@
cursor: pointer;
padding: calc(globals.$spacing-unit / 1.5);
border-radius: 6px;
transition: all ease 0.2s;
transition: background-color ease 0.2s;
overflow: hidden;
white-space: nowrap;
flex-shrink: 0;
box-sizing: border-box;
&:hover {
opacity: 1;
@@ -46,6 +50,12 @@
}
}
&__friend-code {
font-size: globals.$small-font-size;
font-family: monospace;
white-space: nowrap;
}
&__user-information {
display: flex;
padding: calc(globals.$spacing-unit * 7) calc(globals.$spacing-unit * 3);

View File

@@ -20,6 +20,7 @@ import {
} from "@renderer/hooks";
import { addSeconds } from "date-fns";
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
@@ -34,6 +35,7 @@ type FriendAction =
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext);
@@ -312,14 +314,32 @@ export function ProfileHero() {
{userProfile?.displayName}
</h2>
<button
<motion.button
type="button"
className="profile-hero__copy-button"
onClick={copyFriendCode}
title={t("copy_friend_code")}
onMouseEnter={() => setIsCopyButtonHovered(true)}
onMouseLeave={() => setIsCopyButtonHovered(false)}
initial={{ width: 28 }}
animate={{
width: isCopyButtonHovered ? 94 : 28,
}}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<CopyIcon size={16} />
</button>
<motion.span
className="profile-hero__friend-code"
initial={{ opacity: 0, marginLeft: 0 }}
animate={{
opacity: isCopyButtonHovered ? 1 : 0,
marginLeft: isCopyButtonHovered ? 8 : 0,
}}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{userProfile?.id}
</motion.span>
</motion.button>
</div>
) : (
<Skeleton width={150} height={28} />

View File

@@ -0,0 +1,71 @@
@use "../../../scss/globals.scss";
.profile-section {
background-color: globals.$background-color;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: globals.$background-color;
padding-right: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&__button {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: transparent;
color: globals.$muted-color;
flex: 1;
cursor: pointer;
transition: all ease 0.2s;
gap: globals.$spacing-unit;
font-size: globals.$body-font-size;
font-weight: bold;
border: none;
&:active {
opacity: globals.$active-opacity;
}
}
&__count {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
}
&__action {
display: flex;
align-items: center;
}
&__chevron {
transition: transform ease 0.2s;
&--open {
transform: rotate(180deg);
}
}
&__content {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
background-color: globals.$dark-background-color;
position: relative;
}
}

View File

@@ -0,0 +1,64 @@
import { ChevronDownIcon } from "@primer/octicons-react";
import { useEffect, useRef, useState } from "react";
import "./profile-section.scss";
export interface ProfileSectionProps {
title: string;
count?: number;
action?: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
}
export function ProfileSection({
title,
count,
action,
children,
defaultOpen = true,
}: ProfileSectionProps) {
const content = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(defaultOpen);
const [height, setHeight] = useState(0);
useEffect(() => {
if (content.current && content.current.scrollHeight !== height) {
setHeight(isOpen ? content.current.scrollHeight : 0);
} else if (!isOpen) {
setHeight(0);
}
}, [isOpen, children, height]);
return (
<div className="profile-section">
<div className="profile-section__header">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="profile-section__button"
>
<ChevronDownIcon
className={`profile-section__chevron ${
isOpen ? "profile-section__chevron--open" : ""
}`}
/>
<span>{title}</span>
{count !== undefined && (
<span className="profile-section__count">{count}</span>
)}
</button>
{action && <div className="profile-section__action">{action}</div>}
</div>
<div
ref={content}
className="profile-section__content"
style={{
maxHeight: `${height}px`,
}}
>
{children}
</div>
</div>
);
}

View File

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

View File

@@ -1,79 +0,0 @@
@use "../../../scss/globals.scss";
.user-friend-item {
&__container {
display: flex;
gap: calc(globals.$spacing-unit * 3);
align-items: center;
border-radius: 4px;
border: solid 1px globals.$border-color;
width: 100%;
height: 54px;
min-height: 54px;
transition: all ease 0.2s;
position: relative;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__button {
display: flex;
align-items: center;
position: absolute;
cursor: pointer;
height: 100%;
width: 100%;
flex-direction: row;
color: globals.$body-color;
gap: calc(globals.$spacing-unit + globals.$spacing-unit / 2);
padding: 0 globals.$spacing-unit;
&__content {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
}
&__actions {
position: absolute;
right: 8px;
display: flex;
gap: 8px;
}
}
&__display-name {
font-weight: bold;
font-size: globals.$body-font-size;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__accept-button {
cursor: pointer;
color: globals.$body-color;
width: 28px;
height: 28px;
&:hover {
color: globals.$success-color;
}
}
&__cancel-button {
cursor: pointer;
color: globals.$body-color;
width: 28px;
height: 28px;
&:hover {
color: globals.$danger-color;
}
}
}

View File

@@ -1,139 +0,0 @@
import { CheckCircleIcon, XCircleIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { Avatar } from "@renderer/components";
import "./user-friend-item.scss";
export type UserFriendItemProps = {
userId: string;
profileImageUrl: string | null;
displayName: string;
} & (
| {
type: "ACCEPTED";
onClickUndoFriendship: (userId: string) => void;
onClickItem: (userId: string) => void;
}
| { type: "BLOCKED"; onClickUnblock: (userId: string) => void }
| {
type: "SENT" | "RECEIVED";
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
onClickItem: (userId: string) => void;
}
| { type: null; onClickItem: (userId: string) => void }
);
export const UserFriendItem = (props: UserFriendItemProps) => {
const { t } = useTranslation("user_profile");
const { userId, profileImageUrl, displayName, type } = 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="user-friend-item__cancel-button"
onClick={() => props.onClickCancelRequest(userId)}
title={t("cancel_request")}
>
<XCircleIcon size={28} />
</button>
);
}
if (type === "RECEIVED") {
return (
<>
<button
className="user-friend-item__accept-button"
onClick={() => props.onClickAcceptRequest(userId)}
title={t("accept_request")}
>
<CheckCircleIcon size={28} />
</button>
<button
className="user-friend-item__cancel-button"
onClick={() => props.onClickRefuseRequest(userId)}
title={t("ignore_request")}
>
<XCircleIcon size={28} />
</button>
</>
);
}
if (type === "ACCEPTED") {
return (
<button
className="user-friend-item__cancel-button"
onClick={() => props.onClickUndoFriendship(userId)}
title={t("undo_friendship")}
>
<XCircleIcon size={28} />
</button>
);
}
if (type === "BLOCKED") {
return (
<button
className="user-friend-item__cancel-button"
onClick={() => props.onClickUnblock(userId)}
title={t("unblock")}
>
<XCircleIcon size={28} />
</button>
);
}
return null;
};
if (type === "BLOCKED") {
return (
<div className="user-friend-item__container">
<div className="user-friend-item__button">
<Avatar size={35} src={profileImageUrl} alt={displayName} />
<div className="user-friend-item__button__content">
<p className="user-friend-item__display-name">{displayName}</p>
</div>
</div>
<div className="user-friend-item__button__actions">
{getRequestActions()}
</div>
</div>
);
}
return (
<div className="user-friend-item__container">
<button
type="button"
className="user-friend-item__button"
onClick={() => props.onClickItem(userId)}
>
<Avatar size={35} src={profileImageUrl} alt={displayName} />
<div className="user-friend-item__button__content">
<p className="user-friend-item__display-name">{displayName}</p>
{getRequestDescription()}
</div>
</button>
<div className="user-friend-item__button__actions">
{getRequestActions()}
</div>
</div>
);
};

View File

@@ -1,21 +0,0 @@
@use "../../../scss/globals.scss";
.user-friend-modal-add-friend {
&__actions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: globals.$spacing-unit;
}
&__button {
align-self: end;
}
&__pending-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -1,138 +0,0 @@
import { Button, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UserFriendItem } from "./user-friend-item";
import "./user-friend-modal-add-friend.scss";
export interface UserFriendModalAddFriendProps {
closeModal: () => void;
}
export const UserFriendModalAddFriend = ({
closeModal,
}: UserFriendModalAddFriendProps) => {
const { t } = useTranslation("user_profile");
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const navigate = useNavigate();
const { sendFriendRequest, updateFriendRequestState, friendRequests } =
useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const handleClickAddFriend = () => {
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
setFriendCode("");
})
.catch(() => {
showErrorToast(t("error_adding_friend"));
})
.finally(() => {
setIsAddingFriend(false);
});
};
const handleClickRequest = (userId: string) => {
closeModal();
navigate(`/profile/${userId}`);
};
const handleClickSeeProfile = () => {
if (friendCode.length === 8) {
closeModal();
navigate(`/profile/${friendCode}`);
}
};
const validateFriendCode = (callback: () => void) => {
if (friendCode.length === 8) {
return callback();
}
showErrorToast(t("friend_code_length_error"));
};
const handleCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast(t("try_again"));
});
};
const handleAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED")
.then(() => {
showSuccessToast(t("request_accepted"));
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
const handleRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast(t("try_again"));
});
};
const handleChangeFriendCode = (e: React.ChangeEvent<HTMLInputElement>) => {
const friendCode = e.target.value.trim().slice(0, 8);
setFriendCode(friendCode);
};
return (
<>
<div className="user-friend-modal-add-friend__actions">
<TextField
label={t("friend_code")}
value={friendCode}
containerProps={{ style: { width: "100%" } }}
onChange={handleChangeFriendCode}
/>
<Button
disabled={isAddingFriend}
className="user-friend-modal-add-friend__button"
type="button"
onClick={() => validateFriendCode(handleClickAddFriend)}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
onClick={() => validateFriendCode(handleClickSeeProfile)}
disabled={isAddingFriend}
className="user-friend-modal-add-friend__button"
type="button"
>
{t("see_profile")}
</Button>
</div>
<div className="user-friend-modal-add-friend__pending-container">
<h3>{t("pending")}</h3>
{friendRequests.length === 0 && <p>{t("no_pending_invites")}</p>}
{friendRequests.map((request) => {
return (
<UserFriendItem
key={request.id}
displayName={request.displayName}
type={request.type}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleAcceptFriendRequest}
onClickCancelRequest={handleCancelFriendRequest}
onClickRefuseRequest={handleRefuseFriendRequest}
onClickItem={handleClickRequest}
/>
);
})}
</div>
</>
);
};

View File

@@ -1,16 +0,0 @@
@use "../../../scss/globals.scss";
.user-friend-modal-list {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
max-height: 400px;
overflow-y: scroll;
&__skeleton {
width: 100%;
height: 54px;
overflow: hidden;
border-radius: 4px;
}
}

View File

@@ -1,108 +0,0 @@
import type { UserFriend } from "@types";
import { useEffect, useRef, useState } from "react";
import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom";
import { useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import "./user-friend-modal-list.scss";
export interface UserFriendModalListProps {
userId: string;
closeModal: () => void;
}
const pageSize = 12;
export const UserFriendModalList = ({
userId,
closeModal,
}: UserFriendModalListProps) => {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [maxPage, setMaxPage] = useState(0);
const [friends, setFriends] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
const loadNextPage = () => {
if (page > maxPage) return;
setIsLoading(true);
const url = isMe ? "/profile/friends" : `/users/${userId}/friends`;
window.electron.hydraApi
.get<{ totalFriends: number; friends: UserFriend[] }>(url, {
params: { take: pageSize, skip: page * pageSize },
})
.then((newPage) => {
if (page === 0) {
setMaxPage(newPage.totalFriends / pageSize);
}
setFriends([...friends, ...newPage.friends]);
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(() => {
const container = listContainer.current;
container?.addEventListener("scroll", handleScroll);
return () => container?.removeEventListener("scroll", handleScroll);
}, [isLoading]);
const reloadList = () => {
setPage(0);
setMaxPage(0);
setFriends([]);
loadNextPage();
};
useEffect(() => {
reloadList();
}, [userId]);
const handleClickFriend = (userId: string) => {
closeModal();
navigate(`/profile/${userId}`);
};
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
<div ref={listContainer} className="user-friend-modal-list">
{!isLoading && friends.length === 0 && <p>{t("no_friends_added")}</p>}
{friends.map((friend) => (
<UserFriendItem
userId={friend.id}
displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickItem={handleClickFriend}
type={null}
key={"modal" + friend.id}
/>
))}
{isLoading && <Skeleton className="user-friend-modal-list__skeleton" />}
</div>
</SkeletonTheme>
);
};

View File

@@ -1,34 +0,0 @@
@use "../../../scss/globals.scss";
.user-friend-modal {
&__container {
display: flex;
width: 500px;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__header {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
&__friend-code-button {
color: globals.$body-color;
cursor: pointer;
display: flex;
gap: calc(globals.$spacing-unit / 2);
align-items: center;
transition: all ease 0.2s;
&:hover {
color: globals.$muted-color;
}
}
&__tabs {
display: flex;
gap: globals.$spacing-unit;
}
}

View File

@@ -1,96 +0,0 @@
import { Button, Modal } from "@renderer/components";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
import { useToast, useUserDetails } from "@renderer/hooks";
import { UserFriendModalList } from "./user-friend-modal-list";
import { CopyIcon } from "@primer/octicons-react";
import "./user-friend-modal.scss";
export enum UserFriendModalTab {
FriendsList,
AddFriend,
}
export interface UserFriendsModalProps {
visible: boolean;
onClose: () => void;
initialTab: UserFriendModalTab | null;
userId: string;
}
export const UserFriendModal = ({
visible,
onClose,
initialTab,
userId,
}: UserFriendsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("friends_list"), t("add_friends")];
const [currentTab, setCurrentTab] = useState(
initialTab || UserFriendModalTab.FriendsList
);
const { showSuccessToast } = useToast();
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
useEffect(() => {
if (initialTab != null) {
setCurrentTab(initialTab);
}
}, [initialTab]);
const renderTab = () => {
if (currentTab == UserFriendModalTab.FriendsList) {
return <UserFriendModalList userId={userId} closeModal={onClose} />;
}
if (currentTab == UserFriendModalTab.AddFriend) {
return <UserFriendModalAddFriend closeModal={onClose} />;
}
return <></>;
};
const copyToClipboard = useCallback(() => {
navigator.clipboard.writeText(userDetails!.id);
showSuccessToast(t("friend_code_copied"));
}, [userDetails, showSuccessToast, t]);
return (
<Modal visible={visible} title={t("friends")} onClose={onClose}>
<div className="user-friend-modal__container">
{isMe && (
<>
<div className="user-friend-modal__header">
<p>{t("your_friend_code")}</p>
<button
className="user-friend-modal__friend-code-button"
onClick={copyToClipboard}
>
<h3>{userDetails.id}</h3>
<CopyIcon />
</button>
</div>
<section className="user-friend-modal__tabs">
{tabs.map((tab, index) => (
<Button
key={tab}
theme={index === currentTab ? "primary" : "outline"}
onClick={() => setCurrentTab(index)}
>
{tab}
</Button>
))}
</section>
</>
)}
{renderTab()}
</div>
</Modal>
);
};