feat: use new endpoint to get compared achievements

This commit is contained in:
Zamitto 2024-10-19 17:23:26 -03:00
parent 89bb099caa
commit f0a2bf2f48
10 changed files with 291 additions and 203 deletions

View File

@ -50,6 +50,7 @@ import "./user/unblock-user";
import "./user/get-user-friends"; import "./user/get-user-friends";
import "./user/get-user-stats"; import "./user/get-user-stats";
import "./user/report-user"; import "./user/report-user";
import "./user/get-compared-unlocked-achievements";
import "./profile/get-friend-requests"; import "./profile/get-friend-requests";
import "./profile/get-me"; import "./profile/get-me";
import "./profile/undo-friendship"; import "./profile/undo-friendship";

View File

@ -0,0 +1,44 @@
import type { ComparedAchievements, GameShop } from "@types";
import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { HydraApi } from "@main/services";
const getComparedUnlockedAchievements = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
userId: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
return HydraApi.get<ComparedAchievements>(
`/users/${userId}/games/achievements/compare`,
{
shop,
objectId,
language: userPreferences?.language || "en",
}
).then((achievements) => {
const sortedAchievements = achievements.achievements.sort((a, b) => {
if (a.otherUserStat.unlocked && !b.otherUserStat.unlocked) return -1;
if (!a.otherUserStat.unlocked && b.otherUserStat.unlocked) return 1;
if (a.otherUserStat.unlocked && b.otherUserStat.unlocked) {
return b.otherUserStat.unlockTime! - a.otherUserStat.unlockTime!;
}
return Number(a.hidden) - Number(b.hidden);
});
return {
...achievements,
achievements: sortedAchievements,
} as ComparedAchievements;
});
};
registerEvent(
"getComparedUnlockedAchievements",
getComparedUnlockedAchievements
);

View File

@ -259,6 +259,17 @@ contextBridge.exposeInMainWorld("electron", {
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId), getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
reportUser: (userId: string, reason: string, description: string) => reportUser: (userId: string, reason: string, description: string) =>
ipcRenderer.invoke("reportUser", userId, reason, description), ipcRenderer.invoke("reportUser", userId, reason, description),
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,
userId: string
) =>
ipcRenderer.invoke(
"getComparedUnlockedAchievements",
objectId,
shop,
userId
),
/* Auth */ /* Auth */
signOut: () => ipcRenderer.invoke("signOut"), signOut: () => ipcRenderer.invoke("signOut"),

View File

@ -29,6 +29,7 @@ import type {
GameArtifact, GameArtifact,
LudusaviBackup, LudusaviBackup,
UserAchievement, UserAchievement,
ComparedAchievements,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -202,6 +203,11 @@ declare global {
reason: string, reason: string,
description: string description: string
) => Promise<void>; ) => Promise<void>;
getComparedUnlockedAchievements: (
objectId: string,
shop: GameShop,
userId: string
) => Promise<ComparedAchievements>;
/* Profile */ /* Profile */
getMe: () => Promise<UserDetails | null>; getMe: () => Promise<UserDetails | null>;

View File

@ -36,15 +36,13 @@ export const buildGameDetailsPath = (
export const buildGameAchievementPath = ( export const buildGameAchievementPath = (
game: { shop: GameShop; objectId: string; title: string }, game: { shop: GameShop; objectId: string; title: string },
user?: { userId: string; displayName: string; profileImageUrl: string | null } user?: { userId: string }
) => { ) => {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
title: game.title, title: game.title,
shop: game.shop, shop: game.shop,
objectId: game.objectId, objectId: game.objectId,
userId: user?.userId || "", userId: user?.userId || "",
displayName: user?.displayName || "",
profileImageUrl: user?.profileImageUrl || "",
}); });
return `/achievements/?${searchParams.toString()}`; return `/achievements/?${searchParams.toString()}`;

View File

