feat: add local notifications management and UI integration

This commit is contained in:
Moyasee
2025-12-16 15:18:21 +02:00
parent 1552a5f359
commit b8352be274
40 changed files with 2082 additions and 122 deletions

2
proto

Submodule proto updated: 7a23620f93...6f11c99c57

View File

@@ -26,6 +26,7 @@
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"notifications": "Notifications",
"need_help": "Need help?",
"favorites": "Favorites",
"playable_button_title": "Show only games you can play now",
@@ -660,6 +661,7 @@
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"badges": "Badges",
"friends_list": "Friends list",
"user_not_found": "User not found",
"block_user": "Block user",
@@ -670,12 +672,16 @@
"ignore_request": "Ignore request",
"cancel_request": "Cancel request",
"undo_friendship": "Undo friendship",
"friendship_removed": "Friend removed",
"request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}",
"blocked_users": "Blocked users",
"unblock": "Unblock",
"no_friends_added": "You have no added friends",
"view_all": "View all",
"load_more": "Load more",
"loading": "Loading",
"pending": "Pending",
"no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users",
@@ -699,6 +705,7 @@
"report_reason_other": "Other",
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code",
"upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…",
"background_image_updated": "Background image updated",
@@ -772,5 +779,40 @@
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
"learn_more": "Learn More",
"debrid_description": "Download up to 4x faster with Nimbus"
},
"notifications_page": {
"title": "Notifications",
"mark_all_as_read": "Mark all as read",
"clear_all": "Clear All",
"loading": "Loading...",
"empty_title": "No notifications",
"empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.",
"filter_all": "All",
"filter_friends": "Friends",
"filter_badges": "Badges",
"filter_upvotes": "Upvotes",
"filter_local": "Local",
"load_more": "Load more",
"dismiss": "Dismiss",
"accept": "Accept",
"refuse": "Refuse",
"notification": "Notification",
"friend_request_received_title": "New friend request!",
"friend_request_received_description": "{{displayName}} wants to be your friend",
"friend_request_accepted_title": "Friend request accepted!",
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
"badge_received_title": "You got a new badge!",
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
"review_upvote_description": "Your review received {{count}} new upvotes",
"marked_all_as_read": "All notifications marked as read",
"failed_to_mark_as_read": "Failed to mark notifications as read",
"cleared_all": "All notifications cleared",
"failed_to_clear": "Failed to clear notifications",
"failed_to_load": "Failed to load notifications",
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
}
}

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const clearAllLocalNotifications = async () => {
await LocalNotificationManager.clearAll();
};
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const deleteLocalNotification = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.deleteNotification(id);
};
registerEvent("deleteLocalNotification", deleteLocalNotification);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotificationsCount = async () => {
return LocalNotificationManager.getUnreadCount();
};
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const getLocalNotifications = async () => {
return LocalNotificationManager.getNotifications();
};
registerEvent("getLocalNotifications", getLocalNotifications);

View File

@@ -1,3 +1,9 @@
import "./publish-new-repacks-notification";
import "./show-achievement-test-notification";
import "./update-achievement-notification-window";
import "./get-local-notifications";
import "./get-local-notifications-count";
import "./mark-local-notification-read";
import "./mark-all-local-notifications-read";
import "./delete-local-notification";
import "./clear-all-local-notifications";

View File

@@ -0,0 +1,8 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markAllLocalNotificationsRead = async () => {
await LocalNotificationManager.markAllAsRead();
};
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);

View File

@@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { LocalNotificationManager } from "@main/services";
const markLocalNotificationRead = async (
_event: Electron.IpcMainInvokeEvent,
id: string
) => {
await LocalNotificationManager.markAsRead(id);
};
registerEvent("markLocalNotificationRead", markLocalNotificationRead);

View File

