From b8352be2747586311ed46753dbe2417733ddd364 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 16 Dec 2025 15:18:21 +0200 Subject: [PATCH] feat: add local notifications management and UI integration --- proto | 2 +- src/locales/en/translation.json | 42 ++ .../clear-all-local-notifications.ts | 8 + .../delete-local-notification.ts | 11 + .../get-local-notifications-count.ts | 8 + .../notifications/get-local-notifications.ts | 8 + src/main/events/notifications/index.ts | 6 + .../mark-all-local-notifications-read.ts | 8 + .../mark-local-notification-read.ts | 11 + src/main/generated/envelope.ts | 131 +++++- src/main/level/sublevels/index.ts | 1 + src/main/level/sublevels/keys.ts | 1 + .../level/sublevels/local-notifications.ts | 11 + src/main/services/index.ts | 1 + src/main/services/notifications/index.ts | 74 +++- .../notifications/local-notifications.ts | 99 +++++ src/main/services/ws/events/notification.ts | 8 + src/main/services/ws/ws-client.ts | 5 + src/preload/index.ts | 30 ++ src/renderer/src/components/header/header.tsx | 1 + .../components/sidebar/sidebar-profile.scss | 4 + .../components/sidebar/sidebar-profile.tsx | 91 +++- src/renderer/src/declaration.d.ts | 14 + src/renderer/src/main.tsx | 2 + .../game-details/game-details-content.tsx | 35 +- .../notifications/local-notification-item.tsx | 104 +++++ .../notifications/notification-item.scss | 120 ++++++ .../pages/notifications/notification-item.tsx | 227 ++++++++++ .../pages/notifications/notifications.scss | 60 +++ .../src/pages/notifications/notifications.tsx | 392 ++++++++++++++++++ .../profile-content/all-friends-modal.scss | 124 ++++++ .../profile-content/all-friends-modal.tsx | 204 +++++++++ .../profile/profile-content/badges-box.scss | 44 ++ .../profile/profile-content/badges-box.tsx | 56 +++ .../profile/profile-content/friends-box.scss | 15 + .../profile/profile-content/friends-box.tsx | 121 +++--- .../profile-content/profile-content.tsx | 2 + .../profile/profile-hero/profile-hero.scss | 18 +- .../profile/profile-hero/profile-hero.tsx | 49 +-- src/types/index.ts | 56 +++ 40 files changed, 2082 insertions(+), 122 deletions(-) create mode 100644 src/main/events/notifications/clear-all-local-notifications.ts create mode 100644 src/main/events/notifications/delete-local-notification.ts create mode 100644 src/main/events/notifications/get-local-notifications-count.ts create mode 100644 src/main/events/notifications/get-local-notifications.ts create mode 100644 src/main/events/notifications/mark-all-local-notifications-read.ts create mode 100644 src/main/events/notifications/mark-local-notification-read.ts create mode 100644 src/main/level/sublevels/local-notifications.ts create mode 100644 src/main/services/notifications/local-notifications.ts create mode 100644 src/main/services/ws/events/notification.ts create mode 100644 src/renderer/src/pages/notifications/local-notification-item.tsx create mode 100644 src/renderer/src/pages/notifications/notification-item.scss create mode 100644 src/renderer/src/pages/notifications/notification-item.tsx create mode 100644 src/renderer/src/pages/notifications/notifications.scss create mode 100644 src/renderer/src/pages/notifications/notifications.tsx create mode 100644 src/renderer/src/pages/profile/profile-content/all-friends-modal.scss create mode 100644 src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx create mode 100644 src/renderer/src/pages/profile/profile-content/badges-box.scss create mode 100644 src/renderer/src/pages/profile/profile-content/badges-box.tsx diff --git a/proto b/proto index 7a23620f..6f11c99c 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 7a23620f930f6fbb84c0abcaab5149a34ab4b4eb +Subproject commit 6f11c99c572420a282ba5149b6866e39b8a4569c diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9be4ff26..c76cca1d 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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" } } diff --git a/src/main/events/notifications/clear-all-local-notifications.ts b/src/main/events/notifications/clear-all-local-notifications.ts new file mode 100644 index 00000000..8a72b894 --- /dev/null +++ b/src/main/events/notifications/clear-all-local-notifications.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const clearAllLocalNotifications = async () => { + await LocalNotificationManager.clearAll(); +}; + +registerEvent("clearAllLocalNotifications", clearAllLocalNotifications); diff --git a/src/main/events/notifications/delete-local-notification.ts b/src/main/events/notifications/delete-local-notification.ts new file mode 100644 index 00000000..0d22877b --- /dev/null +++ b/src/main/events/notifications/delete-local-notification.ts @@ -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); diff --git a/src/main/events/notifications/get-local-notifications-count.ts b/src/main/events/notifications/get-local-notifications-count.ts new file mode 100644 index 00000000..072e74d5 --- /dev/null +++ b/src/main/events/notifications/get-local-notifications-count.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const getLocalNotificationsCount = async () => { + return LocalNotificationManager.getUnreadCount(); +}; + +registerEvent("getLocalNotificationsCount", getLocalNotificationsCount); diff --git a/src/main/events/notifications/get-local-notifications.ts b/src/main/events/notifications/get-local-notifications.ts new file mode 100644 index 00000000..b15eef86 --- /dev/null +++ b/src/main/events/notifications/get-local-notifications.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const getLocalNotifications = async () => { + return LocalNotificationManager.getNotifications(); +}; + +registerEvent("getLocalNotifications", getLocalNotifications); diff --git a/src/main/events/notifications/index.ts b/src/main/events/notifications/index.ts index c6e681e8..cbae29e5 100644 --- a/src/main/events/notifications/index.ts +++ b/src/main/events/notifications/index.ts @@ -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"; diff --git a/src/main/events/notifications/mark-all-local-notifications-read.ts b/src/main/events/notifications/mark-all-local-notifications-read.ts new file mode 100644 index 00000000..a8ae3729 --- /dev/null +++ b/src/main/events/notifications/mark-all-local-notifications-read.ts @@ -0,0 +1,8 @@ +import { registerEvent } from "../register-event"; +import { LocalNotificationManager } from "@main/services"; + +const markAllLocalNotificationsRead = async () => { + await LocalNotificationManager.markAllAsRead(); +}; + +registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead); diff --git a/src/main/events/notifications/mark-local-notification-read.ts b/src/main/events/notifications/mark-local-notification-read.ts new file mode 100644 index 00000000..6958c258 --- /dev/null +++ b/src/main/events/notifications/mark-local-notification-read.ts @@ -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); diff --git a/src/main/generated/envelope.ts b/src/main/generated/envelope.ts index 0a17a2af..ace32b2d 100644 --- a/src/main/generated/envelope.ts +++ b/src/main/generated/envelope.ts @@ -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 { */ export const FriendGameSession = new FriendGameSession$Type(); // @generated message type with reflection information, may provide speed optimized methods +class Notification$Type extends MessageType { + constructor() { + super("Notification", [ + { + no: 1, + name: "notification_count", + kind: "scalar", + T: 5 /*ScalarType.INT32*/, + }, + ]); + } + create(value?: PartialMessage): Notification { + const message = globalThis.Object.create(this.messagePrototype!); + message.notificationCount = 0; + if (value !== undefined) + reflectionMergePartial(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 { constructor() { super("Envelope", [ @@ -256,6 +346,13 @@ class Envelope$Type extends MessageType { oneof: "payload", T: () => FriendGameSession, }, + { + no: 3, + name: "notification", + kind: "message", + oneof: "payload", + T: () => Notification, + }, ]); } create(value?: PartialMessage): Envelope { @@ -298,6 +395,17 @@ class Envelope$Type extends MessageType { ), }; 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 { 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)( diff --git a/src/main/level/sublevels/index.ts b/src/main/level/sublevels/index.ts index 4575bbc4..54cf2e62 100644 --- a/src/main/level/sublevels/index.ts +++ b/src/main/level/sublevels/index.ts @@ -8,3 +8,4 @@ export * from "./keys"; export * from "./themes"; export * from "./download-sources"; export * from "./downloadSourcesCheckTimestamp"; +export * from "./local-notifications"; diff --git a/src/main/level/sublevels/keys.ts b/src/main/level/sublevels/keys.ts index 89c33f8d..d055d1e6 100644 --- a/src/main/level/sublevels/keys.ts +++ b/src/main/level/sublevels/keys.ts @@ -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", }; diff --git a/src/main/level/sublevels/local-notifications.ts b/src/main/level/sublevels/local-notifications.ts new file mode 100644 index 00000000..847a1c99 --- /dev/null +++ b/src/main/level/sublevels/local-notifications.ts @@ -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", +}); diff --git a/src/main/services/index.ts b/src/main/services/index.ts index a3891dc6..e6ceef03 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -20,3 +20,4 @@ export * from "./lock"; export * from "./decky-plugin"; export * from "./user"; export * from "./download-sources-checker"; +export * from "./notifications/local-notifications"; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index a925e7c7..0fa07c8c 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -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: { diff --git a/src/main/services/notifications/local-notifications.ts b/src/main/services/notifications/local-notifications.ts new file mode 100644 index 00000000..94b832df --- /dev/null +++ b/src/main/services/notifications/local-notifications.ts @@ -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 { + 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 { + 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 { + let count = 0; + + for await (const [, value] of localNotificationsSublevel.iterator()) { + if (!value.isRead) { + count++; + } + } + + return count; + } + + static async markAsRead(id: string): Promise { + const notification = await localNotificationsSublevel.get(id); + if (notification) { + notification.isRead = true; + await localNotificationsSublevel.put(id, notification); + } + } + + static async markAllAsRead(): Promise { + 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 { + await localNotificationsSublevel.del(id); + } + + static async clearAll(): Promise { + await localNotificationsSublevel.clear(); + } +} diff --git a/src/main/services/ws/events/notification.ts b/src/main/services/ws/events/notification.ts new file mode 100644 index 00000000..d38ec4c3 --- /dev/null +++ b/src/main/services/ws/events/notification.ts @@ -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, + }); +}; diff --git a/src/main/services/ws/ws-client.ts b/src/main/services/ws/ws-client.ts index e2e9d550..19b4b397 100644 --- a/src/main/services/ws/ws-client.ts +++ b/src/main/services/ws/ws-client.ts @@ -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")); diff --git a/src/preload/index.ts b/src/preload/index.ts index 5579b6fb..7ad88a9b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 1cac834c..4f5253f1 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -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"); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.scss b/src/renderer/src/components/sidebar/sidebar-profile.scss index 7e634851..4a061e98 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.scss +++ b/src/renderer/src/components/sidebar/sidebar-profile.scss @@ -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 { diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 5f336fc3..2bc28d88 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -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( + "/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 ( + + ); + }, [t, notificationCount, navigate]); + const friendsButton = useMemo(() => { if (!userDetails) return null; @@ -98,6 +184,7 @@ export function SidebarProfile() { + {notificationsButton} {friendsButton} ); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 6975967e..4bc3dabc 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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; + getLocalNotifications: () => Promise; + getLocalNotificationsCount: () => Promise; + markLocalNotificationRead: (id: string) => Promise; + markAllLocalNotificationsRead: () => Promise; + deleteLocalNotification: (id: string) => Promise; + clearAllLocalNotifications: () => Promise; + onLocalNotificationCreated: ( + cb: (notification: LocalNotification) => void + ) => () => Electron.IpcRenderer; onAchievementUnlocked: ( cb: ( position?: AchievementCustomNotificationPosition, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 92220a6e..a012cf39 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( } /> } /> } /> + } /> } /> diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 63c4c974..48a4c0a3 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -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(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 && ( - +
+ +
)} diff --git a/src/renderer/src/pages/notifications/local-notification-item.tsx b/src/renderer/src/pages/notifications/local-notification-item.tsx new file mode 100644 index 00000000..6ebc703b --- /dev/null +++ b/src/renderer/src/pages/notifications/local-notification-item.tsx @@ -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 ; + case "EXTRACTION_COMPLETE": + return ; + case "UPDATE_AVAILABLE": + return ; + case "ACHIEVEMENT_UNLOCKED": + return ; + default: + return ; + } + }; + + return ( +
+
+ {notification.pictureUrl ? ( + + ) : ( + getIcon() + )} +
+ +
+ {notification.title} + + {notification.description} + + + + {formatDistance(new Date(notification.createdAt), new Date())} + +
+ + +
+ ); +} diff --git a/src/renderer/src/pages/notifications/notification-item.scss b/src/renderer/src/pages/notifications/notification-item.scss new file mode 100644 index 00000000..e64063f0 --- /dev/null +++ b/src/renderer/src/pages/notifications/notification-item.scss @@ -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); + } + } +} diff --git a/src/renderer/src/pages/notifications/notification-item.tsx b/src/renderer/src/pages/notifications/notification-item.tsx new file mode 100644 index 00000000..eb31ee36 --- /dev/null +++ b/src/renderer/src/pages/notifications/notification-item.tsx @@ -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) { + 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 ; + } + if (isReview) { + return ; + } + return ; + }; + + return ( +
+
+ {getIcon()} +
+ +
+ {content.title} + + {content.description} + + + + {formatDistance(new Date(notification.createdAt), new Date())} + +
+ + {content.showActions && + notification.type === "FRIEND_REQUEST_RECEIVED" && ( +
+ + +
+ )} + + {notification.type !== "FRIEND_REQUEST_RECEIVED" && ( + + )} +
+ ); +} diff --git a/src/renderer/src/pages/notifications/notifications.scss b/src/renderer/src/pages/notifications/notifications.scss new file mode 100644 index 00000000..34555198 --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -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); + } +} diff --git a/src/renderer/src/pages/notifications/notifications.tsx b/src/renderer/src/pages/notifications/notifications.tsx new file mode 100644 index 00000000..0c8aecb6 --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.tsx @@ -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([]); + const [localNotifications, setLocalNotifications] = useState< + LocalNotification[] + >([]); + const [badges, setBadges] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [clearingIds, setClearingIds] = useState>(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( + `/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( + "/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(() => { + 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 ( + + {notification.source === "local" ? ( + handleDismiss(id, "local")} + onMarkAsRead={(id) => handleMarkAsRead(id, "local")} + /> + ) : ( + handleDismiss(id, "api")} + onMarkAsRead={(id) => handleMarkAsRead(id, "api")} + onAcceptFriendRequest={handleAcceptFriendRequest} + onRefuseFriendRequest={handleRefuseFriendRequest} + /> + )} + + ); + }; + + return ( +
+
+ + +
+ + {isLoading && mergedNotifications.length === 0 ? ( +
+ {t("loading")} +
+ ) : mergedNotifications.length === 0 ? ( +
+ + {t("empty_title")} + + {t("empty_description")} + +
+ ) : ( + <> +
+ + {displayedNotifications.map(renderNotification)} + +
+ + {pagination.hasMore && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss b/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss new file mode 100644 index 00000000..06bbd2ee --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss @@ -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; + } + } +} diff --git a/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx b/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx new file mode 100644 index 00000000..9593c0f2 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx @@ -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([]); + const [totalFriends, setTotalFriends] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(0); + const [removingId, setRemovingId] = useState(null); + const listRef = useRef(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 {game.title}; + } + return ; + }; + + const modalTitle = ( +
+ {t("friends")} + {totalFriends > 0 && ( + {totalFriends} + )} +
+ ); + + return ( + +
+ {friends.length === 0 && !isLoading ? ( +
+ {t("no_friends_added")} +
+ ) : ( +
+ {friends.map((friend) => ( +
handleFriendClick(friend.id)} + role="button" + tabIndex={0} + > + +
+ + {friend.displayName} + + {friend.currentGame && ( +
+ {getGameImage(friend.currentGame)} + {friend.currentGame.title} +
+ )} +
+ {isMe && ( + + )} +
+ ))} +
+ )} + + {isLoading && ( +
{t("loading")}...
+ )} + + {hasMore && !isLoading && friends.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/badges-box.scss b/src/renderer/src/pages/profile/profile-content/badges-box.scss new file mode 100644 index 00000000..cd68e338 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/badges-box.scss @@ -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; + } + } +} diff --git a/src/renderer/src/pages/profile/profile-content/badges-box.tsx b/src/renderer/src/pages/profile/profile-content/badges-box.tsx new file mode 100644 index 00000000..94c68195 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/badges-box.tsx @@ -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 ( +
+
+
+

{t("badges")}

+ + {numberFormatter.format(userProfile.badges.length)} + +
+
+ +
+
+ {userProfile.badges.map((badgeName) => { + const badge = badges.find((b) => b.name === badgeName); + + if (!badge) return null; + + return ( +
+ {badge.name} +
+ ); + })} +
+ + +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/friends-box.scss b/src/renderer/src/pages/profile/profile-content/friends-box.scss index 2e5a1bc1..2b6d28b7 100644 --- a/src/renderer/src/pages/profile/profile-content/friends-box.scss +++ b/src/renderer/src/pages/profile/profile-content/friends-box.scss @@ -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; + } + } } diff --git a/src/renderer/src/pages/profile/profile-content/friends-box.tsx b/src/renderer/src/pages/profile/profile-content/friends-box.tsx index bee4b35c..dfa0fcc0 100644 --- a/src/renderer/src/pages/profile/profile-content/friends-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/friends-box.tsx @@ -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 ( -
-
-
-

{t("friends")}

- {userStats && ( - - {numberFormatter.format(userStats.friendsCount)} - - )} + <> +
+
+
+

{t("friends")}

+ {userStats && ( + + {numberFormatter.format(userStats.friendsCount)} + + )} +
+ +
+ +
+
    + {userProfile?.friends.map((friend) => ( +
  • + + + +
    + + {friend.displayName} + + {friend.currentGame && ( +
    + {getGameImage(friend.currentGame)} + {friend.currentGame.title} +
    + )} +
    + +
  • + ))} +
-
-
    - {userProfile?.friends.map((friend) => ( -
  • - - - -
    - - {friend.displayName} - - {friend.currentGame && ( -
    - {getGameImage(friend.currentGame)} - {friend.currentGame.title} -
    - )} -
    - -
  • - ))} -
-
-
+ {userProfile && ( + setShowAllFriendsModal(false)} + userId={userProfile.id} + isMe={isMe} + /> + )} + ); } diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index 8176bace..a5ae01da 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -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() { +
diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.scss b/src/renderer/src/pages/profile/profile-hero/profile-hero.scss index 834cb0cf..6fd371c9 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.scss +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.scss @@ -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 { diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index fc354d01..9755fb68 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -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} -
- {userProfile.badges.map((badgeName) => { - const badge = badges.find((b) => b.name === badgeName); - - if (!badge) return null; - - return ( - {badge.name} - ); - })} - - -
+
) : ( diff --git a/src/types/index.ts b/src/types/index.ts index 9c4f5b28..d326aeef 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; + 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: {