@ -1,40 +1,37 @@
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks"; import { useAppDispatch, useDate, useUserDetails } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./achievements.css"; import * as styles from "./achievements.css";
import { import {
buildGameDetailsPath, buildGameDetailsPath,
formatDownloadProgress, formatDownloadProgress,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
CheckCircleIcon,
LockIcon,
PersonIcon,
TrophyIcon,
} from "@primer/octicons-react";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { UserAchievement } from "@types"; import { ComparedAchievements, UserAchievement } from "@types";
import { average } from "color.js"; import { average } from "color.js";
import Color from "color"; import Color from "color";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list";
interface UserInfo { interface UserInfo {
userId: string; userId: string;
displayName: string; displayName: string;
achievements: UserAchievement[];
profileImageUrl: string | null; profileImageUrl: string | null;
totalAchievementCount: number;
unlockedAchievementCount: number;
} }
interface AchievementsContentProps { interface AchievementsContentProps {
otherUser: UserInfo | null; otherUser: UserInfo | null;
comparedAchievements: ComparedAchievements | null;
} }
interface AchievementListProps { interface AchievementListProps {
user: UserInfo; achievements: UserAchievement[];
otherUser: UserInfo | null;
} }
interface AchievementSummaryProps { interface AchievementSummaryProps {
@ -46,11 +43,6 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
const { t } = useTranslation("achievement"); const { t } = useTranslation("achievement");
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
const userTotalAchievementCount = user.achievements.length;
const userUnlockedAchievementCount = user.achievements.filter(
(achievement) => achievement.unlocked
).length;
const getProfileImage = (user: UserInfo) => { const getProfileImage = (user: UserInfo) => {
return ( return (
<div className={styles.profileAvatar}> <div className={styles.profileAvatar}>
@ -155,19 +147,19 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
> >
<TrophyIcon size={13} /> <TrophyIcon size={13} />
<span> <span>
{userUnlockedAchievementCount} / {userTotalAchievementCount} {user.unlockedAchievementCount} / {user.totalAchievementCount}
</span> </span>
</div> </div>
<span> <span>
{formatDownloadProgress( {formatDownloadProgress(
userUnlockedAchievementCount / userTotalAchievementCount user.unlockedAchievementCount / user.totalAchievementCount
)} )}
</span> </span>
</div> </div>
<progress <progress
max={1} max={1}
value={userUnlockedAchievementCount / userTotalAchievementCount} value={user.unlockedAchievementCount / user.totalAchievementCount}
className={styles.achievementsProgressBar} className={styles.achievementsProgressBar}
/> />
</div> </div>
@ -175,132 +167,30 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
); );
} }
function AchievementList({ user, otherUser }: AchievementListProps) { function AchievementList({ achievements }: AchievementListProps) {
const achievements = user.achievements;
const otherUserAchievements = otherUser?.achievements;
const { t } = useTranslation("achievement"); const { t } = useTranslation("achievement");
const { formatDateTime } = useDate(); const { formatDateTime } = useDate();
const { hasActiveSubscription } = useUserDetails();
if (!otherUserAchievements || otherUserAchievements.length === 0) {
return (
<ul className={styles.list}>
{achievements.map((achievement, index) => (
<li
key={index}
className={styles.listItem}
style={{ display: "flex" }}
>
<img
className={styles.listItemImage({
unlocked: achievement.unlocked,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div style={{ flex: 1 }}>
<h4>{achievement.displayName}</h4>
<p>{achievement.description}</p>
</div>
{achievement.unlockTime && (
<div style={{ whiteSpace: "nowrap" }}>
<small>{t("unlocked_at")}</small>
<p>{formatDateTime(achievement.unlockTime)}</p>
</div>
)}
</li>
))}
</ul>
);
}
return ( return (
<ul className={styles.list}> <ul className={styles.list}>
{otherUserAchievements.map((otherUserAchievement, index) => ( {achievements.map((achievement, index) => (
<li <li key={index} className={styles.listItem} style={{ display: "flex" }}>
key={index} <img
className={styles.listItem} className={styles.listItemImage({
style={{ unlocked: achievement.unlocked,
display: "grid", })}
gridTemplateColumns: hasActiveSubscription src={achievement.icon}
? "3fr 1fr 1fr" alt={achievement.displayName}
: "3fr 2fr", loading="lazy"
}} />
> <div style={{ flex: 1 }}>
<div <h4>{achievement.displayName}</h4>
style={{ <p>{achievement.description}</p>
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<img
className={styles.listItemImage({
unlocked: true,
})}
src={otherUserAchievement.icon}
alt={otherUserAchievement.displayName}
loading="lazy"
/>
<div>
<h4>{otherUserAchievement.displayName}</h4>
<p>{otherUserAchievement.description}</p>
</div>
</div> </div>
{achievement.unlockTime && (
{hasActiveSubscription ? ( <div style={{ whiteSpace: "nowrap" }}>
achievements[index].unlocked ? ( <small>{t("unlocked_at")}</small>
<div <p>{formatDateTime(achievement.unlockTime)}</p>
style={{
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<CheckCircleIcon />
<small>{formatDateTime(achievements[index].unlockTime!)}</small>
</div>
) : (
<div
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon />
</div>
)
) : null}
{otherUserAchievement.unlocked ? (
<div
style={{
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<CheckCircleIcon />
<small>{formatDateTime(otherUserAchievement.unlockTime!)}</small>
</div>
) : (
<div
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon />
</div> </div>
)} )}
</li> </li>
@ -309,7 +199,10 @@ function AchievementList({ user, otherUser }: AchievementListProps) {
); );
} }
export function AchievementsContent({ otherUser }: AchievementsContentProps) { export function AchievementsContent({
otherUser,
comparedAchievements,
}: AchievementsContentProps) {
const heroRef = useRef<HTMLDivElement | null>(null); const heroRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false); const [isHeaderStuck, setIsHeaderStuck] = useState(false);
@ -317,20 +210,6 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } = const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } =
useContext(gameDetailsContext); useContext(gameDetailsContext);
const sortedAchievements = useMemo(() => {
if (!otherUser || otherUser.achievements.length === 0) return achievements!;
return achievements!.sort((a, b) => {
const indexA = otherUser.achievements.findIndex(
(achievement) => achievement.name === a.name
);
const indexB = otherUser.achievements.findIndex(
(achievement) => achievement.name === b.name
);
return indexA - indexB;
});
}, [achievements, otherUser]);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { userDetails, hasActiveSubscription } = useUserDetails(); const { userDetails, hasActiveSubscription } = useUserDetails();
@ -367,14 +246,17 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
} }
}; };
const getProfileImage = (user: UserInfo) => { const getProfileImage = (
profileImageUrl: string | null,
displayName: string
) => {
return ( return (
<div className={styles.profileAvatarSmall}> <div className={styles.profileAvatarSmall}>
{user.profileImageUrl ? ( {profileImageUrl ? (
<img <img
className={styles.profileAvatarSmall} className={styles.profileAvatarSmall}
src={user.profileImageUrl} src={profileImageUrl}
alt={user.displayName} alt={displayName}
/> />
) : ( ) : (
<PersonIcon size={24} /> <PersonIcon size={24} />
@ -434,7 +316,13 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
user={{ user={{
...userDetails, ...userDetails,
userId: userDetails.id, userId: userDetails.id,
achievements: sortedAchievements, totalAchievementCount: comparedAchievements
? comparedAchievements.ownerUser.totalAchievementCount
: achievements!.length,
unlockedAchievementCount: comparedAchievements
? comparedAchievements.ownerUser.unlockedAchievementCount
: achievements!.filter((achievement) => achievement.unlocked)
.length,
}} }}
isComparison={otherUser !== null} isComparison={otherUser !== null}
/> />
@ -458,15 +346,17 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
<div></div> <div></div>
{hasActiveSubscription && ( {hasActiveSubscription && (
<div style={{ display: "flex", justifyContent: "center" }}> <div style={{ display: "flex", justifyContent: "center" }}>
{getProfileImage({ {getProfileImage(
...userDetails, userDetails.profileImageUrl,
userId: userDetails.id, userDetails.displayName
achievements: sortedAchievements, )}
})}
</div> </div>
)} )}
<div style={{ display: "flex", justifyContent: "center" }}> <div style={{ display: "flex", justifyContent: "center" }}>
{getProfileImage(otherUser)} {getProfileImage(
otherUser.profileImageUrl,
otherUser.displayName
)}
</div> </div>
</div> </div>
</div> </div>
@ -480,14 +370,11 @@ export function AchievementsContent({ otherUser }: AchievementsContentProps) {
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
}} }}
> >
<AchievementList {otherUser ? (
user={{ <ComparedAchievementList achievements={comparedAchievements!} />
...userDetails, ) : (
userId: userDetails.id, <AchievementList achievements={achievements!} />
achievements: sortedAchievements, )}
}}
otherUser={otherUser}
/>
</div> </div>
</section> </section>
</div> </div>

View File

@ -1,7 +1,7 @@
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useUserDetails } from "@renderer/hooks"; import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import type { GameShop, UserAchievement } from "@types"; import type { ComparedAchievements, GameShop } from "@types";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { vars } from "@renderer/theme.css"; import { vars } from "@renderer/theme.css";
import { import {
@ -18,14 +18,11 @@ export default function Achievements() {
const shop = searchParams.get("shop"); const shop = searchParams.get("shop");
const title = searchParams.get("title"); const title = searchParams.get("title");
const userId = searchParams.get("userId"); const userId = searchParams.get("userId");
const displayName = searchParams.get("displayName");
const profileImageUrl = searchParams.get("profileImageUrl");
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
const [otherUserAchievements, setOtherUserAchievements] = useState< const [comparedAchievements, setComparedAchievements] =
UserAchievement[] | null useState<ComparedAchievements | null>(null);
>(null);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -36,31 +33,34 @@ export default function Achievements() {
}, [dispatch, title]); }, [dispatch, title]);
useEffect(() => { useEffect(() => {
setOtherUserAchievements(null); setComparedAchievements(null);
if (userDetails?.id == userId) { if (userDetails?.id == userId) {
setOtherUserAchievements([]);
return; return;
} }
if (objectId && shop && userId) { if (objectId && shop && userId) {
window.electron window.electron
.getGameAchievements(objectId, shop as GameShop, userId) .getComparedUnlockedAchievements(objectId, shop as GameShop, userId)
.then((achievements) => { .then(setComparedAchievements);
setOtherUserAchievements(achievements);
});
} }
}, [objectId, shop, userId]); }, [objectId, shop, userId]);
const otherUserId = userDetails?.id === userId ? null : userId; const otherUserId = userDetails?.id === userId ? null : userId;
const otherUser = otherUserId const otherUser = useMemo(() => {
? { if (!otherUserId || !comparedAchievements) return null;
userId: otherUserId,
displayName: displayName || "", return {
achievements: otherUserAchievements || [], userId: otherUserId,
profileImageUrl: profileImageUrl || "", displayName: comparedAchievements.otherUser.displayName,
} profileImageUrl: comparedAchievements.otherUser.profileImageUrl,
: null; totalAchievementCount:
comparedAchievements.otherUser.totalAchievementCount,
unlockedAchievementCount:
comparedAchievements.otherUser.unlockedAchievementCount,
};
}, [otherUserId, comparedAchievements]);
return ( return (
<GameDetailsContextProvider <GameDetailsContextProvider
@ -70,17 +70,23 @@ export default function Achievements() {
> >
<GameDetailsContextConsumer> <GameDetailsContextConsumer>
{({ isLoading, achievements }) => { {({ isLoading, achievements }) => {
const showSkeleton =
isLoading ||
achievements === null ||
(otherUserId && comparedAchievements === null);
return ( return (
<SkeletonTheme <SkeletonTheme
baseColor={vars.color.background} baseColor={vars.color.background}
highlightColor="#444" highlightColor="#444"
> >
{isLoading || {showSkeleton ? (
achievements === null ||
(otherUserId && otherUserAchievements === null) ? (
<AchievementsSkeleton /> <AchievementsSkeleton />
) : ( ) : (
<AchievementsContent otherUser={otherUser} /> <AchievementsContent
otherUser={otherUser}
comparedAchievements={comparedAchievements!}
/>
)} )}
</SkeletonTheme> </SkeletonTheme>
); );

View File

@ -0,0 +1,110 @@
import type { ComparedAchievements } from "@types";
import * as styles from "./achievements.css";
import { CheckCircleIcon, LockIcon } from "@primer/octicons-react";
import { useDate } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface ComparedAchievementListProps {
achievements: ComparedAchievements;
}
export function ComparedAchievementList({
achievements,
}: ComparedAchievementListProps) {
const { formatDateTime } = useDate();
return (
<ul className={styles.list}>
{achievements.achievements.map((achievement, index) => (
<li
key={index}
className={styles.listItem}
style={{
display: "grid",
gridTemplateColumns: achievement.onwerUserStat
? "3fr 1fr 1fr"
: "3fr 2fr",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<img
className={styles.listItemImage({
unlocked: true,
})}
src={achievement.icon}
alt={achievement.displayName}
loading="lazy"
/>
<div>
<h4>{achievement.displayName}</h4>
<p>{achievement.description}</p>
</div>
</div>
{achievement.onwerUserStat ? (
achievement.onwerUserStat.unlocked ? (
<div
style={{
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<CheckCircleIcon />
<small>
{formatDateTime(achievement.onwerUserStat.unlockTime!)}
</small>
</div>
) : (
<div
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon />
</div>
)
) : null}
{achievement.otherUserStat.unlocked ? (
<div
style={{
whiteSpace: "nowrap",
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<CheckCircleIcon />
<small>
{formatDateTime(achievement.otherUserStat.unlockTime!)}
</small>
</div>
) : (
<div
style={{
display: "flex",
padding: `${SPACING_UNIT}px`,
justifyContent: "center",
}}
>
<LockIcon />
</div>
)}
</li>
))}
</ul>
);
}

View File

@ -56,8 +56,6 @@ export function ProfileContent() {
const userParams = userProfile const userParams = userProfile
? { ? {
userId: userProfile.id, userId: userProfile.id,
displayName: userProfile.displayName,
profileImageUrl: userProfile.profileImageUrl,
} }
: undefined; : undefined;

View File

@ -342,6 +342,33 @@ export interface GameArtifact {
downloadCount: number; downloadCount: number;
} }
export interface ComparedAchievements {
ownerUser: {
totalAchievementCount: number;
unlockedAchievementCount: number;
};
otherUser: {
displayName: string;
profileImageUrl: string;
totalAchievementCount: number;
unlockedAchievementCount: number;
};
achievements: {
hidden: boolean;
icon: string;
displayName: string;
description: string;
onwerUserStat?: {
unlocked: boolean;
unlockTime: number;
};
otherUserStat: {
unlocked: boolean;
unlockTime: number;
};
}[];
}
export * from "./steam.types"; export * from "./steam.types";
export * from "./real-debrid.types"; export * from "./real-debrid.types";
export * from "./ludusavi.types"; export * from "./ludusavi.types";