Merge branch 'feat/migration-to-leveldb' into feature/torbox-integration

# Conflicts:
#	src/locales/en/translation.json
#	src/locales/pt-BR/translation.json
#	src/main/entity/user-preferences.entity.ts
#	src/main/events/auth/sign-out.ts
#	src/main/knex-client.ts
#	src/main/main.ts
#	src/main/services/download/download-manager.ts
#	src/main/services/process-watcher.ts
#	src/renderer/src/pages/downloads/download-group.tsx
#	src/types/index.ts
#	src/types/torbox.types.ts
This commit is contained in:
Zamitto
2025-02-01 15:43:32 -03:00
161 changed files with 2590 additions and 2802 deletions

View File

@@ -123,7 +123,7 @@ export const titleBar = style({
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "4",
zIndex: vars.zIndex.titleBar,
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@@ -84,7 +84,7 @@ export function App() {
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (downloadProgress.game.progress === 1) {
if (downloadProgress.progress === 1) {
clearDownload();
updateLibrary();
return;
@@ -233,13 +233,29 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
audio.play();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(() => {
playAudio();
});
return () => {
unsubscribe();
};
}, [playAudio]);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);
return (
<>
{window.electron.platform === "win32" && (
{/* {window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>
Hydra
@@ -248,7 +264,15 @@ export function App() {
)}
</h4>
</div>
)}
)} */}
<div className={styles.titleBar}>
<h4>
Hydra
{hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span>
)}
</h4>
</div>
<Toast
visible={toast.visible}

Binary file not shown.

View File

@@ -21,4 +21,14 @@
cursor: pointer;
}
}
&__version-button {
color: globals.$body-color;
border-bottom: solid 1px transparent;
&:hover {
border-bottom: solid 1px globals.$body-color;
cursor: pointer;
}
}
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDownload, useUserDetails } from "@renderer/hooks";
import { useDownload, useLibrary, useUserDetails } from "@renderer/hooks";
import "./bottom-panel.scss";
@@ -15,9 +15,11 @@ export function BottomPanel() {
const { userDetails } = useUserDetails();
const { library } = useLibrary();
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const isGameDownloading = !!lastPacket?.game;
const isGameDownloading = !!lastPacket;
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
@@ -32,27 +34,29 @@ export function BottomPanel() {
const status = useMemo(() => {
if (isGameDownloading) {
const game = library.find((game) => game.id === lastPacket?.gameId)!;
if (lastPacket?.isCheckingFiles)
return t("checking_files", {
title: lastPacket?.game.title,
title: game.title,
percentage: progress,
});
if (lastPacket?.isDownloadingMetadata)
return t("downloading_metadata", {
title: lastPacket?.game.title,
title: game.title,
percentage: progress,
});
if (!eta) {
return t("calculating_eta", {
title: lastPacket?.game.title,
title: game.title,
percentage: progress,
});
}
return t("downloading", {
title: lastPacket?.game.title,
title: game.title,
percentage: progress,
eta,
speed: downloadSpeed,
@@ -60,16 +64,7 @@ export function BottomPanel() {
}
return t("no_downloads_in_progress");
}, [
t,
isGameDownloading,
lastPacket?.game,
lastPacket?.isDownloadingMetadata,
lastPacket?.isCheckingFiles,
progress,
eta,
downloadSpeed,
]);
}, [t, isGameDownloading, library, lastPacket, progress, eta, downloadSpeed]);
return (
<footer className="bottom-panel">
@@ -81,10 +76,15 @@ export function BottomPanel() {
<small>{status}</small>
</button>
<small>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
<button
data-featurebase-changelog
className="bottom-panel__version-button"
>
<small data-featurebase-changelog>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
</button>
</footer>
);
}

View File

@@ -29,7 +29,7 @@ export function DropdownMenu({
loop = true,
align = "center",
alignOffset = 0,
}: DropdownMenuProps) {
}: Readonly<DropdownMenuProps>) {
return (
<DropdownMenuPrimitive.Root>
<DropdownMenuPrimitive.Trigger asChild>

View File

@@ -52,6 +52,7 @@ export function Modal({
)
)
return false;
const openModals = document.querySelectorAll("[role=dialog]");
return (

View File

@@ -7,6 +7,7 @@ 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";
const LONG_POLLING_INTERVAL = 120_000;
@@ -26,11 +27,11 @@ export function SidebarProfile() {
const handleProfileClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow();
window.electron.openAuthWindow(AuthPage.SignIn);
return;
}
navigate(`/profile/${userDetails!.id}`);
navigate(`/profile/${userDetails.id}`);
};
useEffect(() => {

View File

@@ -56,7 +56,7 @@ export function Sidebar() {
useEffect(() => {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
}, [lastPacket?.gameId, updateLibrary]);
const sidebarRef = useRef<HTMLElement>(null);
@@ -118,18 +118,17 @@ export function Sidebar() {
}, [isResizing]);
const getGameTitle = (game: LibraryGame) => {
if (lastPacket?.game.id === game.id) {
if (lastPacket?.gameId === game.id) {
return t("downloading", {
title: game.title,
percentage: progress,
});
}
if (game.downloadQueue !== null) {
return t("queued", { title: game.title });
}
if (game.download?.queued) return t("queued", { title: game.title });
if (game.status === "paused") return t("paused", { title: game.title });
if (game.download?.status === "paused")
return t("paused", { title: game.title });
return game.title;
};
@@ -146,7 +145,7 @@ export function Sidebar() {
) => {
const path = buildGameDetailsPath({
...game,
objectId: game.objectID,
objectId: game.objectId,
});
if (path !== location.pathname) {
navigate(path);
@@ -155,7 +154,8 @@ export function Sidebar() {
if (event.detail === 2) {
if (game.executablePath) {
window.electron.openGame(
game.id,
game.shop,
game.objectId,
game.executablePath,
game.launchOptions
);
@@ -219,12 +219,12 @@ export function Sidebar() {
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
key={`${game.shop}-${game.objectId}`}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
`/game/${game.shop}/${game.objectId}`,
muted: game.download?.status === "removed",
})}
>
<button

View File

@@ -18,9 +18,9 @@ import {
} from "@renderer/hooks";
import type {
Game,
GameShop,
GameStats,
LibraryGame,
ShopDetails,
UserAchievement,
} from "@types";
@@ -68,12 +68,12 @@ export function GameDetailsContextProvider({
objectId,
gameTitle,
shop,
}: GameDetailsContextProps) {
}: Readonly<GameDetailsContextProps>) {
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [achievements, setAchievements] = useState<UserAchievement[] | null>(
null
);
const [game, setGame] = useState<Game | null>(null);
const [game, setGame] = useState<LibraryGame | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
@@ -81,7 +81,7 @@ export function GameDetailsContextProvider({
const [isLoading, setIsLoading] = useState(false);
const [gameColor, setGameColor] = useState("");
const [isGameRunning, setisGameRunning] = useState(false);
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
@@ -101,15 +101,16 @@ export function GameDetailsContextProvider({
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectId(objectId!)
.getGameByObjectId(shop, objectId)
.then((result) => setGame(result));
}, [setGame, objectId]);
}, [setGame, shop, objectId]);
const isGameDownloading = lastPacket?.game.id === game?.id;
const isGameDownloading =
lastPacket?.gameId === game?.id && game?.download?.status === "active";
useEffect(() => {
updateGame();
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
}, [updateGame, isGameDownloading, lastPacket?.gameId]);
useEffect(() => {
if (abortControllerRef.current) abortControllerRef.current.abort();
@@ -167,7 +168,7 @@ export function GameDetailsContextProvider({
setShopDetails(null);
setGame(null);
setIsLoading(true);
setisGameRunning(false);
setIsGameRunning(false);
setAchievements(null);
dispatch(setHeaderTitle(gameTitle));
}, [objectId, gameTitle, dispatch]);
@@ -182,17 +183,18 @@ export function GameDetailsContextProvider({
updateGame();
}
setisGameRunning(updatedIsGameRunning);
setIsGameRunning(updatedIsGameRunning);
});
return () => {
unsubscribe();
};
}, [game?.id, isGameRunning, updateGame]);
const lastDownloadedOption = useMemo(() => {
if (game?.uri) {
if (game?.download) {
const repack = repacks.find((repack) =>
repack.uris.some((uri) => uri.includes(game.uri!))
repack.uris.some((uri) => uri.includes(game.download!.uri))
);
if (!repack) return null;
@@ -200,7 +202,7 @@ export function GameDetailsContextProvider({
}
return null;
}, [game?.uri, repacks]);
}, [game?.download, repacks]);
useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements(
@@ -250,7 +252,7 @@ export function GameDetailsContextProvider({
value={{
game,
shopDetails,
shop: shop as GameShop,
shop,
repacks,
gameTitle,
isGameRunning,

View File

@@ -1,14 +1,14 @@
import type {
Game,
GameRepack,
GameShop,
GameStats,
LibraryGame,
ShopDetails,
UserAchievement,
} from "@types";
export interface GameDetailsContext {
game: Game | null;
game: LibraryGame | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
shop: GameShop;

View File

@@ -1,8 +1,6 @@
import type { CatalogueCategory } from "@shared";
import type { AuthPage, CatalogueCategory } from "@shared";
import type {
AppUpdaterEvent,
Game,
LibraryGame,
GameShop,
HowLongToBeatCategory,
ShopDetails,
@@ -23,12 +21,13 @@ import type {
UserStats,
UserDetails,
FriendRequestSync,
GameAchievement,
GameArtifact,
LudusaviBackup,
UserAchievement,
ComparedAchievements,
CatalogueSearchPayload,
LibraryGame,
GameRunning,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -42,11 +41,11 @@ declare global {
interface Electron {
/* Torrenting */
startGameDownload: (payload: StartGameDownloadPayload) => Promise<void>;
cancelGameDownload: (gameId: number) => Promise<void>;
pauseGameDownload: (gameId: number) => Promise<void>;
resumeGameDownload: (gameId: number) => Promise<void>;
pauseGameSeed: (gameId: number) => Promise<void>;
resumeGameSeed: (gameId: number) => Promise<void>;
cancelGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameDownload: (shop: GameShop, objectId: string) => Promise<void>;
pauseGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
resumeGameSeed: (shop: GameShop, objectId: string) => Promise<void>;
onDownloadProgress: (
cb: (value: DownloadProgress) => void
) => () => Electron.IpcRenderer;
@@ -77,52 +76,62 @@ declare global {
onUpdateAchievements: (
objectId: string,
shop: GameShop,
cb: (achievements: GameAchievement[]) => void
cb: (achievements: UserAchievement[]) => void
) => () => Electron.IpcRenderer;
getPublishers: () => Promise<string[]>;
getDevelopers: () => Promise<string[]>;
/* Library */
addGameToLibrary: (
shop: GameShop,
objectId: string,
title: string,
shop: GameShop
title: string
) => Promise<void>;
createGameShortcut: (id: number) => Promise<boolean>;
createGameShortcut: (shop: GameShop, objectId: string) => Promise<boolean>;
updateExecutablePath: (
id: number,
shop: GameShop,
objectId: string,
executablePath: string | null
) => Promise<void>;
updateLaunchOptions: (
id: number,
shop: GameShop,
objectId: string,
launchOptions: string | null
) => Promise<void>;
selectGameWinePrefix: (
id: number,
shop: GameShop,
objectId: string,
winePrefixPath: string | null
) => Promise<void>;
verifyExecutablePathInUse: (executablePath: string) => Promise<Game>;
getLibrary: () => Promise<LibraryGame[]>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGameInstallerPath: (gameId: number) => Promise<boolean>;
openGameExecutablePath: (gameId: number) => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
openGameInstallerPath: (
shop: GameShop,
objectId: string
) => Promise<boolean>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
openGame: (
gameId: number,
shop: GameShop,
objectId: string,
executablePath: string,
launchOptions: string | null
launchOptions?: string | null
) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>;
removeGame: (gameId: number) => Promise<void>;
deleteGameFolder: (gameId: number) => Promise<unknown>;
getGameByObjectId: (objectId: string) => Promise<Game | null>;
closeGame: (shop: GameShop, objectId: string) => Promise<boolean>;
removeGameFromLibrary: (shop: GameShop, objectId: string) => Promise<void>;
removeGame: (shop: GameShop, objectId: string) => Promise<void>;
deleteGameFolder: (shop: GameShop, objectId: string) => Promise<unknown>;
getGameByObjectId: (
shop: GameShop,
objectId: string
) => Promise<LibraryGame | null>;
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
) => void
) => () => Electron.IpcRenderer;
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
resetGameAchievements: (gameId: number) => Promise<void>;
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
/* User preferences */
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
@@ -133,6 +142,7 @@ declare global {
minimized: boolean;
}) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
/* Download sources */
putDownloadSource: (
@@ -208,9 +218,10 @@ declare global {
/* Auth */
signOut: () => Promise<void>;
openAuthWindow: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
onSignIn: (cb: () => void) => () => Electron.IpcRenderer;
onAccountUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onSignOut: (cb: () => void) => () => Electron.IpcRenderer;
/* User */

View File

@@ -4,8 +4,8 @@ import type { DownloadProgress } from "@types";
export interface DownloadState {
lastPacket: DownloadProgress | null;
gameId: number | null;
gamesWithDeletionInProgress: number[];
gameId: string | null;
gamesWithDeletionInProgress: string[];
}
const initialState: DownloadState = {
@@ -20,13 +20,13 @@ export const downloadSlice = createSlice({
reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress>) => {
state.lastPacket = action.payload;
if (!state.gameId) state.gameId = action.payload.game.id;
if (!state.gameId) state.gameId = action.payload.gameId;
},
clearDownload: (state) => {
state.lastPacket = null;
state.gameId = null;
},
setGameDeleting: (state, action: PayloadAction<number>) => {
setGameDeleting: (state, action: PayloadAction<string>) => {
if (
!state.gamesWithDeletionInProgress.includes(action.payload) &&
action.payload
@@ -34,7 +34,7 @@ export const downloadSlice = createSlice({
state.gamesWithDeletionInProgress.push(action.payload);
}
},
removeGameFromDeleting: (state, action: PayloadAction<number>) => {
removeGameFromDeleting: (state, action: PayloadAction<string>) => {
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
},

View File

@@ -10,7 +10,7 @@ const initialState: GameRunningState = {
};
export const gameRunningSlice = createSlice({
name: "running-game",
name: "game-running",
initialState,
reducers: {
setGameRunning: (state, action: PayloadAction<GameRunning | null>) => {

View File

@@ -4,7 +4,7 @@ export * from "./download-slice";
export * from "./window-slice";
export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./running-game-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./catalogue-search";

View File

@@ -9,7 +9,11 @@ import {
setGameDeleting,
removeGameFromDeleting,
} from "@renderer/features";
import type { DownloadProgress, StartGameDownloadPayload } from "@types";
import type {
DownloadProgress,
GameShop,
StartGameDownloadPayload,
} from "@types";
import { useDate } from "./use-date";
import { formatBytes } from "@shared";
@@ -31,48 +35,48 @@ export function useDownload() {
return game;
};
const pauseDownload = async (gameId: number) => {
await window.electron.pauseGameDownload(gameId);
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
dispatch(clearDownload());
};
const resumeDownload = async (gameId: number) => {
await window.electron.resumeGameDownload(gameId);
const resumeDownload = async (shop: GameShop, objectId: string) => {
await window.electron.resumeGameDownload(shop, objectId);
return updateLibrary();
};
const removeGameInstaller = async (gameId: number) => {
dispatch(setGameDeleting(gameId));
const removeGameInstaller = async (shop: GameShop, objectId: string) => {
dispatch(setGameDeleting(objectId));
try {
await window.electron.deleteGameFolder(gameId);
await window.electron.deleteGameFolder(shop, objectId);
updateLibrary();
} finally {
dispatch(removeGameFromDeleting(gameId));
dispatch(removeGameFromDeleting(objectId));
}
};
const cancelDownload = async (gameId: number) => {
await window.electron.cancelGameDownload(gameId);
const cancelDownload = async (shop: GameShop, objectId: string) => {
await window.electron.cancelGameDownload(shop, objectId);
dispatch(clearDownload());
updateLibrary();
removeGameInstaller(gameId);
removeGameInstaller(shop, objectId);
};
const removeGameFromLibrary = (gameId: number) =>
window.electron.removeGameFromLibrary(gameId).then(() => {
const removeGameFromLibrary = (shop: GameShop, objectId: string) =>
window.electron.removeGameFromLibrary(shop, objectId).then(() => {
updateLibrary();
});
const pauseSeeding = async (gameId: number) => {
await window.electron.pauseGameSeed(gameId);
const pauseSeeding = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameSeed(shop, objectId);
await updateLibrary();
};
const resumeSeeding = async (gameId: number) => {
await window.electron.resumeGameSeed(gameId);
const resumeSeeding = async (shop: GameShop, objectId: string) => {
await window.electron.resumeGameSeed(shop, objectId);
await updateLibrary();
};
@@ -90,8 +94,8 @@ export function useDownload() {
}
};
const isGameDeleting = (gameId: number) => {
return gamesWithDeletionInProgress.includes(gameId);
const isGameDeleting = (objectId: string) => {
return gamesWithDeletionInProgress.includes(objectId);
};
return {

View File

@@ -78,9 +78,15 @@ export function useUserDetails() {
...response,
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
});
},
[updateUserDetails, userDetails?.username, userDetails?.subscription]
[
updateUserDetails,
userDetails?.username,
userDetails?.subscription,
userDetails?.featurebaseJwt,
]
);
const syncFriendRequests = useCallback(async () => {

View File

@@ -45,6 +45,7 @@ Sentry.init({
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
release: await window.electron.getVersion(),
});
console.log = logger.log;

View File

@@ -47,7 +47,14 @@ export function AchievementList({ achievements }: AchievementListProps) {
</h4>
<p>{achievement.description}</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "flex-end",
}}
>
{achievement.points != undefined ? (
<div
style={{ display: "flex", alignItems: "center", gap: "4px" }}

View File

@@ -44,7 +44,7 @@ export default function Achievements() {
.getComparedUnlockedAchievements(objectId, shop as GameShop, userId)
.then(setComparedAchievements);
}
}, [objectId, shop, userId]);
}, [objectId, shop, userDetails?.id, userId]);
const otherUserId = userDetails?.id === userId ? null : userId;

View File

@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import type { LibraryGame, SeedingStatus } from "@types";
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { Badge, Button } from "@renderer/components";
import {
@@ -36,8 +36,8 @@ import torBoxLogo from "@renderer/assets/icons/torbox.webp";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;
openDeleteGameModal: (gameId: number) => void;
openGameInstaller: (gameId: number) => void;
openDeleteGameModal: (shop: GameShop, objectId: string) => void;
openGameInstaller: (shop: GameShop, objectId: string) => void;
seedingStatus: SeedingStatus[];
}
@@ -47,7 +47,7 @@ export function DownloadGroup({
openDeleteGameModal,
openGameInstaller,
seedingStatus,
}: DownloadGroupProps) {
}: Readonly<DownloadGroupProps>) {
const navigate = useNavigate();
const { t } = useTranslation("downloads");
@@ -68,18 +68,19 @@ export function DownloadGroup({
} = useDownload();
const getFinalDownloadSize = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const download = game.download!;
const isGameDownloading = lastPacket?.gameId === game.id;
if (game.fileSize) return formatBytes(game.fileSize);
if (download.fileSize) return formatBytes(download.fileSize);
if (lastPacket?.game.fileSize && isGameDownloading)
return formatBytes(lastPacket?.game.fileSize);
if (lastPacket?.download.fileSize && isGameDownloading)
return formatBytes(lastPacket.download.fileSize);
return "N/A";
};
const seedingMap = useMemo(() => {
const map = new Map<number, SeedingStatus>();
const map = new Map<string, SeedingStatus>();
seedingStatus.forEach((seed) => {
map.set(seed.gameId, seed);
@@ -89,7 +90,9 @@ export function DownloadGroup({
}, [seedingStatus]);
const getGameInfo = (game: LibraryGame) => {
const isGameDownloading = lastPacket?.game.id === game.id;
const download = game.download!;
const isGameDownloading = lastPacket?.gameId === game.id;
const finalDownloadSize = getFinalDownloadSize(game);
const seedingStatus = seedingMap.get(game.id);
@@ -116,11 +119,11 @@ export function DownloadGroup({
<p>{progress}</p>
<p>
{formatBytes(lastPacket?.game.bytesDownloaded)} /{" "}
{formatBytes(lastPacket.download.bytesDownloaded)} /{" "}
{finalDownloadSize}
</p>
{game.downloader === Downloader.Torrent && (
{download.downloader === Downloader.Torrent && (
<small>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
</small>
@@ -129,11 +132,11 @@ export function DownloadGroup({
);
}
if (game.progress === 1) {
if (download.progress === 1) {
const uploadSpeed = formatBytes(seedingStatus?.uploadSpeed ?? 0);
return game.status === "seeding" &&
game.downloader === Downloader.Torrent ? (
return download.status === "seeding" &&
download.downloader === Downloader.Torrent ? (
<>
<p>{t("seeding")}</p>
{uploadSpeed && <p>{uploadSpeed}/s</p>}
@@ -143,41 +146,44 @@ export function DownloadGroup({
);
}
if (game.status === "paused") {
if (download.status === "paused") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{t(game.downloadQueue && lastPacket ? "queued" : "paused")}</p>
<p>{formatDownloadProgress(download.progress)}</p>
<p>{t(download.queued ? "queued" : "paused")}</p>
</>
);
}
if (game.status === "active") {
if (download.status === "active") {
return (
<>
<p>{formatDownloadProgress(game.progress)}</p>
<p>{formatDownloadProgress(download.progress)}</p>
<p>
{formatBytes(game.bytesDownloaded)} / {finalDownloadSize}
{formatBytes(download.bytesDownloaded)} / {finalDownloadSize}
</p>
</>
);
}
return <p>{t(game.status as string)}</p>;
return <p>{t(download.status as string)}</p>;
};
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
const isGameDownloading = lastPacket?.game.id === game.id;
const download = lastPacket?.download;
const isGameDownloading = lastPacket?.gameId === game.id;
const deleting = isGameDeleting(game.id);
if (game.progress === 1) {
if (download?.progress === 1) {
return [
{
label: t("install"),
disabled: deleting,
onClick: () => openGameInstaller(game.id),
onClick: () => {
openGameInstaller(game.shop, game.objectId);
},
icon: <DownloadIcon />,
},
{
@@ -185,36 +191,48 @@ export function DownloadGroup({
disabled: deleting,
icon: <UnlinkIcon />,
show:
game.status === "seeding" && game.downloader === Downloader.Torrent,
onClick: () => pauseSeeding(game.id),
download.status === "seeding" &&
download.downloader === Downloader.Torrent,
onClick: () => {
pauseSeeding(game.shop, game.objectId);
},
},
{
label: t("resume_seeding"),
disabled: deleting,
icon: <LinkIcon />,
show:
game.status !== "seeding" && game.downloader === Downloader.Torrent,
onClick: () => resumeSeeding(game.id),
download.status !== "seeding" &&
download.downloader === Downloader.Torrent,
onClick: () => {
resumeSeeding(game.shop, game.objectId);
},
},
{
label: t("delete"),
disabled: deleting,
icon: <TrashIcon />,
onClick: () => openDeleteGameModal(game.id),
onClick: () => {
openDeleteGameModal(game.shop, game.objectId);
},
},
];
}
if (isGameDownloading || game.status === "active") {
if (isGameDownloading || download?.status === "active") {
return [
{
label: t("pause"),
onClick: () => pauseDownload(game.id),
onClick: () => {
pauseDownload(game.shop, game.objectId);
},
icon: <ColumnsIcon />,
},
{
label: t("cancel"),
onClick: () => cancelDownload(game.id),
onClick: () => {
cancelDownload(game.shop, game.objectId);
},
icon: <XCircleIcon />,
},
];
@@ -224,14 +242,18 @@ export function DownloadGroup({
{
label: t("resume"),
disabled:
game.downloader === Downloader.RealDebrid &&
download?.downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken,
onClick: () => resumeDownload(game.id),
onClick: () => {
resumeDownload(game.shop, game.objectId);
},
icon: <PlayIcon />,
},
{
label: t("cancel"),
onClick: () => cancelDownload(game.id),
onClick: () => {
cancelDownload(game.shop, game.objectId);
},
icon: <XCircleIcon />,
},
];
@@ -272,7 +294,7 @@ export function DownloadGroup({
<div className={styles.downloadCover}>
<div className={styles.downloadCoverBackdrop}>
<img
src={steamUrlBuilder.library(game.objectID)}
src={steamUrlBuilder.library(game.objectId)}
className={styles.downloadCoverImage}
alt={game.title}
/>
@@ -313,7 +335,7 @@ export function DownloadGroup({
navigate(
buildGameDetailsPath({
...game,
objectId: game.objectID,
objectId: game.objectId,
})
)
}

View File

@@ -7,7 +7,7 @@ import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
import * as styles from "./downloads.css";
import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group";
import type { LibraryGame, SeedingStatus } from "@types";
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
@@ -16,7 +16,7 @@ export default function Downloads() {
const { t } = useTranslation("downloads");
const gameToBeDeleted = useRef<number | null>(null);
const gameToBeDeleted = useRef<[GameShop, string] | null>(null);
const [showBinaryNotFoundModal, setShowBinaryNotFoundModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -25,8 +25,10 @@ export default function Downloads() {
const handleDeleteGame = async () => {
if (gameToBeDeleted.current) {
await pauseSeeding(gameToBeDeleted.current);
await removeGameInstaller(gameToBeDeleted.current);
const [shop, objectId] = gameToBeDeleted.current;
await pauseSeeding(shop, objectId);
await removeGameInstaller(shop, objectId);
}
};
@@ -38,14 +40,14 @@ export default function Downloads() {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
}, []);
const handleOpenGameInstaller = (gameId: number) =>
window.electron.openGameInstaller(gameId).then((isBinaryInPath) => {
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
window.electron.openGameInstaller(shop, objectId).then((isBinaryInPath) => {
if (!isBinaryInPath) setShowBinaryNotFoundModal(true);
updateLibrary();
});
const handleOpenDeleteGameModal = (gameId: number) => {
gameToBeDeleted.current = gameId;
const handleOpenDeleteGameModal = (shop: GameShop, objectId: string) => {
gameToBeDeleted.current = [shop, objectId];
setShowDeleteModal(true);
};
@@ -58,27 +60,26 @@ export default function Downloads() {
const result = library.reduce((prev, next) => {
/* Game has been manually added to the library or has been canceled */
if (!next.status || next.status === "removed") return prev;
if (!next.download?.status || next.download?.status === "removed")
return prev;
/* Is downloading */
if (lastPacket?.game.id === next.id)
if (lastPacket?.gameId === next.id)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
if (next.downloadQueue || next.status === "paused")
if (next.download.queued || next.download?.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
const queued = orderBy(
result.queued,
(game) => game.downloadQueue?.id ?? -1,
["desc"]
);
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
"desc",
]);
const complete = orderBy(result.complete, (game) =>
game.progress === 1 ? 0 : 1
game.download?.progress === 1 ? 0 : 1
);
return {
@@ -86,7 +87,7 @@ export default function Downloads() {
queued,
complete,
};
}, [library, lastPacket?.game.id]);
}, [library, lastPacket?.gameId]);
const downloadGroups = [
{

View File

@@ -10,7 +10,7 @@ import { Sidebar } from "./sidebar/sidebar";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared";
import { AuthPage, steamUrlBuilder } from "@shared";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails } from "@renderer/hooks";
@@ -69,7 +69,7 @@ export function GameDetailsContent() {
});
const backgroundColor = output
? (new Color(output).darken(0.7).toString() as string)
? new Color(output).darken(0.7).toString()
: "";
setGameColor(backgroundColor);
@@ -101,7 +101,7 @@ export function GameDetailsContent() {
const handleCloudSaveButtonClick = () => {
if (!userDetails) {
window.electron.openAuthWindow();
window.electron.openAuthWindow(AuthPage.SignIn);
return;
}

View File

@@ -22,6 +22,7 @@ export function HeroPanelActions() {
game,
repacks,
isGameRunning,
shop,
objectId,
gameTitle,
setShowGameOptionsModal,
@@ -33,7 +34,7 @@ export function HeroPanelActions() {
const { lastPacket } = useDownload();
const isGameDownloading =
game?.status === "active" && lastPacket?.game.id === game?.id;
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
const { updateLibrary } = useLibrary();
@@ -43,7 +44,7 @@ export function HeroPanelActions() {
setToggleLibraryGameDisabled(true);
try {
await window.electron.addGameToLibrary(objectId!, gameTitle, "steam");
await window.electron.addGameToLibrary(shop, objectId!, gameTitle);
updateLibrary();
updateGame();
@@ -56,7 +57,8 @@ export function HeroPanelActions() {
if (game) {
if (game.executablePath) {
window.electron.openGame(
game.id,
game.shop,
game.objectId,
game.executablePath,
game.launchOptions
);
@@ -66,7 +68,8 @@ export function HeroPanelActions() {
const gameExecutablePath = await selectGameExecutable();
if (gameExecutablePath)
window.electron.openGame(
game.id,
game.shop,
game.objectId,
gameExecutablePath,
game.launchOptions
);
@@ -74,7 +77,7 @@ export function HeroPanelActions() {
};
const closeGame = () => {
if (game) window.electron.closeGame(game.id);
if (game) window.electron.closeGame(game.shop, game.objectId);
};
const deleting = game ? isGameDeleting(game?.id) : false;

View File

@@ -49,21 +49,24 @@ export function HeroPanelPlaytime() {
if (!game) return null;
const hasDownload =
["active", "paused"].includes(game.status as string) && game.progress !== 1;
["active", "paused"].includes(game.download?.status as string) &&
game.download?.progress !== 1;
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;
game.download?.status === "active" && lastPacket?.gameId === game.id;
const downloadInProgressInfo = (
<div className={styles.downloadDetailsRow}>
<Link to="/downloads" className={styles.downloadsLink}>
{game.status === "active"
{game.download?.status === "active"
? t("download_in_progress")
: t("download_paused")}
</Link>
<small>
{isGameDownloading ? progress : formatDownloadProgress(game.progress)}
{isGameDownloading
? progress
: formatDownloadProgress(game.download?.progress)}
</small>
</div>
);

View File

@@ -23,7 +23,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
const { lastPacket } = useDownload();
const isGameDownloading =
game?.status === "active" && lastPacket?.game.id === game?.id;
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
const getInfo = () => {
if (!game) {
@@ -50,8 +50,8 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
};
const showProgressBar =
(game?.status === "active" && game?.progress < 1) ||
game?.status === "paused";
(game?.download?.status === "active" && game?.download?.progress < 1) ||
game?.download?.status === "paused";
return (
<>
@@ -68,10 +68,12 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
<progress
max={1}
value={
isGameDownloading ? lastPacket?.game.progress : game?.progress
isGameDownloading
? lastPacket?.progress
: game?.download?.progress
}
className={styles.progressBar({
disabled: game?.status === "paused",
disabled: game?.download?.status === "paused",
})}
/>
)}

View File

@@ -96,9 +96,7 @@ export function DownloadSettingsModal({
? Downloader.TorBox
: filteredDownloaders[0];
setSelectedDownloader(
selectedDownloader === undefined ? null : selectedDownloader
);
setSelectedDownloader(selectedDownloader ?? null);
}, [
userPreferences?.downloadsPath,
downloaders,

View File

@@ -1,7 +1,7 @@
import { useContext, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import type { Game } from "@types";
import type { LibraryGame } from "@types";
import * as styles from "./game-options-modal.css";
import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
@@ -13,7 +13,7 @@ import { debounce } from "lodash-es";
export interface GameOptionsModalProps {
visible: boolean;
game: Game;
game: LibraryGame;
onClose: () => void;
}
@@ -59,21 +59,25 @@ export function GameOptionsModal({
const { lastPacket } = useDownload();
const isGameDownloading =
game.status === "active" && lastPacket?.game.id === game.id;
game.download?.status === "active" && lastPacket?.gameId === game.id;
const debounceUpdateLaunchOptions = useRef(
debounce(async (value: string) => {
await window.electron.updateLaunchOptions(game.id, value);
await window.electron.updateLaunchOptions(
game.shop,
game.objectId,
value
);
updateGame();
}, 1000)
).current;
const handleRemoveGameFromLibrary = async () => {
if (isGameDownloading) {
await cancelDownload(game.id);
await cancelDownload(game.shop, game.objectId);
}
await removeGameFromLibrary(game.id);
await removeGameFromLibrary(game.shop, game.objectId);
updateGame();
onClose();
};
@@ -92,35 +96,39 @@ export function GameOptionsModal({
return;
}
window.electron.updateExecutablePath(game.id, path).then(updateGame);
window.electron
.updateExecutablePath(game.shop, game.objectId, path)
.then(updateGame);
}
};
const handleCreateShortcut = async () => {
window.electron.createGameShortcut(game.id).then((success) => {
if (success) {
showSuccessToast(t("create_shortcut_success"));
} else {
showErrorToast(t("create_shortcut_error"));
}
});
window.electron
.createGameShortcut(game.shop, game.objectId)
.then((success) => {
if (success) {
showSuccessToast(t("create_shortcut_success"));
} else {
showErrorToast(t("create_shortcut_error"));
}
});
};
const handleOpenDownloadFolder = async () => {
await window.electron.openGameInstallerPath(game.id);
await window.electron.openGameInstallerPath(game.shop, game.objectId);
};
const handleDeleteGame = async () => {
await removeGameInstaller(game.id);
await removeGameInstaller(game.shop, game.objectId);
updateGame();
};
const handleOpenGameExecutablePath = async () => {
await window.electron.openGameExecutablePath(game.id);
await window.electron.openGameExecutablePath(game.shop, game.objectId);
};
const handleClearExecutablePath = async () => {
await window.electron.updateExecutablePath(game.id, null);
await window.electron.updateExecutablePath(game.shop, game.objectId, null);
updateGame();
};
@@ -130,13 +138,17 @@ export function GameOptionsModal({
});
if (filePaths && filePaths.length > 0) {
await window.electron.selectGameWinePrefix(game.id, filePaths[0]);
await window.electron.selectGameWinePrefix(
game.shop,
game.objectId,
filePaths[0]
);
await updateGame();
}
};
const handleClearWinePrefixPath = async () => {
await window.electron.selectGameWinePrefix(game.id, null);
await window.electron.selectGameWinePrefix(game.shop, game.objectId, null);
updateGame();
};
@@ -150,7 +162,9 @@ export function GameOptionsModal({
const handleClearLaunchOptions = async () => {
setLaunchOptions("");
window.electron.updateLaunchOptions(game.id, null).then(updateGame);
window.electron
.updateLaunchOptions(game.shop, game.objectId, null)
.then(updateGame);
};
const shouldShowWinePrefixConfiguration =
@@ -159,7 +173,7 @@ export function GameOptionsModal({
const handleResetAchievements = async () => {
setIsDeletingAchievements(true);
try {
await window.electron.resetGameAchievements(game.id);
await window.electron.resetGameAchievements(game.shop, game.objectId);
await updateGame();
showSuccessToast(t("reset_achievements_success"));
} catch (error) {
@@ -169,8 +183,6 @@ export function GameOptionsModal({
}
};
const shouldShowLaunchOptionsConfiguration = false;
return (
<>
<DeleteGameModal
@@ -285,27 +297,28 @@ export function GameOptionsModal({
</div>
)}
{shouldShowLaunchOptionsConfiguration && (
<div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<h2>{t("launch_options")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("launch_options_description")}
</h4>
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
)}
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
<div className={styles.gameOptionHeader}>
<h2>{t("downloads_secion_title")}</h2>
@@ -322,7 +335,7 @@ export function GameOptionsModal({
>
{t("open_download_options")}
</Button>
{game.downloadPath && (
{game.download?.downloadPath && (
<Button
onClick={handleOpenDownloadFolder}
theme="outline"
@@ -367,7 +380,9 @@ export function GameOptionsModal({
setShowDeleteModal(true);
}}
theme="danger"
disabled={isGameDownloading || deleting || !game.downloadPath}
disabled={
isGameDownloading || deleting || !game.download?.downloadPath
}
>
{t("remove_files")}
</Button>

View File

@@ -67,8 +67,8 @@ export function RepacksModal({
};
const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (!game) return false;
return repack.uris.some((uri) => uri.includes(game.uri!));
if (!game?.download) return false;
return repack.uris.some((uri) => uri.includes(game.download!.uri));
};
return (

View File

@@ -1,5 +1,5 @@
import { ChevronDownIcon } from "@primer/octicons-react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import * as styles from "./sidebar-section.css";
@@ -11,6 +11,15 @@ export interface SidebarSectionProps {
export function SidebarSection({ title, children }: SidebarSectionProps) {
const content = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(true);
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>
@@ -26,7 +35,7 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
<div
ref={content}
style={{
maxHeight: isOpen ? `${content.current?.scrollHeight}px` : "0",
maxHeight: `${height}px`,
overflow: "hidden",
transition: "max-height 0.4s cubic-bezier(0, 1, 0, 1)",
position: "relative",

View File

@@ -23,7 +23,7 @@ import { buildGameAchievementPath } from "@renderer/helpers";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useSubscription } from "@renderer/hooks/use-subscription";
const fakeAchievements: UserAchievement[] = [
const achievementsPlaceholder: UserAchievement[] = [
{
displayName: "Timber!!",
name: "",
@@ -140,7 +140,7 @@ export function Sidebar() {
<h3>{t("sign_in_to_see_achievements")}</h3>
</div>
<ul className={styles.list} style={{ filter: "blur(4px)" }}>
{fakeAchievements.map((achievement, index) => (
{achievementsPlaceholder.map((achievement, index) => (
<li key={index}>
<div className={styles.listItem}>
<img
@@ -212,7 +212,6 @@ export function Sidebar() {
))}
<Link
style={{ textAlign: "center" }}
to={buildGameAchievementPath({
shop: shop,
objectId: objectId!,

View File

@@ -7,7 +7,6 @@ import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { FriendsBox } from "./friends-box";
@@ -66,8 +65,6 @@ export function ProfileContent() {
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const usersAreFriends = useMemo(() => {
return userProfile?.relation?.status === "ACCEPTED";
}, [userProfile]);
@@ -148,7 +145,6 @@ export function ProfileContent() {
userStats,
numberFormatter,
t,
navigate,
statsIndex,
]);

View File

@@ -254,7 +254,7 @@ export function ProfileHero() {
if (gameRunning)
return {
...gameRunning,
objectId: gameRunning.objectID,
objectId: gameRunning.objectId,
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
};

View File

@@ -5,14 +5,7 @@ import { SPACING_UNIT, vars } from "../../theme.css";
export const form = style({
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
});
export const blockedUserAvatar = style({
width: "32px",
height: "32px",
borderRadius: "4px",
filter: "grayscale(100%)",
gap: `${SPACING_UNIT * 3}px`,
});
export const blockedUser = style({
@@ -43,5 +36,4 @@ export const blockedUsersList = style({
flexDirection: "column",
alignItems: "flex-start",
gap: `${SPACING_UNIT}px`,
marginTop: `${SPACING_UNIT}px`,
});

View File

@@ -0,0 +1,291 @@
import { Avatar, Button, SelectField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-account.css";
import { useDate, useToast, useUserDetails } from "@renderer/hooks";
import { useCallback, useContext, useEffect, useState } from "react";
import {
CloudIcon,
KeyIcon,
MailIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { settingsContext } from "@renderer/context";
import { AuthPage } from "@shared";
interface FormValues {
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
}
export function SettingsAccount() {
const { t } = useTranslation("settings");
const [isUnblocking, setIsUnblocking] = useState(false);
const { showSuccessToast } = useToast();
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
const { formatDate } = useDate();
const {
control,
formState: { isSubmitting },
setValue,
handleSubmit,
} = useForm<FormValues>();
const {
userDetails,
hasActiveSubscription,
patchUser,
fetchUserDetails,
updateUserDetails,
unblockUser,
} = useUserDetails();
useEffect(() => {
if (userDetails?.profileVisibility) {
setValue("profileVisibility", userDetails.profileVisibility);
}
}, [userDetails, setValue]);
useEffect(() => {
const unsubscribe = window.electron.onAccountUpdated(() => {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
}
});
showSuccessToast(t("account_data_updated_successfully"));
});
return () => {
unsubscribe();
};
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },
{ value: "FRIENDS", label: t("friends_only") },
{ value: "PRIVATE", label: t("private") },
];
const onSubmit = async (values: FormValues) => {
await patchUser(values);
showSuccessToast(t("changes_saved"));
};
const handleUnblockClick = useCallback(
(id: string) => {
setIsUnblocking(true);
unblockUser(id)
.then(() => {
fetchBlockedUsers();
showSuccessToast(t("user_unblocked"));
})
.finally(() => {
setIsUnblocking(false);
});
},
[unblockUser, fetchBlockedUsers, t, showSuccessToast]
);
const getHydraCloudSectionContent = () => {
const hasSubscribedBefore = Boolean(userDetails?.subscription?.expiresAt);
const isRenewalActive = userDetails?.subscription?.status === "active";
if (!hasSubscribedBefore) {
return {
description: <small>{t("no_subscription")}</small>,
callToAction: t("become_subscriber"),
};
}
if (hasActiveSubscription) {
return {
description: isRenewalActive ? (
<>
<small>
{t("subscription_renews_on", {
date: formatDate(userDetails.subscription!.expiresAt!),
})}
</small>
<small>{t("bill_sent_until")}</small>
</>
) : (
<>
<small>{t("subscription_renew_cancelled")}</small>
<small>
{t("subscription_active_until", {
date: formatDate(userDetails!.subscription!.expiresAt!),
})}
</small>
</>
),
callToAction: t("manage_subscription"),
};
}
return {
description: (
<small>
{t("subscription_expired_at", {
date: formatDate(userDetails!.subscription!.expiresAt!),
})}
</small>
),
callToAction: t("renew_subscription"),
};
};
if (!userDetails) return null;
return (
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="profileVisibility"
render={({ field }) => {
const handleChange = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
field.onChange(event);
handleSubmit(onSubmit)();
};
return (
<section>
<SelectField
label={t("profile_visibility")}
value={field.value}
onChange={handleChange}
options={visibilityOptions.map((visiblity) => ({
key: visiblity.value,
value: visiblity.value,
label: visiblity.label,
}))}
disabled={isSubmitting}
/>
<small>{t("profile_visibility_description")}</small>
</section>
);
}}
/>
<section>
<h4>{t("current_email")}</h4>
<p>{userDetails?.email ?? t("no_email_account")}</p>
<div
style={{
display: "flex",
justifyContent: "start",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
marginTop: `${SPACING_UNIT * 2}px`,
}}
>
<Button
theme="outline"
onClick={() => window.electron.openAuthWindow(AuthPage.UpdateEmail)}
>
<MailIcon />
{t("update_email")}
</Button>
<Button
theme="outline"
onClick={() =>
window.electron.openAuthWindow(AuthPage.UpdatePassword)
}
>
<KeyIcon />
{t("update_password")}
</Button>
</div>
</section>
<section
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h3>Hydra Cloud</h3>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{getHydraCloudSectionContent().description}
</div>
<Button
style={{
placeSelf: "flex-start",
}}
theme="outline"
onClick={() => window.electron.openCheckout()}
>
<CloudIcon />
{getHydraCloudSectionContent().callToAction}
</Button>
</section>
<section
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h3>{t("blocked_users")}</h3>
{blockedUsers.length > 0 ? (
<ul className={styles.blockedUsersList}>
{blockedUsers.map((user) => {
return (
<li key={user.id} className={styles.blockedUser}>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<Avatar
style={{ filter: "grayscale(100%)" }}
size={32}
src={user.profileImageUrl}
alt={user.displayName}
/>
<span>{user.displayName}</span>
</div>
<button
type="button"
className={styles.unblockButton}
onClick={() => handleUnblockClick(user.id)}
disabled={isUnblocking}
>
<XCircleFillIcon />
</button>
</li>
);
})}
</ul>
) : (
<small>{t("no_users_blocked")}</small>
)}
</section>
</form>
);
}

View File

@@ -57,10 +57,30 @@ export function SettingsGeneral() {
);
}, []);
useEffect(updateFormWithUserPreferences, [
userPreferences,
defaultDownloadsPath,
]);
useEffect(() => {
if (userPreferences) {
const languageKeys = Object.keys(languageResources);
const language =
languageKeys.find(
(language) => language === userPreferences.language
) ??
languageKeys.find((language) => {
return language.startsWith(userPreferences.language.split("-")[0]);
});
setForm((prev) => ({
...prev,
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
downloadNotificationsEnabled:
userPreferences.downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled:
userPreferences.repackUpdatesNotificationsEnabled,
achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled,
language: language ?? "en",
}));
}
}, [userPreferences, defaultDownloadsPath]);
const handleLanguageChange = (event) => {
const value = event.target.value;
@@ -86,31 +106,6 @@ export function SettingsGeneral() {
}
};
function updateFormWithUserPreferences() {
if (userPreferences) {
const languageKeys = Object.keys(languageResources);
const language =
languageKeys.find((language) => {
return language === userPreferences.language;
}) ??
languageKeys.find((language) => {
return language.startsWith(userPreferences.language.split("-")[0]);
});
setForm((prev) => ({
...prev,
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
downloadNotificationsEnabled:
userPreferences.downloadNotificationsEnabled,
repackUpdatesNotificationsEnabled:
userPreferences.repackUpdatesNotificationsEnabled,
achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled,
language: language ?? "en",
}));
}
}
return (
<>
<TextField

View File

@@ -1,139 +0,0 @@
import { SelectField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-privacy.css";
import { useToast, useUserDetails } from "@renderer/hooks";
import { useCallback, useContext, useEffect, useState } from "react";
import { XCircleFillIcon } from "@primer/octicons-react";
import { settingsContext } from "@renderer/context";
interface FormValues {
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
}
export function SettingsPrivacy() {
const { t } = useTranslation("settings");
const [isUnblocking, setIsUnblocking] = useState(false);
const { showSuccessToast } = useToast();
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
const {
control,
formState: { isSubmitting },
setValue,
handleSubmit,
} = useForm<FormValues>();
const { patchUser, userDetails } = useUserDetails();
const { unblockUser } = useUserDetails();
useEffect(() => {
if (userDetails?.profileVisibility) {
setValue("profileVisibility", userDetails.profileVisibility);
}
}, [userDetails, setValue]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },
{ value: "FRIENDS", label: t("friends_only") },
{ value: "PRIVATE", label: t("private") },
];
const onSubmit = async (values: FormValues) => {
await patchUser(values);
showSuccessToast(t("changes_saved"));
};
const handleUnblockClick = useCallback(
(id: string) => {
setIsUnblocking(true);
unblockUser(id)
.then(() => {
fetchBlockedUsers();
showSuccessToast(t("user_unblocked"));
})
.finally(() => {
setIsUnblocking(false);
});
},
[unblockUser, fetchBlockedUsers, t, showSuccessToast]
);
return (
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="profileVisibility"
render={({ field }) => {
const handleChange = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
field.onChange(event);
handleSubmit(onSubmit)();
};
return (
<>
<SelectField
label={t("profile_visibility")}
value={field.value}
onChange={handleChange}
options={visibilityOptions.map((visiblity) => ({
key: visiblity.value,
value: visiblity.value,
label: visiblity.label,
}))}
disabled={isSubmitting}
/>
<small>{t("profile_visibility_description")}</small>
</>
);
}}
/>
<h3 style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
{t("blocked_users")}
</h3>
<ul className={styles.blockedUsersList}>
{blockedUsers.map((user) => {
return (
<li key={user.id} className={styles.blockedUser}>
<div
style={{
display: "flex",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<img
src={user.profileImageUrl!}
alt={user.displayName}
className={styles.blockedUserAvatar}
/>
<span>{user.displayName}</span>
</div>
<button
type="button"
className={styles.unblockButton}
onClick={() => handleUnblockClick(user.id)}
disabled={isUnblocking}
>
<XCircleFillIcon />
</button>
</li>
);
})}
</ul>
</form>
);
}

View File

@@ -11,7 +11,7 @@ import {
SettingsContextConsumer,
SettingsContextProvider,
} from "@renderer/context";
import { SettingsPrivacy } from "./settings-privacy";
import { SettingsAccount } from "./settings-account";
import { useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
@@ -28,7 +28,7 @@ export default function Settings() {
t("debrid_services"),
];
if (userDetails) return [...categories, t("privacy")];
if (userDetails) return [...categories, t("account")];
return categories;
}, [userDetails, t]);
@@ -53,7 +53,7 @@ export default function Settings() {
return <SettingsDebrid />;
}
return <SettingsPrivacy />;
return <SettingsAccount />;
};
return (

View File

@@ -16,6 +16,6 @@ $spacing-unit: 8px;
$toast-z-index: 5;
$bottom-panel-z-index: 3;
$title-bar-z-index: 4;
$title-bar-z-index: 1900000001;
$backdrop-z-index: 4;
$modal-z-index: 5;

View File

@@ -24,7 +24,7 @@ export const vars = createGlobalTheme(":root", {
zIndex: {
toast: "5",
bottomPanel: "3",
titleBar: "4",
titleBar: "1900000001",
backdrop: "4",
},
});