feat: adding user report

This commit is contained in:
Chubby Granny Chaser 2024-09-14 20:55:00 +01:00
parent fcc24d6b94
commit 8799378bf2
No known key found for this signature in database
20 changed files with 463 additions and 228 deletions

View file

@ -1,56 +0,0 @@
// electron.vite.config.ts
import { resolve } from "path";
import {
defineConfig,
loadEnv,
swcPlugin,
externalizeDepsPlugin,
} from "electron-vite";
import react from "@vitejs/plugin-react";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr";
var sentryPlugin = sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher",
project: "hydra-launcher",
});
var electron_vite_config_default = defineConfig(({ mode }) => {
loadEnv(mode);
return {
main: {
build: {
sourcemap: true,
rollupOptions: {
external: ["better-sqlite3"],
},
},
resolve: {
alias: {
"@main": resolve("src/main"),
"@locales": resolve("src/locales"),
"@resources": resolve("resources"),
"@shared": resolve("src/shared"),
},
},
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
build: {
sourcemap: true,
},
resolve: {
alias: {
"@renderer": resolve("src/renderer/src"),
"@locales": resolve("src/locales"),
"@shared": resolve("src/shared"),
},
},
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],
},
};
});
export { electron_vite_config_default as default };

View file

@ -49,6 +49,8 @@ import "./user/get-blocked-users";
import "./user/block-user";
import "./user/unblock-user";
import "./user/get-user-friends";
import "./user/get-user-stats";
import "./user/report-user";
import "./profile/get-friend-requests";
import "./profile/get-me";
import "./profile/undo-friendship";

View file

@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import type { UserStats } from "@types";
export const getUserStats = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
): Promise<UserStats> => {
return HydraApi.get(`/users/${userId}/stats`);
};
registerEvent("getUserStats", getUserStats);

View file

@ -27,12 +27,7 @@ const getUser = async (
userId: string
): Promise<UserProfile | null> => {
try {
const [profile, friends] = await Promise.all([
HydraApi.get<UserProfile | null>(`/users/${userId}`),
getUserFriends(userId, 12, 0).catch(() => {
return { totalFriends: 0, friends: [] };
}),
]);
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
if (!profile) return null;
@ -77,10 +72,9 @@ const getUser = async (
...profile,
libraryGames,
recentGames,
friends: friends.friends,
totalFriends: friends.totalFriends,
};
} catch (err) {
console.log(err);
return null;
}
};

View file

@ -0,0 +1,16 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
export const reportUser = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
reason: string,
description: string
): Promise<void> => {
return HydraApi.post(`/users/${userId}/report`, {
reason,
description,
});
};
registerEvent("reportUser", reportUser);

View file

@ -161,6 +161,9 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getUserFriends", userId, take, skip),
getBlockedUsers: (take: number, skip: number) =>
ipcRenderer.invoke("getBlockedUsers", take, skip),
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
reportUser: (userId: string, reason: string, description: string) =>
ipcRenderer.invoke("reportUser", userId, reason, description),
/* Auth */
signOut: () => ipcRenderer.invoke("signOut"),

View file

