mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
feat: implement add friend modal and enhance friends box with add friend functionality
This commit is contained in:
@@ -324,7 +324,8 @@ export function Header() {
|
||||
<SearchDropdown
|
||||
visible={
|
||||
isDropdownVisible &&
|
||||
(historyItems.length > 0 ||
|
||||
(searchValue.trim().length > 0 ||
|
||||
historyItems.length > 0 ||
|
||||
suggestions.length > 0 ||
|
||||
isLoadingSuggestions)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -428,9 +428,9 @@ export function ProfileContent() {
|
||||
{shouldShowRightContent && (
|
||||
<div className="profile-content__right-content">
|
||||
<UserStatsBox />
|
||||
<BadgesBox />
|
||||
<UserKarmaBox />
|
||||
<RecentGamesBox />
|
||||
<BadgesBox />
|
||||
<FriendsBox />
|
||||
<ReportProfile />
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user