feat: implement add friend modal and enhance friends box with add friend functionality

This commit is contained in:
Moyasee
2025-12-18 22:41:28 +02:00
parent cf16c8245c
commit 6cd65d6239
12 changed files with 289 additions and 119 deletions

View File

@@ -324,7 +324,8 @@ export function Header() {
<SearchDropdown
visible={
isDropdownVisible &&
(historyItems.length > 0 ||
(searchValue.trim().length > 0 ||
historyItems.length > 0 ||
suggestions.length > 0 ||
isLoadingSuggestions)
}

View File

@@ -5,7 +5,7 @@
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
max-height: 300px;
max-height: 350px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import cn from "classnames";
@@ -92,23 +92,8 @@ export function SearchDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [visible, onClose, searchContainerRef]);
const handleItemClick = useCallback(
(
type: "history" | "suggestion",
item: SearchHistoryEntry | SearchSuggestion
) => {
if (type === "history") {
onSelectHistory((item as SearchHistoryEntry).query);
} else {
onSelectSuggestion(item as SearchSuggestion);
}
},
[onSelectHistory, onSelectSuggestion]
);
if (!visible) return null;
const totalItems = historyItems.length + suggestions.length;
const hasHistory = historyItems.length > 0;
const hasSuggestions = suggestions.length > 0;
@@ -158,7 +143,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("history", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("history", item)}
onClick={() => onSelectHistory(item.query)}
>
<ClockIcon size={16} className="search-dropdown__item-icon" />
<span className="search-dropdown__item-text">
@@ -200,7 +185,7 @@ export function SearchDropdown({
activeIndex === getItemIndex("suggestion", index),
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleItemClick("suggestion", item)}
onClick={() => onSelectSuggestion(item)}
>
{item.iconUrl ? (
<img
@@ -227,13 +212,6 @@ export function SearchDropdown({
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
<div className="search-dropdown__loading">{t("loading")}</div>
)}
{!isLoadingSuggestions &&
!hasHistory &&
!hasSuggestions &&
totalItems === 0 && (
<div className="search-dropdown__empty">{t("no_results")}</div>
)}
</div>
);

View File

@@ -0,0 +1,54 @@
@use "../../../scss/globals.scss";
.add-friend-modal {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
width: 100%;
min-width: 400px;
&__actions {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: globals.$spacing-unit;
}
&__button {
align-self: flex-end;
white-space: nowrap;
}
&__pending-status {
color: globals.$body-color;
font-size: globals.$small-font-size;
text-align: center;
padding: calc(globals.$spacing-unit / 2);
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-top: calc(globals.$spacing-unit * -1);
}
&__pending-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
h3 {
margin: 0;
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
}
&__pending-list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 300px;
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
}

View File

@@ -0,0 +1,160 @@
import { 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 {
readonly visible: boolean;
readonly onClose: () => void;
}
export function AddFriendModal({ visible, onClose }: AddFriendModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const {
sendFriendRequest,
updateFriendRequestState,
friendRequests,
fetchFriendRequests,
} = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
if (visible) {
setFriendCode("");
fetchFriendRequests();
}
}, [visible, fetchFriendRequests]);
const handleChangeFriendCode = (e: React.ChangeEvent<HTMLInputElement>) => {
const code = e.target.value.trim().slice(0, 8);
setFriendCode(code);
};
const validateFriendCode = (callback: () => void) => {
if (friendCode.length === 8) {
return callback();
}
showErrorToast(t("friend_code_length_error"));
};
const handleClickAddFriend = () => {
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
setFriendCode("");
showSuccessToast(t("request_sent"));
})
.catch(() => {
showErrorToast(t("error_adding_friend"));
})
.finally(() => {
setIsAddingFriend(false);
});
};
const handleClickSeeProfile = () => {
if (friendCode.length === 8) {
onClose();
navigate(`/profile/${friendCode}`);
}
};
const handleClickRequest = (userId: string) => {
onClose();
navigate(`/profile/${userId}`);
};
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 sentRequests = friendRequests.filter((req) => req.type === "SENT");
const currentRequest =
friendCode.length === 8
? sentRequests.find((req) => req.id === friendCode)
: null;
return (
<Modal visible={visible} title={t("add_friends")} onClose={onClose}>
<div className="add-friend-modal">
<div className="add-friend-modal__actions">
<TextField
label={t("friend_code")}
value={friendCode}
containerProps={{ style: { flex: 1 } }}
onChange={handleChangeFriendCode}
/>
<Button
disabled={isAddingFriend}
type="button"
className="add-friend-modal__button"
onClick={() => validateFriendCode(handleClickAddFriend)}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
theme="outline"
onClick={() => validateFriendCode(handleClickSeeProfile)}
disabled={isAddingFriend}
className="add-friend-modal__button"
type="button"
>
{t("see_profile")}
</Button>
</div>
{currentRequest && (
<div className="add-friend-modal__pending-status">{t("pending")}</div>
)}
{sentRequests.length > 0 && (
<div className="add-friend-modal__pending-container">
<h3>{t("pending")}</h3>
<div className="add-friend-modal__pending-list">
{sentRequests.map((request) => (
<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>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -98,27 +98,4 @@
justify-content: center;
padding-top: globals.$spacing-unit;
}
&__remove {
flex-shrink: 0;
background: none;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: globals.$spacing-unit;
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
color: globals.$error-color;
background-color: rgba(globals.$error-color, 0.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}

View File

@@ -1,9 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { XCircleIcon } from "@primer/octicons-react";
import { Modal, Avatar, Button } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import type { UserFriend } from "@types";
@@ -26,15 +24,12 @@ export function AllFriendsModal({
}: AllFriendsModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const { undoFriendship } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const [friends, setFriends] = useState<UserFriend[]>([]);
const [totalFriends, setTotalFriends] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const [removingId, setRemovingId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const fetchFriends = useCallback(
@@ -99,26 +94,6 @@ export function AllFriendsModal({
}
};
const handleRemoveFriend = useCallback(
async (e: React.MouseEvent, friendId: string) => {
e.stopPropagation();
setRemovingId(friendId);
try {
await undoFriendship(friendId);
setFriends((prev) => prev.filter((f) => f.id !== friendId));
setTotalFriends((prev) => prev - 1);
showSuccessToast(t("friendship_removed"));
} catch (error) {
logger.error("Failed to remove friend", error);
showErrorToast(t("try_again"));
} finally {
setRemovingId(null);
}
},
[undoFriendship, showSuccessToast, showErrorToast, t]
);
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return <img alt={game.title} width={16} height={16} src={game.iconUrl} />;
@@ -177,17 +152,6 @@ export function AllFriendsModal({
</div>
)}
</div>
{isMe && (
<button
type="button"
className="all-friends-modal__remove"
onClick={(e) => handleRemoveFriend(e, friend.id)}
disabled={removingId === friend.id}
title={t("undo_friendship")}
>
<XCircleIcon size={20} />
</button>
)}
</div>
))}
</div>

View File

@@ -13,6 +13,33 @@
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
position: relative;
}
&__add-friend-button {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
&:hover {
color: globals.$muted-color;
}
}
&__view-all-container {
background-color: globals.$background-color;
padding-top: calc(globals.$spacing-unit * 2);
margin-top: calc(globals.$spacing-unit * 2);
display: flex;
justify-content: flex-start;
}
&__list {

View File

@@ -2,9 +2,11 @@ import { userProfileContext } from "@renderer/context";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusIcon } from "@primer/octicons-react";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar, Link } from "@renderer/components";
import { AllFriendsModal } from "./all-friends-modal";
import { AddFriendModal } from "./add-friend-modal";
import "./friends-box.scss";
export function FriendsBox() {
@@ -13,6 +15,7 @@ export function FriendsBox() {
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const [showAllFriendsModal, setShowAllFriendsModal] = useState(false);
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
@@ -45,13 +48,16 @@ export function FriendsBox() {
</span>
)}
</div>
{isMe && (
<button
type="button"
className="friends-box__view-all"
onClick={() => setShowAllFriendsModal(true)}
className="friends-box__add-friend-button"
onClick={() => setShowAddFriendModal(true)}
>
{t("view_all")}
<PlusIcon size={16} />
{t("add_friends")}
</button>
)}
</div>
<div className="friends-box__box">
@@ -90,16 +96,31 @@ export function FriendsBox() {
</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>
</div>
{userProfile && (
<>
<AllFriendsModal
visible={showAllFriendsModal}
onClose={() => setShowAllFriendsModal(false)}
userId={userProfile.id}
isMe={isMe}
/>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
</>
)}
</>
);

View File

@@ -428,9 +428,9 @@ export function ProfileContent() {
{shouldShowRightContent && (
<div className="profile-content__right-content">
<UserStatsBox />
<BadgesBox />
<UserKarmaBox />
<RecentGamesBox />
<BadgesBox />
<FriendsBox />
<ReportProfile />
</div>

View File

@@ -34,15 +34,15 @@
background: none;
border: none;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.3);
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 4px;
padding: calc(globals.$spacing-unit / 1.5);
border-radius: 6px;
transition: all ease 0.2s;
opacity: 0.7;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
background-color: rgba(0, 0, 0, 0.4);
}
}

View File

@@ -2,7 +2,7 @@ import type { UserFriend } from "@types";
import { useEffect, useRef, useState } from "react";
import { UserFriendItem } from "./user-friend-item";
import { useNavigate } from "react-router-dom";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import "./user-friend-modal-list.scss";
@@ -19,7 +19,6 @@ export const UserFriendModalList = ({
closeModal,
}: UserFriendModalListProps) => {
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const navigate = useNavigate();
const [page, setPage] = useState(0);
@@ -28,7 +27,7 @@ export const UserFriendModalList = ({
const [friends, setFriends] = useState<UserFriend[]>([]);
const listContainer = useRef<HTMLDivElement>(null);
const { userDetails, undoFriendship } = useUserDetails();
const { userDetails } = useUserDetails();
const isMe = userDetails?.id == userId;
const loadNextPage = () => {
@@ -88,16 +87,6 @@ export const UserFriendModalList = ({
navigate(`/profile/${userId}`);
};
const handleUndoFriendship = (userId: string) => {
undoFriendship(userId)
.then(() => {
reloadList();
})
.catch(() => {
showErrorToast(t("try_again"));
});
};
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
<div ref={listContainer} className="user-friend-modal-list">
@@ -108,8 +97,7 @@ export const UserFriendModalList = ({
displayName={friend.displayName}
profileImageUrl={friend.profileImageUrl}
onClickItem={handleClickFriend}
onClickUndoFriendship={handleUndoFriendship}
type={isMe ? "ACCEPTED" : null}
type={null}
key={"modal" + friend.id}
/>
))}