@@ -1,4 +1,4 @@
// @generated by protobuf-ts 2.10.0
// @generated by protobuf-ts 2.11.1
// @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1;
* @generated from protobuf field: int32 friend_request_count = 1
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2;
* @generated from protobuf field: optional string sender_id = 2
*/
senderId?: string;
}
@@ -28,18 +28,27 @@ export interface FriendRequest {
*/
export interface FriendGameSession {
/**
* @generated from protobuf field: string object_id = 1;
* @generated from protobuf field: string object_id = 1
*/
objectId: string;
/**
* @generated from protobuf field: string shop = 2;
* @generated from protobuf field: string shop = 2
*/
shop: string;
/**
* @generated from protobuf field: string friend_id = 3;
* @generated from protobuf field: string friend_id = 3
*/
friendId: string;
}
/**
* @generated from protobuf message Notification
*/
export interface Notification {
/**
* @generated from protobuf field: int32 notification_count = 1
*/
notificationCount: number;
}
/**
* @generated from protobuf message Envelope
*/
@@ -51,17 +60,24 @@ export interface Envelope {
| {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1;
* @generated from protobuf field: FriendRequest friend_request = 1
*/
friendRequest: FriendRequest;
}
| {
oneofKind: "friendGameSession";
/**
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
* @generated from protobuf field: FriendGameSession friend_game_session = 2
*/
friendGameSession: FriendGameSession;
}
| {
oneofKind: "notification";
/**
* @generated from protobuf field: Notification notification = 3
*/
notification: Notification;
}
| {
oneofKind: undefined;
};
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
*/
export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Notification$Type extends MessageType<Notification> {
constructor() {
super("Notification", [
{
no: 1,
name: "notification_count",
kind: "scalar",
T: 5 /*ScalarType.INT32*/,
},
]);
}
create(value?: PartialMessage<Notification>): Notification {
const message = globalThis.Object.create(this.messagePrototype!);
message.notificationCount = 0;
if (value !== undefined)
reflectionMergePartial<Notification>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Notification
): Notification {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 notification_count */ 1:
message.notificationCount = reader.int32();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Notification,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* int32 notification_count = 1; */
if (message.notificationCount !== 0)
writer.tag(1, WireType.Varint).int32(message.notificationCount);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message Notification
*/
export const Notification = new Notification$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload",
T: () => FriendGameSession,
},
{
no: 3,
name: "notification",
kind: "message",
oneof: "payload",
T: () => Notification,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
),
};
break;
case /* Notification notification */ 3:
message.payload = {
oneofKind: "notification",
notification: Notification.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).notification
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(2, WireType.LengthDelimited).fork(),
options
).join();
/* Notification notification = 3; */
if (message.payload.oneofKind === "notification")
Notification.internalBinaryWrite(
message.payload.notification,
writer.tag(3, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(

View File

@@ -8,3 +8,4 @@ export * from "./keys";
export * from "./themes";
export * from "./download-sources";
export * from "./downloadSourcesCheckTimestamp";
export * from "./local-notifications";

View File

@@ -20,4 +20,5 @@ export const levelKeys = {
downloadSources: "downloadSources",
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
localNotifications: "localNotifications",
};

View File

@@ -0,0 +1,11 @@
import type { LocalNotification } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const localNotificationsSublevel = db.sublevel<
string,
LocalNotification
>(levelKeys.localNotifications, {
valueEncoding: "json",
});

View File

@@ -20,3 +20,4 @@ export * from "./lock";
export * from "./decky-plugin";
export * from "./user";
export * from "./download-sources-checker";
export * from "./notifications/local-notifications";

View File

@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
import { SystemPath } from "../system-path";
import { getThemeSoundPath } from "@main/helpers";
import { processProfileImage } from "@main/events/profile/process-profile-image";
import { LocalNotificationManager } from "./local-notifications";
const getStaticImage = async (path: string) => {
return processProfileImage(path, "jpg")
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
}
);
const title = t("download_complete", { ns: "notifications" });
const body = t("game_ready_to_install", {
ns: "notifications",
title: game.title,
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
title,
body,
icon: await downloadImage(game.iconUrl),
}).show();
}
// Create local notification
await LocalNotificationManager.createNotification(
"DOWNLOAD_COMPLETE",
title,
body,
{
pictureUrl: game.iconUrl,
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
const title = t("new_update_available", {
ns: "notifications",
version,
});
const body = t("restart_to_install_update", {
ns: "notifications",
});
new Notification({
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
title,
body,
icon: trayIcon,
})
.on("click", () => {
restartAndInstallUpdate();
})
.show();
// Create local notification
await LocalNotificationManager.createNotification(
"UPDATE_AVAILABLE",
title,
body
);
};
export const publishNewFriendRequestNotification = async (
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
};
export const publishExtractionCompleteNotification = async (game: Game) => {
const title = t("extraction_complete", { ns: "notifications" });
const body = t("game_extracted", {
ns: "notifications",
title: game.title,
});
new Notification({
title: t("extraction_complete", { ns: "notifications" }),
body: t("game_extracted", {
ns: "notifications",
title: game.title,
}),
title,
body,
icon: trayIcon,
}).show();
// Create local notification
await LocalNotificationManager.createNotification(
"EXTRACTION_COMPLETE",
title,
body,
{
url: `/game/${game.shop}/${game.objectId}`,
}
);
};
export const publishNewAchievementNotification = async (info: {

View File

@@ -0,0 +1,99 @@
import { localNotificationsSublevel } from "@main/level";
import { WindowManager } from "../window-manager";
import type { LocalNotification, LocalNotificationType } from "@types";
import crypto from "node:crypto";
export class LocalNotificationManager {
private static generateId(): string {
return crypto.randomBytes(8).toString("hex");
}
static async createNotification(
type: LocalNotificationType,
title: string,
description: string,
options?: {
pictureUrl?: string | null;
url?: string | null;
}
): Promise<LocalNotification> {
const id = this.generateId();
const notification: LocalNotification = {
id,
type,
title,
description,
pictureUrl: options?.pictureUrl ?? null,
url: options?.url ?? null,
isRead: false,
createdAt: new Date().toISOString(),
};
await localNotificationsSublevel.put(id, notification);
// Notify renderer about new notification
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send(
"on-local-notification-created",
notification
);
}
return notification;
}
static async getNotifications(): Promise<LocalNotification[]> {
const notifications: LocalNotification[] = [];
for await (const [, value] of localNotificationsSublevel.iterator()) {
notifications.push(value);
}
// Sort by createdAt descending
return notifications.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
static async getUnreadCount(): Promise<number> {
let count = 0;
for await (const [, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
count++;
}
}
return count;
}
static async markAsRead(id: string): Promise<void> {
const notification = await localNotificationsSublevel.get(id);
if (notification) {
notification.isRead = true;
await localNotificationsSublevel.put(id, notification);
}
}
static async markAllAsRead(): Promise<void> {
const batch = localNotificationsSublevel.batch();
for await (const [key, value] of localNotificationsSublevel.iterator()) {
if (!value.isRead) {
value.isRead = true;
batch.put(key, value);
}
}
await batch.write();
}
static async deleteNotification(id: string): Promise<void> {
await localNotificationsSublevel.del(id);
}
static async clearAll(): Promise<void> {
await localNotificationsSublevel.clear();
}
}

View File

@@ -0,0 +1,8 @@
import type { Notification } from "@main/generated/envelope";
import { WindowManager } from "@main/services/window-manager";
export const notificationEvent = (payload: Notification) => {
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
notificationCount: payload.notificationCount,
});
};

View File

@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
import { notificationEvent } from "./events/notification";
export class WSClient {
private static ws: WebSocket | null = null;
@@ -51,6 +52,10 @@ export class WSClient {
if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}
});
this.ws.on("close", () => this.handleDisconnect("close"));

View File

@@ -15,6 +15,7 @@ import type {
GameAchievement,
Theme,
FriendRequestSync,
NotificationSync,
ShortcutLocation,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
@@ -507,6 +508,15 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-sync-friend-requests", listener);
},
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: NotificationSync
) => cb(notification);
ipcRenderer.on("on-sync-notification-count", listener);
return () =>
ipcRenderer.removeListener("on-sync-notification-count", listener);
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
@@ -550,6 +560,26 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
getLocalNotificationsCount: () =>
ipcRenderer.invoke("getLocalNotificationsCount"),
markLocalNotificationRead: (id: string) =>
ipcRenderer.invoke("markLocalNotificationRead", id),
markAllLocalNotificationsRead: () =>
ipcRenderer.invoke("markAllLocalNotificationsRead"),
deleteLocalNotification: (id: string) =>
ipcRenderer.invoke("deleteLocalNotification", id),
clearAllLocalNotifications: () =>
ipcRenderer.invoke("clearAllLocalNotifications"),
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
notification: unknown
) => cb(notification);
ipcRenderer.on("on-local-notification-created", listener);
return () =>
ipcRenderer.removeListener("on-local-notification-created", listener);
},
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,