@ -1,8 +1,8 @@
import { createContext, useEffect, useState } from "react";
import { createContext, useCallback, useEffect, useState } from "react";
import { setUserPreferences } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import type { UserPreferences } from "@types";
import type { UserBlocks, UserPreferences } from "@types";
import { useSearchParams } from "react-router-dom";
export interface SettingsContext {
@ -11,6 +11,8 @@ export interface SettingsContext {
clearSourceUrl: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
blockedUsers: UserBlocks["blocks"];
fetchBlockedUsers: () => Promise<void>;
}
export const settingsContext = createContext<SettingsContext>({
@ -19,6 +21,8 @@ export const settingsContext = createContext<SettingsContext>({
clearSourceUrl: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
blockedUsers: [],
fetchBlockedUsers: async () => {},
});
const { Provider } = settingsContext;
@ -35,6 +39,8 @@ export function SettingsContextProvider({
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
@ -48,6 +54,15 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
setBlockedUsers(blockedUsers.blocks);
}, []);
useEffect(() => {
fetchBlockedUsers();
}, [fetchBlockedUsers]);
const clearSourceUrl = () => setSourceUrl(null);
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
@ -63,8 +78,10 @@ export function SettingsContextProvider({
updateUserPreferences,
setCurrentCategoryIndex,
clearSourceUrl,
fetchBlockedUsers,
currentCategoryIndex,
sourceUrl,
blockedUsers,
}}
>
{children}

View file

@ -1,6 +1,6 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { UserProfile } from "@types";
import type { UserProfile, UserStats } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@ -12,6 +12,7 @@ export interface UserProfileContext {
heroBackground: string;
/* Indicates if the current user is viewing their own profile */
isMe: boolean;
userStats: UserStats | null;
getUserProfile: () => Promise<void>;
}
@ -22,6 +23,7 @@ export const userProfileContext = createContext<UserProfileContext>({
userProfile: null,
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
isMe: false,
userStats: null,
getUserProfile: async () => {},
});
@ -39,6 +41,8 @@ export function UserProfileContextProvider({
}: UserProfileContextProviderProps) {
const { userDetails } = useAppSelector((state) => state.userDetails);
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
@ -58,7 +62,15 @@ export function UserProfileContextProvider({
const { showErrorToast } = useToast();
const navigate = useNavigate();
const getUserStats = useCallback(async () => {
window.electron.getUserStats(userId).then((stats) => {
setUserStats(stats);
});
}, [userId]);
const getUserProfile = useCallback(async () => {
getUserStats();
return window.electron.getUser(userId).then((userProfile) => {
if (userProfile) {
setUserProfile(userProfile);
@ -73,7 +85,7 @@ export function UserProfileContextProvider({
navigate(-1);
}
});
}, [navigate, showErrorToast, userId, t]);
}, [navigate, getUserStats, showErrorToast, userId, t]);
useEffect(() => {
setUserProfile(null);
@ -89,6 +101,7 @@ export function UserProfileContextProvider({
heroBackground,
isMe: userDetails?.id === userProfile?.id,
getUserProfile,
userStats,
}}
>
{children}

View file

@ -22,6 +22,7 @@ import type {
UpdateProfileRequest,
GameStats,
TrendingGame,
UserStats,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -143,6 +144,12 @@ declare global {
skip: number
) => Promise<UserFriends>;
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
getUserStats: (userId: string) => Promise<UserStats>;
reportUser: (
userId: string,
reason: string,
description: string
) => Promise<void>;
/* Profile */
getMe: () => Promise<UserProfile | null>;

View file

@ -97,7 +97,7 @@ export const statsSection = style({
});
export const statsCategoryTitle = style({
fontSize: "16px",
fontSize: "14px",
fontWeight: "bold",
display: "flex",
alignItems: "center",

View file

@ -0,0 +1,43 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./profile-content.css";
import { Link } from "@renderer/components";
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
return (
<div>
<div className={styles.sectionHeader}>
<h2>{t("friends")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.friendsCount)}</span>
)}
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.friends.map((friend) => (
<li key={friend.id}>
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
<img
src={friend.profileImageUrl!}
alt={friend.displayName}
className={styles.listItemImage}
/>
<span className={styles.friendName}>{friend.displayName}</span>
</Link>
</li>
))}
</ul>
</div>
</div>
);
}

View file

@ -150,3 +150,29 @@ export const noGames = style({
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const listItemImage = style({
width: "32px",
height: "32px",
borderRadius: "4px",
});
export const listItemDetails = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
overflow: "hidden",
});
export const listItemTitle = style({
fontWeight: "bold",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
});
export const listItemDescription = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
});

View file

@ -1,5 +1,5 @@
import { userProfileContext } from "@renderer/context";
import { useCallback, useContext, useEffect, useMemo } from "react";
import { useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
@ -7,23 +7,26 @@ import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { ClockIcon, TelescopeIcon } from "@primer/octicons-react";
import { Link } from "@renderer/components";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { UserGame } from "@types";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserGame } from "@types";
import { buildGameDetailsPath } from "@renderer/helpers";
export function ProfileContent() {
const { userProfile, isMe } = useContext(userProfileContext);
const { userProfile, isMe, userStats } = useContext(userProfileContext);
const dispatch = useAppDispatch();
const { t } = useTranslation("user_profile");
useEffect(() => {
dispatch(setHeaderTitle(""));
if (userProfile) {
dispatch(setHeaderTitle(userProfile.displayName));
}
@ -33,22 +36,9 @@ export function ProfileContent() {
const navigate = useNavigate();
const formatPlayTime = useCallback(
(game: UserGame) => {
const seconds = game?.playTimeInSeconds || 0;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
@ -56,10 +46,6 @@ export function ProfileContent() {
objectID: game.objectId,
});
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
const content = useMemo(() => {
if (!userProfile) return null;
@ -95,9 +81,9 @@ export function ProfileContent() {
<div className={styles.sectionHeader}>
<h2>{t("library")}</h2>
<span>
{numberFormatter.format(userProfile.libraryGames.length)}
</span>
{userStats && (
<span>{numberFormatter.format(userStats.libraryCount)}</span>
)}
</div>
<ul className={styles.gamesGrid}>
@ -135,112 +121,14 @@ export function ProfileContent() {
</div>
<div className={styles.rightContent}>
{userProfile?.recentGames?.length > 0 && (
<div>
<div className={styles.sectionHeader}>
<h2>{t("activity")}</h2>
</div>
<RecentGamesBox />
<FriendsBox />
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className={styles.listItem}
>
<img
src={game.iconUrl!}
alt={game.title}
style={{
width: "32px",
height: "32px",
borderRadius: "4px",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
overflow: "hidden",
}}
>
<span
style={{
fontWeight: "bold",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
}}
>
{game.title}
</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
)}
<div>
<div className={styles.sectionHeader}>
<h2>{t("friends")}</h2>
<span>{numberFormatter.format(userProfile?.totalFriends)}</span>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.friends.map((friend) => (
<li key={friend.id}>
<Link
to={`/profile/${friend.id}`}
className={styles.listItem}
>
<img
src={friend.profileImageUrl!}
alt={friend.displayName}
style={{
width: "32px",
height: "32px",
borderRadius: "4px",
}}
/>
<span className={styles.friendName}>
{friend.displayName}
</span>
</Link>
</li>
))}
</ul>
</div>
</div>
<ReportProfile />
</div>
</section>
);
}, [
userProfile,
formatPlayTime,
numberFormatter,
t,
usersAreFriends,
isMe,
navigate,
]);
}, [userProfile, isMe, usersAreFriends, numberFormatter, t, navigate]);
return (
<div>

View file

@ -0,0 +1,80 @@
import { buildGameDetailsPath } from "@renderer/helpers";
import * as styles from "./profile-content.css";
import { Link } from "@renderer/components";
import { useCallback, useContext } from "react";
import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { ClockIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks";
import type { UserGame } from "@types";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function RecentGamesBox() {
const { userProfile } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const formatPlayTime = useCallback(
(game: UserGame) => {
const seconds = game?.playTimeInSeconds || 0;
const minutes = seconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
const buildUserGameDetailsPath = (game: UserGame) =>
buildGameDetailsPath({
...game,
objectID: game.objectId,
});
if (!userProfile?.recentGames.length) return null;
return (
<div>
<div className={styles.sectionHeader}>
<h2>{t("activity")}</h2>
</div>
<div className={styles.box}>
<ul className={styles.list}>
{userProfile?.recentGames.map((game) => (
<li key={`${game.shop}-${game.objectId}`}>
<Link
to={buildUserGameDetailsPath(game)}
className={styles.listItem}
>
<img
src={game.iconUrl!}
alt={game.title}
className={styles.listItemImage}
/>
<div className={styles.listItemDetails}>
<span className={styles.listItemTitle}>{game.title}</span>
<div className={styles.listItemDescription}>
<ClockIcon />
<small>{formatPlayTime(game)}</small>
</div>
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
);
}

View file

@ -167,14 +167,26 @@ export function ProfileHero() {
if (userProfile.relation.status === "ACCEPTED") {
return (
<Button
theme="outline"
onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")}
disabled={isPerformingAction}
>
<XCircleFillIcon />
{t("undo_friendship")}
</Button>
<>
<Button
theme="danger"
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
disabled={isPerformingAction}
>
<BlockedIcon />
{t("block_user")}
</Button>
<Button
theme="outline"
onClick={() =>
handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")
}
disabled={isPerformingAction}
>
<XCircleFillIcon />
{t("undo_friendship")}
</Button>
</>
);
}

View file

@ -0,0 +1,12 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const reportButton = style({
alignSelf: "flex-end",
color: vars.color.muted,
gap: `${SPACING_UNIT}px`,
display: "flex",
cursor: "pointer",
alignItems: "center",
fontSize: "12px",
});

View file

@ -0,0 +1,131 @@
import { ReportIcon } from "@primer/octicons-react";
import * as styles from "./report-profile.css";
import { Button, Modal, SelectField, TextField } from "@renderer/components";
import { useCallback, useContext, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as yup from "yup";
import { SPACING_UNIT } from "@renderer/theme.css";
import { userProfileContext } from "@renderer/context";
import { yupResolver } from "@hookform/resolvers/yup";
import { useToast } from "@renderer/hooks";
const reportReasons = ["hate", "sexual_content", "violence", "spam", "other"];
interface FormValues {
reason: string;
description: string;
}
export function ReportProfile() {
const [showReportProfileModal, setShowReportProfileModal] = useState(false);
const { userProfile, isMe } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const schema = yup.object().shape({
reason: yup.string().required(t("required_field")),
description: yup.string().required(t("required_field")),
});
const {
register,
control,
formState: { isSubmitting, errors },
reset,
handleSubmit,
} = useForm<FormValues>({
resolver: yupResolver(schema),
defaultValues: {
reason: "hate",
description: "",
},
});
const { showSuccessToast } = useToast();
useEffect(() => {
reset({
reason: "hate",
description: "",
});
}, [userProfile, reset]);
const onSubmit = useCallback(
async (values: FormValues) => {
return window.electron
.reportUser(userProfile!.id, values.reason, values.description)
.then(() => {
showSuccessToast(t("profile_reported"));
setShowReportProfileModal(false);
});
},
[userProfile, showSuccessToast, t]
);
if (isMe) return null;
return (
<>
<Modal
visible={showReportProfileModal}
onClose={() => setShowReportProfileModal(false)}
title="Report this profile"
clickOutsideToClose={false}
>
<form
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<Controller
control={control}
name="reason"
render={({ field }) => {
return (
<SelectField
label={t("report_reason")}
value={field.value}
onChange={field.onChange}
options={reportReasons.map((reason) => ({
key: reason,
value: reason,
label: t(`report_reason_${reason}`),
}))}
disabled={isSubmitting}
/>
);
}}
/>
<TextField
{...register("description")}
label={t("report_description")}
placeholder={t("report_description_placeholder")}
error={errors.description}
/>
<Button
style={{ marginTop: `${SPACING_UNIT}px`, alignSelf: "flex-end" }}
onClick={handleSubmit(onSubmit)}
>
{t("report")}
</Button>
</form>
</Modal>
<button
type="button"
className={styles.reportButton}
onClick={() => setShowReportProfileModal(true)}
>
<ReportIcon size={13} />
{t("report_profile")}
</button>
</>
);
}

View file

@ -12,6 +12,7 @@ export const blockedUserAvatar = style({
width: "32px",
height: "32px",
borderRadius: "4px",
filter: "grayscale(100%)",
});
export const blockedUser = style({
@ -28,4 +29,19 @@ export const blockedUser = style({
export const unblockButton = style({
color: vars.color.muted,
cursor: "pointer",
transition: "all ease 0.2s",
":hover": {
opacity: "0.7",
},
});
export const blockedUsersList = style({
padding: "0",
margin: "0",
listStyle: "none",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT}px`,
marginTop: `${SPACING_UNIT}px`,
});

View file

@ -5,8 +5,9 @@ import { useTranslation } from "react-i18next";
import * as styles from "./settings-privacy.css";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { XCircleFillIcon } from "@primer/octicons-react";
import { settingsContext } from "@renderer/context";
interface FormValues {
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
@ -15,8 +16,12 @@ interface FormValues {
export function SettingsPrivacy() {
const { t } = useTranslation("settings");
const [isUnblocking, setIsUnblocking] = useState(false);
const { showSuccessToast } = useToast();
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
const {
control,
formState: { isSubmitting },
@ -26,7 +31,7 @@ export function SettingsPrivacy() {
const { patchUser, userDetails } = useUserDetails();
const [blockedUsers, setBlockedUsers] = useState<any[]>([]);
const { unblockUser } = useUserDetails();
useEffect(() => {
if (userDetails?.profileVisibility) {
@ -34,14 +39,6 @@ export function SettingsPrivacy() {
}
}, [userDetails, setValue]);
useEffect(() => {
window.electron.getBlockedUsers(12, 0).then((users) => {
setBlockedUsers(users.blocks);
});
}, []);
console.log("BLOCKED USERS", blockedUsers);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },
{ value: "FRIENDS", label: t("friends_only") },
@ -53,6 +50,25 @@ export function SettingsPrivacy() {
showSuccessToast(t("changes_saved"));
};
const handleUnblockClick = useCallback(
(id: string) => {
setIsUnblocking(true);
unblockUser(id)
.then(() => {
fetchBlockedUsers();
// show toast
})
.catch((err) => {
//show toast
})
.finally(() => {
setIsUnblocking(false);
});
},
[unblockUser, fetchBlockedUsers]
);
return (
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<Controller
@ -90,14 +106,7 @@ export function SettingsPrivacy() {
Usuários bloqueados
</h3>
<ul
style={{
padding: 0,
margin: 0,
listStyle: "none",
display: "flex",
}}
>
<ul className={styles.blockedUsersList}>
{blockedUsers.map((user) => {
return (
<li key={user.id} className={styles.blockedUser}>
@ -116,7 +125,12 @@ export function SettingsPrivacy() {
<span>{user.displayName}</span>
</div>
<button type="button" className={styles.unblockButton}>
<button
type="button"
className={styles.unblockButton}
onClick={() => handleUnblockClick(user.id)}
disabled={isUnblocking}
>
<XCircleFillIcon />
</button>
</li>

View file

@ -235,5 +235,10 @@ export interface TrendingGame {
logo: string | null;
}
export interface UserStats {
libraryCount: number;
friendsCount: number;
}
export * from "./steam.types";
export * from "./real-debrid.types";