View File

@@ -82,6 +82,7 @@ export function Header() {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/achievements")) return headerTitle;
if (location.pathname.startsWith("/profile")) return headerTitle;
if (location.pathname.startsWith("/notifications")) return headerTitle;
if (location.pathname.startsWith("/library"))
return headerTitle || t("library");
if (location.pathname.startsWith("/search")) return t("search_results");

View File

@@ -46,6 +46,7 @@
white-space: nowrap;
}
&__notification-button,
&__friends-button {
color: globals.$muted-color;
cursor: pointer;
@@ -62,6 +63,7 @@
}
}
&__notification-button-badge,
&__friends-button-badge {
background-color: globals.$success-color;
display: flex;
@@ -73,6 +75,8 @@
position: absolute;
top: -5px;
right: -5px;
font-size: 10px;
font-weight: bold;
}
&__game-running-icon {

View File

@@ -1,12 +1,14 @@
import { useNavigate } from "react-router-dom";
import { PeopleIcon } from "@primer/octicons-react";
import { PeopleIcon, BellIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
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";
import { logger } from "@renderer/logger";
import type { NotificationCountResponse } from "@types";
import "./sidebar-profile.scss";
export function SidebarProfile() {
@@ -19,6 +21,71 @@ export function SidebarProfile() {
const { gameRunning } = useAppSelector((state) => state.gameRunning);
const [notificationCount, setNotificationCount] = useState(0);
const fetchNotificationCount = useCallback(async () => {
try {
// Always fetch local notification count
const localCount = await window.electron.getLocalNotificationsCount();
// Fetch API notification count only if logged in
let apiCount = 0;
if (userDetails) {
try {
const response =
await window.electron.hydraApi.get<NotificationCountResponse>(
"/profile/notifications/count",
{ needsAuth: true }
);
apiCount = response.count;
} catch {
// Ignore API errors
}
}
setNotificationCount(localCount + apiCount);
} catch (error) {
logger.error("Failed to fetch notification count", error);
}
}, [userDetails]);
useEffect(() => {
fetchNotificationCount();
const interval = setInterval(fetchNotificationCount, 60000);
return () => clearInterval(interval);
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
useEffect(() => {
const handleNotificationsChange = () => {
fetchNotificationCount();
};
window.addEventListener("notificationsChanged", handleNotificationsChange);
return () => {
window.removeEventListener(
"notificationsChanged",
handleNotificationsChange
);
};
}, [fetchNotificationCount]);
useEffect(() => {
const unsubscribe = window.electron.onSyncNotificationCount(() => {
fetchNotificationCount();
});
return () => unsubscribe();
}, [fetchNotificationCount]);
const handleProfileClick = () => {
if (userDetails === null) {
window.electron.openAuthWindow(AuthPage.SignIn);
@@ -28,6 +95,25 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`);
};
const notificationsButton = useMemo(() => {
return (
<button
type="button"
className="sidebar-profile__notification-button"
onClick={() => navigate("/notifications")}
title={t("notifications")}
>
{notificationCount > 0 && (
<small className="sidebar-profile__notification-button-badge">
{notificationCount > 99 ? "99+" : notificationCount}
</small>
)}
<BellIcon size={16} />
</button>
);
}, [t, notificationCount, navigate]);
const friendsButton = useMemo(() => {
if (!userDetails) return null;
@@ -98,6 +184,7 @@ export function SidebarProfile() {
</div>
</button>
{notificationsButton}
{friendsButton}
</div>
);

View File

@@ -14,6 +14,7 @@ import type {
GameStats,
UserDetails,
FriendRequestSync,
NotificationSync,
GameArtifact,
LudusaviBackup,
UserAchievement,
@@ -31,6 +32,7 @@ import type {
Game,
DiskUsage,
DownloadSource,
LocalNotification,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -391,6 +393,9 @@ declare global {
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;
onSyncNotificationCount: (
cb: (notification: NotificationSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
@@ -398,6 +403,15 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
getLocalNotifications: () => Promise<LocalNotification[]>;
getLocalNotificationsCount: () => Promise<number>;
markLocalNotificationRead: (id: string) => Promise<void>;
markAllLocalNotificationsRead: () => Promise<void>;
deleteLocalNotification: (id: string) => Promise<void>;
clearAllLocalNotifications: () => Promise<void>;
onLocalNotificationCreated: (
cb: (notification: LocalNotification) => void
) => () => Electron.IpcRenderer;
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,

View File

@@ -31,6 +31,7 @@ import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
import Library from "./pages/library/library";
import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
console.log = logger.log;
@@ -76,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />

View File

@@ -1,6 +1,7 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const [searchParams] = useSearchParams();
const reviewsRef = useRef<HTMLDivElement>(null);
const {
objectId,
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
getGameArtifacts();
}, [getGameArtifacts]);
// Scroll to reviews section if reviews=true in URL
useEffect(() => {
const shouldScrollToReviews = searchParams.get("reviews") === "true";
if (shouldScrollToReviews && reviewsRef.current) {
setTimeout(() => {
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
}, 500);
}
}, [searchParams, objectId]);
const isCustomGame = game?.shop === "custom";
const heroImage = isCustomGame
@@ -229,15 +242,17 @@ export function GameDetailsContent() {
)}
{shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
<div ref={reviewsRef}>
<GameReviews
shop={shop}
objectId={objectId}
game={game}
userDetailsId={userDetails?.id}
isGameInLibrary={isGameInLibrary}
hasUserReviewed={hasUserReviewed}
onUserReviewedChange={setHasUserReviewed}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,104 @@
import { useCallback } from "react";
import {
XIcon,
DownloadIcon,
PackageIcon,
SyncIcon,
TrophyIcon,
ClockIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDate } from "@renderer/hooks";
import cn from "classnames";
import type { LocalNotification } from "@types";
import "./notification-item.scss";
interface LocalNotificationItemProps {
notification: LocalNotification;
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
}
export function LocalNotificationItem({
notification,
onDismiss,
onMarkAsRead,
}: LocalNotificationItemProps) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(notification.url);
}
}, [notification, onMarkAsRead, navigate]);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getIcon = () => {
switch (notification.type) {
case "DOWNLOAD_COMPLETE":
return <DownloadIcon size={24} />;
case "EXTRACTION_COMPLETE":
return <PackageIcon size={24} />;
case "UPDATE_AVAILABLE":
return <SyncIcon size={24} />;
case "ACHIEVEMENT_UNLOCKED":
return <TrophyIcon size={24} />;
default:
return <DownloadIcon size={24} />;
}
};
return (
<div
className={cn("notification-item", {
"notification-item--unread": !notification.isRead,
})}
onClick={handleClick}
role="button"
tabIndex={0}
>
<div className="notification-item__picture">
{notification.pictureUrl ? (
<img src={notification.pictureUrl} alt="" />
) : (
getIcon()
)}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{notification.title}</span>
<span className="notification-item__description">
{notification.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
</div>
);
}

View File

@@ -0,0 +1,120 @@
@use "../../scss/globals.scss";
.notification-item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
transition: all ease 0.2s;
position: relative;
opacity: 0.4;
&:hover {
background-color: rgba(255, 255, 255, 0.03);
opacity: 0.6;
}
&--unread {
border-left: 3px solid globals.$brand-teal;
opacity: 1;
&:hover {
opacity: 1;
}
.notification-item__title {
color: #fff;
}
}
&__picture {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: globals.$background-color;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__badge-picture {
border-radius: 8px;
background-color: globals.$background-color;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
&__review-picture {
color: #f5a623;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
}
&__title {
font-size: globals.$body-font-size;
font-weight: 600;
color: globals.$muted-color;
}
&__description {
font-size: globals.$small-font-size;
color: globals.$body-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__time {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
font-size: globals.$small-font-size;
color: rgba(255, 255, 255, 0.5);
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
flex-shrink: 0;
}
&__dismiss {
position: absolute;
top: calc(globals.$spacing-unit / 2);
right: calc(globals.$spacing-unit / 2);
background: transparent;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
}
}

View File

@@ -0,0 +1,227 @@
import { useCallback, useMemo } from "react";
import {
XIcon,
PersonIcon,
ClockIcon,
StarFillIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@renderer/components";
import { useDate, useUserDetails } from "@renderer/hooks";
import cn from "classnames";
import type { Notification, Badge } from "@types";
import "./notification-item.scss";
const parseNotificationUrl = (notificationUrl: string): string => {
const url = new URL(notificationUrl, "http://localhost");
const userId = url.searchParams.get("userId");
const badgeName = url.searchParams.get("name");
const gameTitle = url.searchParams.get("title");
const showReviews = url.searchParams.get("reviews");
if (url.pathname === "/profile" && userId) {
return `/profile/${userId}`;
}
if (url.pathname === "/badges" && badgeName) {
return `/badges/${badgeName}`;
}
if (url.pathname.startsWith("/game/")) {
const params = new URLSearchParams();
if (gameTitle) params.set("title", gameTitle);
if (showReviews) params.set("reviews", showReviews);
const queryString = params.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}
return notificationUrl;
};
interface NotificationItemProps {
notification: Notification;
badges: Badge[];
onDismiss: (id: string) => void;
onMarkAsRead: (id: string) => void;
onAcceptFriendRequest?: (senderId: string) => void;
onRefuseFriendRequest?: (senderId: string) => void;
}
export function NotificationItem({
notification,
badges,
onDismiss,
onMarkAsRead,
onAcceptFriendRequest,
onRefuseFriendRequest,
}: Readonly<NotificationItemProps>) {
const { t } = useTranslation("notifications_page");
const { formatDistance } = useDate();
const navigate = useNavigate();
const { updateFriendRequestState } = useUserDetails();
const badge = useMemo(() => {
if (notification.type !== "BADGE_RECEIVED") return null;
return badges.find((b) => b.name === notification.variables.badgeName);
}, [notification, badges]);
const handleClick = useCallback(() => {
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
if (notification.url) {
navigate(parseNotificationUrl(notification.url));
}
}, [notification, onMarkAsRead, navigate]);
const handleAccept = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "ACCEPTED");
onAcceptFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
);
const handleRefuse = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
const senderId = notification.variables.senderId;
if (senderId) {
await updateFriendRequestState(senderId, "REFUSED");
onRefuseFriendRequest?.(senderId);
onDismiss(notification.id);
}
},
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
);
const handleDismiss = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDismiss(notification.id);
},
[notification.id, onDismiss]
);
const getNotificationContent = () => {
switch (notification.type) {
case "FRIEND_REQUEST_RECEIVED":
return {
title: t("friend_request_received_title"),
description: t("friend_request_received_description", {
displayName: notification.variables.senderDisplayName,
}),
showActions: true,
};
case "FRIEND_REQUEST_ACCEPTED":
return {
title: t("friend_request_accepted_title"),
description: t("friend_request_accepted_description", {
displayName: notification.variables.senderDisplayName,
}),
showActions: false,
};
case "BADGE_RECEIVED":
return {
title: t("badge_received_title"),
description: badge?.description || notification.variables.badgeName,
showActions: false,
};
case "REVIEW_UPVOTE":
return {
title: t("review_upvote_title", {
gameTitle: notification.variables.gameTitle,
}),
description: t("review_upvote_description", {
count: Number.parseInt(
notification.variables.upvoteCount || "1",
10
),
}),
showActions: false,
};
default:
return {
title: t("notification"),
description: "",
showActions: false,
};
}
};
const content = getNotificationContent();
const isBadge = notification.type === "BADGE_RECEIVED";
const isReview = notification.type === "REVIEW_UPVOTE";
const getIcon = () => {
if (notification.pictureUrl) {
return <img src={notification.pictureUrl} alt="" />;
}
if (isReview) {
return <StarFillIcon size={24} />;
}
return <PersonIcon size={24} />;
};
return (
<div
className={cn("notification-item", {
"notification-item--unread": !notification.isRead,
})}
onClick={handleClick}
role="button"
tabIndex={0}
>
<div
className={cn("notification-item__picture", {
"notification-item__badge-picture": isBadge,
"notification-item__review-picture": isReview,
})}
>
{getIcon()}
</div>
<div className="notification-item__content">
<span className="notification-item__title">{content.title}</span>
<span className="notification-item__description">
{content.description}
</span>
<span className="notification-item__time">
<ClockIcon size={12} />
{formatDistance(new Date(notification.createdAt), new Date())}
</span>
</div>
{content.showActions &&
notification.type === "FRIEND_REQUEST_RECEIVED" && (
<div className="notification-item__actions">
<Button theme="primary" onClick={handleAccept}>
{t("accept")}
</Button>
<Button theme="outline" onClick={handleRefuse}>
{t("refuse")}
</Button>
</div>
)}
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
<button
type="button"
className="notification-item__dismiss"
onClick={handleDismiss}
title={t("dismiss")}
>
<XIcon size={16} />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
@use "../../scss/globals.scss";
.notifications {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 3);
width: 100%;
max-width: 800px;
margin: 0 auto;
&__actions {
display: flex;
gap: globals.$spacing-unit;
justify-content: flex-end;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 6);
text-align: center;
color: globals.$body-color;
&-icon {
opacity: 0.4;
}
&-title {
font-size: 18px;
font-weight: 600;
color: globals.$muted-color;
}
&-description {
font-size: globals.$body-font-size;
}
}
&__loading {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 4);
}
&__load-more {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -0,0 +1,392 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "@renderer/components";
import { useAppDispatch, useToast, useUserDetails } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { logger } from "@renderer/logger";
import { NotificationItem } from "./notification-item";
import { LocalNotificationItem } from "./local-notification-item";
import type {
Notification,
LocalNotification,
NotificationsResponse,
MergedNotification,
Badge,
} from "@types";
import "./notifications.scss";
export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast();
const { userDetails } = useUserDetails();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setHeaderTitle(t("title")));
}, [dispatch, t]);
const [apiNotifications, setApiNotifications] = useState<Notification[]>([]);
const [localNotifications, setLocalNotifications] = useState<
LocalNotification[]
>([]);
const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set());
const [pagination, setPagination] = useState({
total: 0,
hasMore: false,
skip: 0,
});
const fetchLocalNotifications = useCallback(async () => {
try {
const notifications = await window.electron.getLocalNotifications();
setLocalNotifications(notifications);
} catch (error) {
logger.error("Failed to fetch local notifications", error);
}
}, []);
const fetchBadges = useCallback(async () => {
try {
const language = i18n.language.split("-")[0];
const params = new URLSearchParams({ locale: language });
const badgesResponse = await window.electron.hydraApi.get<Badge[]>(
`/badges?${params.toString()}`,
{ needsAuth: false }
);
setBadges(badgesResponse);
} catch (error) {
logger.error("Failed to fetch badges", error);
}
}, [i18n.language]);
const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => {
if (!userDetails) return;
try {
setIsLoading(true);
const response =
await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications",
{
params: { filter: "all", take: 20, skip },
needsAuth: true,
}
);
logger.log("Notifications API response:", response);
if (append) {
setApiNotifications((prev) => [...prev, ...response.notifications]);
} else {
setApiNotifications(response.notifications);
}
setPagination({
total: response.pagination.total,
hasMore: response.pagination.hasMore,
skip: response.pagination.skip + response.pagination.take,
});
} catch (error) {
logger.error("Failed to fetch API notifications", error);
} finally {
setIsLoading(false);
}
},
[userDetails]
);
const fetchAllNotifications = useCallback(async () => {
setIsLoading(true);
await Promise.all([
fetchLocalNotifications(),
fetchBadges(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(),
]);
setIsLoading(false);
}, [
fetchLocalNotifications,
fetchBadges,
fetchApiNotifications,
userDetails,
]);
useEffect(() => {
fetchAllNotifications();
}, [fetchAllNotifications]);
useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated(
(notification) => {
setLocalNotifications((prev) => [notification, ...prev]);
}
);
return () => unsubscribe();
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
// High priority notifications (priority === 1) - keep in API order
const highPriority: MergedNotification[] = apiNotifications
.filter((n) => n.priority === 1)
.map((n) => ({ ...n, source: "api" as const }));
// Low priority: other API notifications + local notifications, merged and sorted by date
const lowPriorityApi: MergedNotification[] = apiNotifications
.filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map(
(n) => ({
...n,
source: "local" as const,
})
);
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate
);
return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications]);
const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id));
}, [mergedNotifications, clearingIds]);
const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged"));
}, []);
const handleMarkAsRead = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.patch(
`/profile/notifications/${id}/read`,
{
data: { id },
needsAuth: true,
}
);
setApiNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
} else {
await window.electron.markLocalNotificationRead(id);
setLocalNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
);
}
notifyCountChange();
} catch (error) {
logger.error("Failed to mark notification as read", error);
}
},
[notifyCountChange]
);
const handleMarkAllAsRead = useCallback(async () => {
try {
// Mark all API notifications as read
if (userDetails && apiNotifications.some((n) => !n.isRead)) {
await window.electron.hydraApi.patch(
`/profile/notifications/all/read`,
{ needsAuth: true }
);
setApiNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
}
// Mark all local notifications as read
await window.electron.markAllLocalNotificationsRead();
setLocalNotifications((prev) =>
prev.map((n) => ({ ...n, isRead: true }))
);
notifyCountChange();
showSuccessToast(t("marked_all_as_read"));
} catch (error) {
logger.error("Failed to mark all as read", error);
showErrorToast(t("failed_to_mark_as_read"));
}
}, [
apiNotifications,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleDismiss = useCallback(
async (id: string, source: "api" | "local") => {
try {
if (source === "api") {
await window.electron.hydraApi.delete(
`/profile/notifications/${id}`,
{ needsAuth: true }
);
setApiNotifications((prev) => prev.filter((n) => n.id !== id));
setPagination((prev) => ({ ...prev, total: prev.total - 1 }));
} else {
await window.electron.deleteLocalNotification(id);
setLocalNotifications((prev) => prev.filter((n) => n.id !== id));
}
notifyCountChange();
} catch (error) {
logger.error("Failed to dismiss notification", error);
showErrorToast(t("failed_to_dismiss"));
}
},
[showErrorToast, t, notifyCountChange]
);
const handleClearAll = useCallback(async () => {
try {
// Mark all as clearing for animation
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
// Wait for exit animation
await new Promise((resolve) => setTimeout(resolve, 300));
// Clear all API notifications
if (userDetails && apiNotifications.length > 0) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true,
});
setApiNotifications([]);
}
// Clear all local notifications
await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange();
showSuccessToast(t("cleared_all"));
} catch (error) {
logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear"));
}
}, [
apiNotifications,
localNotifications,
userDetails,
showSuccessToast,
showErrorToast,
t,
notifyCountChange,
]);
const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true);
}
}, [pagination, isLoading, fetchApiNotifications]);
const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted"));
}, [showSuccessToast, t]);
const handleRefuseFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_refused"));
}, [showSuccessToast, t]);
const renderNotification = (notification: MergedNotification) => {
const key =
notification.source === "local"
? `local-${notification.id}`
: `api-${notification.id}`;
return (
<motion.div
key={key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }}
transition={{ duration: 0.2 }}
>
{notification.source === "local" ? (
<LocalNotificationItem
notification={notification}
onDismiss={(id) => handleDismiss(id, "local")}
onMarkAsRead={(id) => handleMarkAsRead(id, "local")}
/>
) : (
<NotificationItem
notification={notification}
badges={badges}
onDismiss={(id) => handleDismiss(id, "api")}
onMarkAsRead={(id) => handleMarkAsRead(id, "api")}
onAcceptFriendRequest={handleAcceptFriendRequest}
onRefuseFriendRequest={handleRefuseFriendRequest}
/>
)}
</motion.div>
);
};
return (
<div className="notifications">
<div className="notifications__actions">
<Button theme="outline" onClick={handleMarkAllAsRead}>
{t("mark_all_as_read")}
</Button>
<Button theme="danger" onClick={handleClearAll}>
{t("clear_all")}
</Button>
</div>
{isLoading && mergedNotifications.length === 0 ? (
<div className="notifications__loading">
<span>{t("loading")}</span>
</div>
) : mergedNotifications.length === 0 ? (
<div className="notifications__empty">
<BellIcon size={48} className="notifications__empty-icon" />
<span className="notifications__empty-title">{t("empty_title")}</span>
<span className="notifications__empty-description">
{t("empty_description")}
</span>
</div>
) : (
<>
<div className="notifications__list">
<AnimatePresence mode="popLayout">
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
{pagination.hasMore && (
<div className="notifications__load-more">
<Button
theme="outline"
onClick={handleLoadMore}
disabled={isLoading}
>
{isLoading ? t("loading") : t("load_more")}
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
@use "../../../scss/globals.scss";
.all-friends-modal {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-height: 400px;
margin-top: calc(globals.$spacing-unit * -1);
&__title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__count {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
}
&__list {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
overflow-y: auto;
padding-right: globals.$spacing-unit;
}
&__item {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 1.5);
border-radius: 8px;
cursor: pointer;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
&__info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 2);
flex: 1;
min-width: 0;
}
&__name {
font-weight: 600;
color: globals.$muted-color;
font-size: globals.$body-font-size;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__game {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
font-size: globals.$small-font-size;
color: globals.$body-color;
img {
border-radius: 4px;
}
small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4);
color: globals.$body-color;
}
&__loading {
display: flex;
justify-content: center;
padding: calc(globals.$spacing-unit * 2);
}
&__load-more {
display: flex;
justify-content: center;
padding-top: globals.$spacing-unit;
}
&__remove {
flex-shrink: 0;
background: none;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: globals.$spacing-unit;
border-radius: 50%;
transition: all ease 0.2s;
opacity: 0.5;
&:hover {
opacity: 1;
color: globals.$error-color;
background-color: rgba(globals.$error-color, 0.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}

View File

@@ -0,0 +1,204 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { XCircleIcon } from "@primer/octicons-react";
import { Modal, Avatar, Button } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import type { UserFriend } from "@types";
import "./all-friends-modal.scss";
interface AllFriendsModalProps {
visible: boolean;
onClose: () => void;
userId: string;
isMe: boolean;
}
const PAGE_SIZE = 20;
export function AllFriendsModal({
visible,
onClose,
userId,
isMe,
}: AllFriendsModalProps) {
const { t } = useTranslation("user_profile");
const navigate = useNavigate();
const { undoFriendship } = useUserDetails();
const { showSuccessToast, showErrorToast } = useToast();
const [friends, setFriends] = useState<UserFriend[]>([]);
const [totalFriends, setTotalFriends] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const [removingId, setRemovingId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const fetchFriends = useCallback(
async (pageNum: number, append = false) => {
if (isLoading) return;
setIsLoading(true);
try {
const url = isMe ? "/profile/friends" : `/users/${userId}/friends`;
const response = await window.electron.hydraApi.get<{
totalFriends: number;
friends: UserFriend[];
}>(url, {
params: { take: PAGE_SIZE, skip: pageNum * PAGE_SIZE },
});
if (append) {
setFriends((prev) => [...prev, ...response.friends]);
} else {
setFriends(response.friends);
}
setTotalFriends(response.totalFriends);
setHasMore((pageNum + 1) * PAGE_SIZE < response.totalFriends);
setPage(pageNum + 1);
} catch (error) {
logger.error("Failed to fetch friends", error);
} finally {
setIsLoading(false);
}
},
[userId, isMe, isLoading]
);
useEffect(() => {
if (visible) {
setFriends([]);
setPage(0);
setHasMore(true);
fetchFriends(0, false);
}
}, [visible, userId]);
const handleScroll = useCallback(() => {
if (!listRef.current || isLoading || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
if (scrollTop + clientHeight >= scrollHeight - 50) {
fetchFriends(page, true);
}
}, [isLoading, hasMore, page, fetchFriends]);
const handleFriendClick = (friendId: string) => {
onClose();
navigate(`/profile/${friendId}`);
};
const handleLoadMore = () => {
if (!isLoading && hasMore) {
fetchFriends(page, true);
}
};
const handleRemoveFriend = useCallback(
async (e: React.MouseEvent, friendId: string) => {
e.stopPropagation();
setRemovingId(friendId);
try {
await undoFriendship(friendId);
setFriends((prev) => prev.filter((f) => f.id !== friendId));
setTotalFriends((prev) => prev - 1);
showSuccessToast(t("friendship_removed"));
} catch (error) {
logger.error("Failed to remove friend", error);
showErrorToast(t("try_again"));
} finally {
setRemovingId(null);
}
},
[undoFriendship, showSuccessToast, showErrorToast, t]
);
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return <img alt={game.title} width={16} height={16} src={game.iconUrl} />;
}
return <SteamLogo width={16} height={16} />;
};
const modalTitle = (
<div className="all-friends-modal__title">
{t("friends")}
{totalFriends > 0 && (
<span className="all-friends-modal__count">{totalFriends}</span>
)}
</div>
);
return (
<Modal visible={visible} title={modalTitle} onClose={onClose}>
<div className="all-friends-modal">
{friends.length === 0 && !isLoading ? (
<div className="all-friends-modal__empty">
{t("no_friends_added")}
</div>
) : (
<div
ref={listRef}
className="all-friends-modal__list"
onScroll={handleScroll}
>
{friends.map((friend) => (
<div
key={friend.id}
className="all-friends-modal__item"
onClick={() => handleFriendClick(friend.id)}
role="button"
tabIndex={0}
>
<Avatar
size={40}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div className="all-friends-modal__info">
<span className="all-friends-modal__name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="all-friends-modal__game">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
{isMe && (
<button
type="button"
className="all-friends-modal__remove"
onClick={(e) => handleRemoveFriend(e, friend.id)}
disabled={removingId === friend.id}
title={t("undo_friendship")}
>
<XCircleIcon size={20} />
</button>
)}
</div>
))}
</div>
)}
{isLoading && (
<div className="all-friends-modal__loading">{t("loading")}...</div>
)}
{hasMore && !isLoading && friends.length > 0 && (
<div className="all-friends-modal__load-more">
<Button theme="outline" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,44 @@
@use "../../../scss/globals.scss";
.badges-box {
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__box {
background-color: globals.$background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
padding: calc(globals.$spacing-unit * 2);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: calc(globals.$spacing-unit * 2);
}
&__item {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
cursor: pointer;
transition: all ease 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.05);
}
img {
border-radius: 4px;
}
}
}

View File

@@ -0,0 +1,56 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "react-tooltip";
import "./badges-box.scss";
export function BadgesBox() {
const { userProfile, badges } = useContext(userProfileContext);
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
if (!userProfile?.badges.length) return null;
return (
<div>
<div className="badges-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("badges")}</h2>
<span className="profile-content__section-badge">
{numberFormatter.format(userProfile.badges.length)}
</span>
</div>
</div>
<div className="badges-box__box">
<div className="badges-box__list">
{userProfile.badges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
if (!badge) return null;
return (
<div
key={badge.name}
className="badges-box__item"
data-tooltip-place="top"
data-tooltip-content={badge.description}
data-tooltip-id="badges-box-tooltip"
>
<img
src={badge.badge.url}
alt={badge.name}
width={32}
height={32}
/>
</div>
);
})}
</div>
<Tooltip id="badges-box-tooltip" />
</div>
</div>
);
}

View File

@@ -63,4 +63,19 @@
&__game-image {
border-radius: 4px;
}
&__view-all {
background: none;
border: none;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color ease 0.2s;
&:hover {
color: globals.$muted-color;
}
}
}

View File

@@ -1,15 +1,20 @@
import { userProfileContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { useContext } from "react";
import { useFormat, useUserDetails } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { Avatar, Link } from "@renderer/components";
import { AllFriendsModal } from "./all-friends-modal";
import "./friends-box.scss";
export function FriendsBox() {
const { userProfile, userStats } = useContext(userProfileContext);
const { userDetails } = useUserDetails();
const { t } = useTranslation("user_profile");
const { numberFormatter } = useFormat();
const [showAllFriendsModal, setShowAllFriendsModal] = useState(false);
const isMe = userDetails?.id === userProfile?.id;
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
@@ -29,55 +34,73 @@ export function FriendsBox() {
if (!userProfile?.friends.length) return null;
return (
<div>
<div className="friends-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("friends")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.friendsCount)}
</span>
)}
<>
<div>
<div className="friends-box__section-header">
<div className="profile-content__section-title-group">
<h2>{t("friends")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.friendsCount)}
</span>
)}
</div>
<button
type="button"
className="friends-box__view-all"
onClick={() => setShowAllFriendsModal(true)}
>
{t("view_all")}
</button>
</div>
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
<li
key={friend.id}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
>
<Link
to={`/profile/${friend.id}`}
className="friends-box__list-item"
>
<Avatar
size={32}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div className="friends-box__friend-details">
<span className="friends-box__friend-name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="friends-box__game-info">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="friends-box__box">
<ul className="friends-box__list">
{userProfile?.friends.map((friend) => (
<li
key={friend.id}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
>
<Link
to={`/profile/${friend.id}`}
className="friends-box__list-item"
>
<Avatar
size={32}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
<div className="friends-box__friend-details">
<span className="friends-box__friend-name">
{friend.displayName}
</span>
{friend.currentGame && (
<div className="friends-box__game-info">
{getGameImage(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
)}
</div>
</Link>
</li>
))}
</ul>
</div>
</div>
{userProfile && (
<AllFriendsModal
visible={showAllFriendsModal}
onClose={() => setShowAllFriendsModal(false)}
userId={userProfile.id}
isMe={isMe}
/>
)}
</>
);
}

View File

@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
import type { GameShop } from "@types";
import { LockedProfile } from "./locked-profile";
import { ReportProfile } from "../report-profile/report-profile";
import { BadgesBox } from "./badges-box";
import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserStatsBox } from "./user-stats-box";
@@ -429,6 +430,7 @@ export function ProfileContent() {
<UserStatsBox />
<UserKarmaBox />
<RecentGamesBox />
<BadgesBox />
<FriendsBox />
<ReportProfile />
</div>

View File

@@ -27,9 +27,23 @@
}
}
&__badges {
&__copy-button {
display: flex;
gap: calc(globals.$spacing-unit / 2);
align-items: center;
justify-content: center;
background: none;
border: none;
color: globals.$body-color;
cursor: pointer;
padding: calc(globals.$spacing-unit / 2);
border-radius: 4px;
transition: all ease 0.2s;
opacity: 0.7;
&:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
}
&__user-information {

View File

@@ -3,6 +3,7 @@ import { userProfileContext } from "@renderer/context";
import {
BlockedIcon,
CheckCircleFillIcon,
CopyIcon,
PencilIcon,
PersonAddIcon,
SignOutIcon,
@@ -24,7 +25,6 @@ import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import { Tooltip } from "react-tooltip";
import "./profile-hero.scss";
type FriendAction =
@@ -35,14 +35,8 @@ export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const {
isMe,
badges,
getUserProfile,
userProfile,
heroBackground,
backgroundImage,
} = useContext(userProfileContext);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext);
const {
signOut,
updateFriendRequestState,
@@ -251,6 +245,13 @@ export function ProfileHero() {
}
}, [isMe]);
const copyFriendCode = useCallback(() => {
if (userProfile?.id) {
navigator.clipboard.writeText(userProfile.id);
showSuccessToast(t("friend_code_copied"));
}
}, [userProfile, showSuccessToast, t]);
const currentGame = useMemo(() => {
if (isMe) {
if (gameRunning)
@@ -311,28 +312,14 @@ export function ProfileHero() {
{userProfile?.displayName}
</h2>
<div className="profile-hero__badges">
{userProfile.badges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
if (!badge) return null;
return (
<img
key={badge.name}
src={badge.badge.url}
alt={badge.name}
width={24}
height={24}
data-tooltip-place="top"
data-tooltip-content={badge.description}
data-tooltip-id="badge-name"
/>
);
})}
<Tooltip id="badge-name" />
</div>
<button
type="button"
className="profile-hero__copy-button"
onClick={copyFriendCode}
title={t("copy_friend_code")}
>
<CopyIcon size={16} />
</button>
</div>
) : (
<Skeleton width={150} height={28} />

View File

@@ -144,6 +144,10 @@ export interface FriendRequestSync {
friendRequestCount: number;
}
export interface NotificationSync {
notificationCount: number;
}
export interface FriendRequest {
id: string;
displayName: string;
@@ -312,6 +316,58 @@ export interface GameArtifact {
isFrozen: boolean;
}
export type NotificationType =
| "FRIEND_REQUEST_RECEIVED"
| "FRIEND_REQUEST_ACCEPTED"
| "BADGE_RECEIVED"
| "REVIEW_UPVOTE";
export type LocalNotificationType =
| "EXTRACTION_COMPLETE"
| "DOWNLOAD_COMPLETE"
| "UPDATE_AVAILABLE"
| "ACHIEVEMENT_UNLOCKED";
export interface Notification {
id: string;
type: NotificationType;
variables: Record<string, string>;
pictureUrl: string | null;
url: string | null;
isRead: boolean;
priority: number;
createdAt: string;
}
export interface LocalNotification {
id: string;
type: LocalNotificationType;
title: string;
description: string;
pictureUrl: string | null;
url: string | null;
isRead: boolean;
createdAt: string;
}
export type MergedNotification =
| (Notification & { source: "api" })
| (LocalNotification & { source: "local" });
export interface NotificationsResponse {
notifications: Notification[];
pagination: {
total: number;
take: number;
skip: number;
hasMore: boolean;
};
}
export interface NotificationCountResponse {
count: number;
}
export interface ComparedAchievements {
achievementsPointsTotal: number;
owner: {