diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f3a233cc..07029def 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", @@ -663,6 +664,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", @@ -673,12 +675,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", @@ -702,6 +708,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", @@ -778,5 +785,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/events/profile/index.ts b/src/main/events/profile/index.ts index 1548249f..664d6ee2 100644 --- a/src/main/events/profile/index.ts +++ b/src/main/events/profile/index.ts @@ -1,4 +1,3 @@ import "./get-me"; import "./process-profile-image"; -import "./sync-friend-requests"; import "./update-profile"; diff --git a/src/main/events/profile/sync-friend-requests.ts b/src/main/events/profile/sync-friend-requests.ts deleted file mode 100644 index 478c337f..00000000 --- a/src/main/events/profile/sync-friend-requests.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { registerEvent } from "../register-event"; -import { HydraApi, WindowManager } from "@main/services"; -import { UserNotLoggedInError } from "@shared"; -import type { FriendRequestSync } from "@types"; - -export const syncFriendRequests = async () => { - return HydraApi.get(`/profile/friend-requests/sync`) - .then((res) => { - WindowManager.mainWindow?.webContents.send( - "on-sync-friend-requests", - res - ); - - return res; - }) - .catch((err) => { - if (err instanceof UserNotLoggedInError) { - return { friendRequestCount: 0 } as FriendRequestSync; - } - throw err; - }); -}; - -registerEvent("syncFriendRequests", syncFriendRequests); 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/download/download-manager.ts b/src/main/services/download/download-manager.ts index bc9ddf1d..b6a21ca3 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -29,20 +29,23 @@ import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; export class DownloadManager { private static downloadingGameId: string | null = null; - private static extractFilename(url: string, originalUrl?: string): string | undefined { - if (originalUrl?.includes('#')) { - const hashPart = originalUrl.split('#')[1]; - if (hashPart && !hashPart.startsWith('http')) return hashPart; + private static extractFilename( + url: string, + originalUrl?: string + ): string | undefined { + if (originalUrl?.includes("#")) { + const hashPart = originalUrl.split("#")[1]; + if (hashPart && !hashPart.startsWith("http")) return hashPart; } - if (url.includes('#')) { - const hashPart = url.split('#')[1]; - if (hashPart && !hashPart.startsWith('http')) return hashPart; + if (url.includes("#")) { + const hashPart = url.split("#")[1]; + if (hashPart && !hashPart.startsWith("http")) return hashPart; } try { const urlObj = new URL(url); - const filename = urlObj.pathname.split('/').pop(); + const filename = urlObj.pathname.split("/").pop(); if (filename?.length) return filename; } catch { // Invalid URL @@ -52,13 +55,20 @@ export class DownloadManager { } private static sanitizeFilename(filename: string): string { - return filename.replace(/[<>:"/\\|?*]/g, '_'); + return filename.replace(/[<>:"/\\|?*]/g, "_"); } - private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) { + private static createDownloadPayload( + directUrl: string, + originalUrl: string, + downloadId: string, + savePath: string + ) { const filename = this.extractFilename(directUrl, originalUrl); - const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined; - + const sanitizedFilename = filename + ? this.sanitizeFilename(filename) + : undefined; + if (sanitizedFilename) { logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`); } @@ -98,7 +108,9 @@ export class DownloadManager { } private static async getDownloadStatus() { - const response = await PythonRPC.rpc.get("/status"); + const response = await PythonRPC.rpc.get( + "/status" + ); if (response.data === null || !this.downloadingGameId) return null; const downloadId = this.downloadingGameId; @@ -114,7 +126,8 @@ export class DownloadManager { status, } = response.data; - const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata; + const isDownloadingMetadata = + status === LibtorrentStatus.DownloadingMetadata; const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; const download = await downloadsSublevel.get(downloadId); @@ -179,7 +192,10 @@ export class DownloadManager { if (progress === 1 && download) { publishDownloadCompleteNotification(game); - if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) { + if ( + userPreferences?.seedAfterDownloadComplete && + download.downloader === Downloader.Torrent + ) { await downloadsSublevel.put(gameId, { ...download, status: "seeding", @@ -200,13 +216,22 @@ export class DownloadManager { } if (shouldExtract) { - const gameFilesManager = new GameFilesManager(game.shop, game.objectId); + const gameFilesManager = new GameFilesManager( + game.shop, + game.objectId + ); - if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) { + if ( + FILE_EXTENSIONS_TO_EXTRACT.some((ext) => + download.folderName?.endsWith(ext) + ) + ) { gameFilesManager.extractDownloadedFile(); } else { gameFilesManager - .extractFilesInDirectory(path.join(download.downloadPath, download.folderName!)) + .extractFilesInDirectory( + path.join(download.downloadPath, download.folderName!) + ) .then(() => gameFilesManager.setExtractionComplete()); } } @@ -214,11 +239,13 @@ export class DownloadManager { const downloads = await downloadsSublevel .values() .all() - .then((games) => sortBy( - games.filter((game) => game.status === "paused" && game.queued), - "timestamp", - "DESC" - )); + .then((games) => + sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + ) + ); const [nextItemOnQueue] = downloads; @@ -245,7 +272,9 @@ export class DownloadManager { if (!download) return; - const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName)); + const totalSize = await getDirSize( + path.join(download.downloadPath, status.folderName) + ); if (totalSize < status.fileSize) { await this.cancelDownload(status.gameId); @@ -266,7 +295,10 @@ export class DownloadManager { static async pauseDownload(downloadKey = this.downloadingGameId) { await PythonRPC.rpc - .post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload) + .post("/action", { + action: "pause", + game_id: downloadKey, + } as PauseDownloadPayload) .catch(() => {}); if (downloadKey === this.downloadingGameId) { @@ -357,24 +389,44 @@ export class DownloadManager { }; } case Downloader.Buzzheavier: { - logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`); + logger.log( + `[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}` + ); try { const directUrl = await BuzzheavierApi.getDirectLink(download.uri); logger.log(`[DownloadManager] Buzzheavier direct URL obtained`); - return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath); + return this.createDownloadPayload( + directUrl, + download.uri, + downloadId, + download.downloadPath + ); } catch (error) { - logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error); + logger.error( + `[DownloadManager] Error processing Buzzheavier download:`, + error + ); throw error; } } case Downloader.FuckingFast: { - logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`); + logger.log( + `[DownloadManager] Processing FuckingFast download for URI: ${download.uri}` + ); try { const directUrl = await FuckingFastApi.getDirectLink(download.uri); logger.log(`[DownloadManager] FuckingFast direct URL obtained`); - return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath); + return this.createDownloadPayload( + directUrl, + download.uri, + downloadId, + download.downloadPath + ); } catch (error) { - logger.error(`[DownloadManager] Error processing FuckingFast download:`, error); + logger.error( + `[DownloadManager] Error processing FuckingFast download:`, + error + ); throw error; } } @@ -419,7 +471,9 @@ export class DownloadManager { }; } case Downloader.Hydra: { - const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri); + const downloadUrl = await HydraDebridClient.getDownloadUrl( + download.uri + ); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); return { diff --git a/src/main/services/hosters/buzzheavier.ts b/src/main/services/hosters/buzzheavier.ts index 819c8f25..9ef2d830 100644 --- a/src/main/services/hosters/buzzheavier.ts +++ b/src/main/services/hosters/buzzheavier.ts @@ -21,7 +21,9 @@ export class BuzzheavierApi { private static async getBuzzheavierDirectLink(url: string): Promise { try { const baseUrl = url.split("#")[0]; - logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`); + logger.log( + `[Buzzheavier] Starting download link extraction for: ${baseUrl}` + ); await axios.get(baseUrl, { headers: { "User-Agent": HOSTER_USER_AGENT }, @@ -46,7 +48,9 @@ export class BuzzheavierApi { const hxRedirect = headResponse.headers["hx-redirect"]; logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`); if (!hxRedirect) { - logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`); + logger.error( + `[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}` + ); throw new Error( "Could not extract download link. File may be deleted or is a directory." ); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index fa712105..596b0635 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -30,7 +30,7 @@ export class HydraApi { private static instance: AxiosInstance; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes - private static readonly ADD_LOG_INTERCEPTOR = false; + private static readonly ADD_LOG_INTERCEPTOR = true; private static secondsToMilliseconds(seconds: number) { return seconds * 1000; 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..32bc0f88 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,7 @@ import type { GameAchievement, Theme, FriendRequestSync, + NotificationSync, ShortcutLocation, AchievementCustomNotificationPosition, AchievementNotificationInfo, @@ -497,7 +498,6 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("updateProfile", updateProfile), processProfileImage: (imagePath: string) => ipcRenderer.invoke("processProfileImage", imagePath), - syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"), onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => { const listener = ( _event: Electron.IpcRendererEvent, @@ -507,6 +507,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 +559,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/app.tsx b/src/renderer/src/app.tsx index 6619c890..9334b5b9 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -23,7 +23,6 @@ import { clearExtraction, } from "@renderer/features"; import { useTranslation } from "react-i18next"; -import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { useSubscription } from "./hooks/use-subscription"; import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal"; import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal"; @@ -54,12 +53,7 @@ export function App() { const { clearDownload, setLastPacket } = useDownload(); const { - userDetails, hasActiveSubscription, - isFriendsModalVisible, - friendRequetsModalTab, - friendModalUserId, - hideFriendsModal, fetchUserDetails, updateUserDetails, clearUserDetails, @@ -135,7 +129,6 @@ export function App() { .then((response) => { if (response) { updateUserDetails(response); - window.electron.syncFriendRequests(); } }) .finally(() => { @@ -152,7 +145,6 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - window.electron.syncFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -305,15 +297,6 @@ export function App() { onClose={() => setShowArchiveDeletionModal(false)} /> - {userDetails && ( - - )} -
diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 1cac834c..5c058252 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"); @@ -323,7 +324,8 @@ export function Header() { 0 || + (searchValue.trim().length > 0 || + historyItems.length > 0 || suggestions.length > 0 || isLoadingSuggestions) } diff --git a/src/renderer/src/components/modal/modal.tsx b/src/renderer/src/components/modal/modal.tsx index 09a61438..f4892313 100644 --- a/src/renderer/src/components/modal/modal.tsx +++ b/src/renderer/src/components/modal/modal.tsx @@ -10,7 +10,7 @@ import cn from "classnames"; export interface ModalProps { visible: boolean; - title: string; + title: React.ReactNode; description?: string; onClose: () => void; large?: boolean; @@ -115,7 +115,6 @@ export function Modal({ "modal--large": large, })} role="dialog" - aria-labelledby={title} aria-describedby={description} ref={modalContentRef} data-hydra-dialog diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.scss b/src/renderer/src/components/search-dropdown/search-dropdown.scss index 40a55432..6a2cbede 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.scss +++ b/src/renderer/src/components/search-dropdown/search-dropdown.scss @@ -5,7 +5,7 @@ background-color: globals.$dark-background-color; border: 1px solid globals.$border-color; border-radius: 8px; - max-height: 300px; + max-height: 350px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; diff --git a/src/renderer/src/components/search-dropdown/search-dropdown.tsx b/src/renderer/src/components/search-dropdown/search-dropdown.tsx index 4142e4a5..cc7ce5b4 100644 --- a/src/renderer/src/components/search-dropdown/search-dropdown.tsx +++ b/src/renderer/src/components/search-dropdown/search-dropdown.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react"; import cn from "classnames"; @@ -92,23 +92,8 @@ export function SearchDropdown({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [visible, onClose, searchContainerRef]); - const handleItemClick = useCallback( - ( - type: "history" | "suggestion", - item: SearchHistoryEntry | SearchSuggestion - ) => { - if (type === "history") { - onSelectHistory((item as SearchHistoryEntry).query); - } else { - onSelectSuggestion(item as SearchSuggestion); - } - }, - [onSelectHistory, onSelectSuggestion] - ); - if (!visible) return null; - const totalItems = historyItems.length + suggestions.length; const hasHistory = historyItems.length > 0; const hasSuggestions = suggestions.length > 0; @@ -158,7 +143,7 @@ export function SearchDropdown({ activeIndex === getItemIndex("history", index), })} onMouseDown={(e) => e.preventDefault()} - onClick={() => handleItemClick("history", item)} + onClick={() => onSelectHistory(item.query)} > @@ -200,7 +185,7 @@ export function SearchDropdown({ activeIndex === getItemIndex("suggestion", index), })} onMouseDown={(e) => e.preventDefault()} - onClick={() => handleItemClick("suggestion", item)} + onClick={() => onSelectSuggestion(item)} > {item.iconUrl ? ( {t("loading")} )} - - {!isLoadingSuggestions && - !hasHistory && - !hasSuggestions && - totalItems === 0 && ( -
{t("no_results")}
- )} ); diff --git a/src/renderer/src/components/sidebar/sidebar-profile.scss b/src/renderer/src/components/sidebar/sidebar-profile.scss index 7e634851..8ec442f2 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.scss +++ b/src/renderer/src/components/sidebar/sidebar-profile.scss @@ -46,7 +46,7 @@ white-space: nowrap; } - &__friends-button { + &__notification-button { color: globals.$muted-color; cursor: pointer; border-radius: 50%; @@ -62,7 +62,7 @@ } } - &__friends-button-badge { + &__notification-button-badge { background-color: globals.$success-color; display: flex; justify-content: center; @@ -73,6 +73,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..bd1209ec 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -1,12 +1,13 @@ import { useNavigate } from "react-router-dom"; -import { PeopleIcon } from "@primer/octicons-react"; +import { 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() { @@ -14,11 +15,75 @@ export function SidebarProfile() { const { t } = useTranslation("sidebar"); - const { userDetails, friendRequestCount, showFriendsModal } = - useUserDetails(); + const { userDetails } = useUserDetails(); 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,28 +93,24 @@ export function SidebarProfile() { navigate(`/profile/${userDetails.id}`); }; - const friendsButton = useMemo(() => { - if (!userDetails) return null; - + const notificationsButton = useMemo(() => { return ( ); - }, [userDetails, t, friendRequestCount, showFriendsModal]); + }, [t, notificationCount, navigate]); const gameRunningDetails = () => { if (!userDetails || !gameRunning) return null; @@ -98,7 +159,7 @@ export function SidebarProfile() { - {friendsButton} + {notificationsButton} ); } diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 6975967e..4e7fd245 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"; @@ -387,10 +389,12 @@ declare global { processProfileImage: ( path: string ) => Promise<{ imagePath: string; mimeType: string }>; - syncFriendRequests: () => Promise; onSyncFriendRequests: ( cb: (friendRequests: FriendRequestSync) => void ) => () => Electron.IpcRenderer; + onSyncNotificationCount: ( + cb: (notification: NotificationSync) => void + ) => () => Electron.IpcRenderer; updateFriendRequest: ( userId: string, action: FriendRequestAction @@ -398,6 +402,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/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 8994f180..0f477ec2 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -1,5 +1,4 @@ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; -import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; import type { FriendRequest, UserDetails } from "@types"; export interface UserDetailsState { @@ -7,9 +6,6 @@ export interface UserDetailsState { profileBackground: null | string; friendRequests: FriendRequest[]; friendRequestCount: number; - isFriendsModalVisible: boolean; - friendRequetsModalTab: UserFriendModalTab | null; - friendModalUserId: string; } const initialState: UserDetailsState = { @@ -17,9 +13,6 @@ const initialState: UserDetailsState = { profileBackground: null, friendRequests: [], friendRequestCount: 0, - isFriendsModalVisible: false, - friendRequetsModalTab: null, - friendModalUserId: "", }; export const userDetailsSlice = createSlice({ @@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({ setFriendRequestCount: (state, action: PayloadAction) => { state.friendRequestCount = action.payload; }, - setFriendsModalVisible: ( - state, - action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }> - ) => { - state.isFriendsModalVisible = true; - state.friendRequetsModalTab = action.payload.initialTab; - state.friendModalUserId = action.payload.userId; - }, - setFriendsModalHidden: (state) => { - state.isFriendsModalVisible = false; - state.friendRequetsModalTab = null; - }, }, }); @@ -58,6 +39,4 @@ export const { setProfileBackground, setFriendRequests, setFriendRequestCount, - setFriendsModalVisible, - setFriendsModalHidden, } = userDetailsSlice.actions; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 6d89f9b4..d8b9bbd2 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -4,8 +4,6 @@ import { setProfileBackground, setUserDetails, setFriendRequests, - setFriendsModalVisible, - setFriendsModalHidden, } from "@renderer/features"; import type { FriendRequestAction, @@ -13,20 +11,12 @@ import type { UserDetails, FriendRequest, } from "@types"; -import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; export function useUserDetails() { const dispatch = useAppDispatch(); - const { - userDetails, - profileBackground, - friendRequests, - friendRequestCount, - isFriendsModalVisible, - friendModalUserId, - friendRequetsModalTab, - } = useAppSelector((state) => state.userDetails); + const { userDetails, profileBackground, friendRequests, friendRequestCount } = + useAppSelector((state) => state.userDetails); const clearUserDetails = useCallback(async () => { dispatch(setUserDetails(null)); @@ -85,24 +75,11 @@ export function useUserDetails() { return window.electron.hydraApi .get("/profile/friend-requests") .then((friendRequests) => { - window.electron.syncFriendRequests(); dispatch(setFriendRequests(friendRequests)); }) .catch(() => {}); }, [dispatch]); - const showFriendsModal = useCallback( - (initialTab: UserFriendModalTab, userId: string) => { - dispatch(setFriendsModalVisible({ initialTab, userId })); - fetchFriendRequests(); - }, - [dispatch, fetchFriendRequests] - ); - - const hideFriendsModal = useCallback(() => { - dispatch(setFriendsModalHidden()); - }, [dispatch]); - const sendFriendRequest = useCallback( async (userId: string) => { return window.electron.hydraApi @@ -152,12 +129,7 @@ export function useUserDetails() { profileBackground, friendRequests, friendRequestCount, - friendRequetsModalTab, - isFriendsModalVisible, - friendModalUserId, hasActiveSubscription, - showFriendsModal, - hideFriendsModal, fetchUserDetails, signOut, clearUserDetails, 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..30380965 --- /dev/null +++ b/src/renderer/src/pages/notifications/local-notification-item.tsx @@ -0,0 +1,103 @@ +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, +}: Readonly) { + 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 ( + + + ); +} 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..b250ffe8 --- /dev/null +++ b/src/renderer/src/pages/notifications/notification-item.tsx @@ -0,0 +1,228 @@ +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.accepterDisplayName, + }), + 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 ( + + + + )} + + {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..c8fa7c3f --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -0,0 +1,58 @@ +@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; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + flex-direction: column; + gap: globals.$spacing-unit; + } + + &__icon-container { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__loading { + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + } + + &__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..f9bd0b46 --- /dev/null +++ b/src/renderer/src/pages/notifications/notifications.tsx @@ -0,0 +1,400 @@ +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} + /> + )} + + ); + }; + + const renderContent = () => { + if (isLoading && mergedNotifications.length === 0) { + return ( +
+ {t("loading")} +
+ ); + } + + if (mergedNotifications.length === 0) { + return ( +
+
+ +
+

{t("empty_title")}

+

{t("empty_description")}

+
+ ); + } + + return ( +
+
+ + +
+ +
+ + {displayedNotifications.map(renderNotification)} + +
+ + {pagination.hasMore && ( +
+ +
+ )} +
+ ); + }; + + return <>{renderContent()}; +} diff --git a/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss b/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss new file mode 100644 index 00000000..6e89ae1a --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/add-friend-modal.scss @@ -0,0 +1,120 @@ +@use "../../../scss/globals.scss"; + +.add-friend-modal { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + width: 100%; + min-width: 400px; + + &__my-code { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1.5); + padding: calc(globals.$spacing-unit * 1.5); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + } + + &__my-code-label { + font-size: 0.875rem; + color: globals.$muted-color; + font-weight: 500; + } + + &__my-code-value { + font-size: 0.875rem; + color: globals.$body-color; + font-family: monospace; + font-weight: 600; + flex: 1; + } + + &__copy-icon-button { + display: flex; + 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; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: globals.$body-color; + } + } + + &__actions { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: globals.$spacing-unit; + } + + &__button { + align-self: flex-end; + white-space: nowrap; + } + + &__pending-status { + color: globals.$body-color; + font-size: globals.$small-font-size; + text-align: center; + padding: calc(globals.$spacing-unit / 2); + background-color: rgba(255, 255, 255, 0.05); + border-radius: 4px; + margin-top: calc(globals.$spacing-unit * -1); + } + + &__pending-container { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + margin-top: calc(globals.$spacing-unit * 2); + + h3 { + margin: 0; + font-size: globals.$body-font-size; + font-weight: 600; + color: globals.$muted-color; + } + } + + &__pending-list { + display: flex; + flex-direction: column; + gap: globals.$spacing-unit; + max-height: 300px; + overflow-y: auto; + padding-right: globals.$spacing-unit; + } + + &__friend-item { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 2); + padding: calc(globals.$spacing-unit * 1.5); + 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); + } + } + + &__friend-name { + flex: 1; + font-weight: 600; + color: globals.$muted-color; + font-size: globals.$body-font-size; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/add-friend-modal.tsx b/src/renderer/src/pages/profile/profile-content/add-friend-modal.tsx new file mode 100644 index 00000000..7f370a39 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/add-friend-modal.tsx @@ -0,0 +1,185 @@ +import { Avatar, Button, Modal, TextField } from "@renderer/components"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { CopyIcon } from "@primer/octicons-react"; +import "./add-friend-modal.scss"; + +interface AddFriendModalProps { + readonly visible: boolean; + readonly onClose: () => void; +} + +export function AddFriendModal({ visible, onClose }: AddFriendModalProps) { + const { t } = useTranslation("user_profile"); + const navigate = useNavigate(); + + const [friendCode, setFriendCode] = useState(""); + const [isAddingFriend, setIsAddingFriend] = useState(false); + + const { + sendFriendRequest, + updateFriendRequestState, + friendRequests, + fetchFriendRequests, + userDetails, + } = useUserDetails(); + + const { showSuccessToast, showErrorToast } = useToast(); + + const copyMyFriendCode = () => { + if (userDetails?.id) { + navigator.clipboard.writeText(userDetails.id); + showSuccessToast(t("friend_code_copied")); + } + }; + + useEffect(() => { + if (visible) { + setFriendCode(""); + fetchFriendRequests(); + } + }, [visible, fetchFriendRequests]); + + const handleChangeFriendCode = (e: React.ChangeEvent) => { + const code = e.target.value.trim().slice(0, 8); + setFriendCode(code); + }; + + const validateFriendCode = (callback: () => void) => { + if (friendCode.length === 8) { + return callback(); + } + + showErrorToast(t("friend_code_length_error")); + }; + + const handleClickAddFriend = () => { + setIsAddingFriend(true); + sendFriendRequest(friendCode) + .then(() => { + setFriendCode(""); + showSuccessToast(t("request_sent")); + }) + .catch(() => { + showErrorToast(t("error_adding_friend")); + }) + .finally(() => { + setIsAddingFriend(false); + }); + }; + + const handleClickSeeProfile = () => { + if (friendCode.length === 8) { + onClose(); + navigate(`/profile/${friendCode}`); + } + }; + + const handleClickRequest = (userId: string) => { + onClose(); + navigate(`/profile/${userId}`); + }; + + const handleCancelFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "CANCEL").catch(() => { + showErrorToast(t("try_again")); + }); + }; + + const sentRequests = friendRequests.filter((req) => req.type === "SENT"); + const currentRequest = + friendCode.length === 8 + ? sentRequests.find((req) => req.id === friendCode) + : null; + + return ( + +
+ {userDetails?.id && ( +
+ + {t("your_friend_code")} + + + {userDetails.id} + + +
+ )} + +
+ + + +
+ {currentRequest && ( +
{t("pending")}
+ )} + + {sentRequests.length > 0 && ( +
+

{t("pending")}

+
+ {sentRequests.map((request) => ( + + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/renderer/src/pages/profile/profile-content/all-badges-modal.scss b/src/renderer/src/pages/profile/profile-content/all-badges-modal.scss new file mode 100644 index 00000000..83f8f6ef --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-badges-modal.scss @@ -0,0 +1,87 @@ +@use "../../../scss/globals.scss"; + +.all-badges-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: calc(globals.$spacing-unit * 2); + 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); + background-color: rgba(255, 255, 255, 0.05); + border-radius: 8px; + transition: background-color ease 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__item-icon { + 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: 32px; + height: 32px; + object-fit: contain; + } + } + + &__item-content { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.5); + flex: 1; + min-width: 0; + } + + &__item-title { + font-size: globals.$body-font-size; + font-weight: 600; + color: globals.$body-color; + margin: 0; + } + + &__item-description { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; + } +} diff --git a/src/renderer/src/pages/profile/profile-content/all-badges-modal.tsx b/src/renderer/src/pages/profile/profile-content/all-badges-modal.tsx new file mode 100644 index 00000000..8eb50051 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-badges-modal.tsx @@ -0,0 +1,58 @@ +import { useContext } from "react"; +import { useTranslation } from "react-i18next"; +import { Modal } from "@renderer/components"; +import { userProfileContext } from "@renderer/context"; +import "./all-badges-modal.scss"; + +interface AllBadgesModalProps { + visible: boolean; + onClose: () => void; +} + +export function AllBadgesModal({ + visible, + onClose, +}: Readonly) { + const { t } = useTranslation("user_profile"); + const { userProfile, badges } = useContext(userProfileContext); + + const userBadges = userProfile?.badges + .map((badgeName) => badges.find((b) => b.name === badgeName)) + .filter((badge) => badge !== undefined); + + const modalTitle = ( +
+ {t("badges")} + {userBadges && userBadges.length > 0 && ( + {userBadges.length} + )} +
+ ); + + return ( + +
+
+ {userBadges?.map((badge) => ( +
+
+ {badge.name} +
+
+

{badge.title}

+

+ {badge.description} +

+
+
+ ))} +
+
+
+ ); +} 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..8ecbaa46 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-friends-modal.scss @@ -0,0 +1,101 @@ +@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; + } +} 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..4344956a --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/all-friends-modal.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Modal, Avatar, Button } from "@renderer/components"; +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 [friends, setFriends] = useState([]); + const [totalFriends, setTotalFriends] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(0); + 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); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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 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)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + handleFriendClick(friend.id); + } + }} + role="button" + tabIndex={0} + > + +
+ + {friend.displayName} + + {friend.currentGame && ( +
+ {getGameImage(friend.currentGame)} + {friend.currentGame.title} +
+ )} +
+
+ ))} +
+ )} + + {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..ce8622ac --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/badges-box.scss @@ -0,0 +1,95 @@ +@use "../../../scss/globals.scss"; + +.badges-box { + &__box { + padding: calc(globals.$spacing-unit * 2); + } + + &__header { + display: flex; + justify-content: flex-end; + margin-bottom: calc(globals.$spacing-unit * 2); + } + + &__list { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 2); + } + + &__item { + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit * 1.5); + width: 100%; + padding: calc(globals.$spacing-unit * 1.5); + background-color: rgba(255, 255, 255, 0.05); + border-radius: 8px; + transition: background-color ease 0.2s; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__item-icon { + flex-shrink: 0; + width: 34px; + height: 34px; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: globals.$background-color; + + img { + width: 28px; + height: 28px; + object-fit: contain; + } + } + + &__item-content { + display: flex; + flex-direction: column; + gap: calc(globals.$spacing-unit * 0.5); + flex: 1; + min-width: 0; + } + + &__item-title { + font-size: 0.8rem; + font-weight: 600; + color: globals.$body-color; + margin: 0; + } + + &__item-description { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; + } + + &__view-all-container { + padding-top: calc(globals.$spacing-unit * 2); + margin-top: calc(globals.$spacing-unit * 2); + display: flex; + justify-content: flex-start; + } + + &__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/badges-box.tsx b/src/renderer/src/pages/profile/profile-content/badges-box.tsx new file mode 100644 index 00000000..501341b2 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-content/badges-box.tsx @@ -0,0 +1,67 @@ +import { userProfileContext } from "@renderer/context"; +import { useContext, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { AllBadgesModal } from "./all-badges-modal"; +import "./badges-box.scss"; + +const MAX_VISIBLE_BADGES = 4; + +export function BadgesBox() { + const { userProfile, badges } = useContext(userProfileContext); + const { t } = useTranslation("user_profile"); + const [showAllBadgesModal, setShowAllBadgesModal] = useState(false); + + if (!userProfile?.badges.length) return null; + + const visibleBadges = userProfile.badges.slice(0, MAX_VISIBLE_BADGES); + const hasMoreBadges = userProfile.badges.length > MAX_VISIBLE_BADGES; + + return ( + <> +
+
+ {visibleBadges.map((badgeName) => { + const badge = badges.find((b) => b.name === badgeName); + + if (!badge) return null; + + return ( +
+
+ {badge.name} +
+
+

{badge.title}

+

+ {badge.description} +

+
+
+ ); + })} +
+ {hasMoreBadges && ( +
+ +
+ )} +
+ + setShowAllBadgesModal(false)} + /> + + ); +} 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..088b204f 100644 --- a/src/renderer/src/pages/profile/profile-content/friends-box.scss +++ b/src/renderer/src/pages/profile/profile-content/friends-box.scss @@ -1,18 +1,34 @@ @use "../../../scss/globals.scss"; .friends-box { - &__section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: calc(globals.$spacing-unit * 2); + &__box { + padding: calc(globals.$spacing-unit * 2); + position: relative; } - &__box { - background-color: globals.$background-color; - border-radius: 4px; - border: solid 1px globals.$border-color; - padding: calc(globals.$spacing-unit * 2); + &__add-friend-button { + 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; + display: flex; + align-items: center; + gap: calc(globals.$spacing-unit / 2); + + &:hover { + color: globals.$muted-color; + } + } + + &__view-all-container { + padding-top: calc(globals.$spacing-unit * 2); + margin-top: calc(globals.$spacing-unit * 2); + display: flex; + justify-content: flex-start; } &__list { @@ -44,11 +60,12 @@ &__friend-name { color: globals.$muted-color; - font-weight: bold; - font-size: globals.$body-font-size; + font-size: 0.8rem; + font-weight: 600; } &__game-info { + font-size: 0.75rem; display: flex; gap: globals.$spacing-unit; align-items: center; @@ -63,4 +80,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..cd0fed24 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,24 @@ import { userProfileContext } from "@renderer/context"; -import { useFormat } from "@renderer/hooks"; -import { useContext } from "react"; +import { useUserDetails } from "@renderer/hooks"; +import { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; +import { PlusIcon } from "@primer/octicons-react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { Avatar, Link } from "@renderer/components"; +import { AllFriendsModal } from "./all-friends-modal"; +import { AddFriendModal } from "./add-friend-modal"; import "./friends-box.scss"; +const MAX_VISIBLE_FRIENDS = 5; + export function FriendsBox() { - const { userProfile, userStats } = useContext(userProfileContext); + const { userProfile } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); const { t } = useTranslation("user_profile"); - const { numberFormatter } = useFormat(); + const [showAllFriendsModal, setShowAllFriendsModal] = useState(false); + const [showAddFriendModal, setShowAddFriendModal] = useState(false); + + const isMe = userDetails?.id === userProfile?.id; const getGameImage = (game: { iconUrl: string | null; title: string }) => { if (game.iconUrl) { @@ -28,22 +37,15 @@ export function FriendsBox() { if (!userProfile?.friends.length) return null; - return ( -
-
-
-

{t("friends")}

- {userStats && ( - - {numberFormatter.format(userStats.friendsCount)} - - )} -
-
+ const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS); + const totalFriends = userProfile.friends.length; + const showViewAllButton = totalFriends > MAX_VISIBLE_FRIENDS; + return ( + <>
    - {userProfile?.friends.map((friend) => ( + {visibleFriends.map((friend) => (
  • ))}
+ {showViewAllButton && ( +
+ +
+ )}
-
+ + {userProfile && ( + <> + setShowAllFriendsModal(false)} + userId={userProfile.id} + isMe={isMe} + /> + setShowAddFriendModal(false)} + /> + + )} + + ); +} + +export function FriendsBoxAddButton() { + const { userProfile } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); + const { t } = useTranslation("user_profile"); + const [showAddFriendModal, setShowAddFriendModal] = useState(false); + + const isMe = userDetails?.id === userProfile?.id; + + if (!isMe) return null; + + return ( + <> + + setShowAddFriendModal(false)} + /> + ); } 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 6e20e686..dfd489ec 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -14,10 +14,11 @@ import { useTranslation } from "react-i18next"; import type { GameShop } from "@types"; import { LockedProfile } from "./locked-profile"; import { ReportProfile } from "../report-profile/report-profile"; -import { FriendsBox } from "./friends-box"; +import { BadgesBox } from "./badges-box"; +import { FriendsBox, FriendsBoxAddButton } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserStatsBox } from "./user-stats-box"; -import { UserKarmaBox } from "./user-karma-box"; +import { ProfileSection } from "../profile-section/profile-section"; import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal"; import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; @@ -186,8 +187,6 @@ export function ProfileContent() { ); setReviews(response.reviews); setReviewsTotalCount(response.totalCount); - } catch (error) { - // Error handling for fetching reviews } finally { setIsLoadingReviews(false); } @@ -426,10 +425,35 @@ export function ProfileContent() { {shouldShowRightContent && (
- - - - + {userStats && ( + + + + )} + {userProfile?.badges.length > 0 && ( + + + + )} + {userProfile?.recentGames.length > 0 && ( + + + + )} + {userProfile?.friends.length > 0 && ( + } + defaultOpen={true} + > + + + )}
)} diff --git a/src/renderer/src/pages/profile/profile-content/recent-games-box.scss b/src/renderer/src/pages/profile/profile-content/recent-games-box.scss index 6478fd79..394fbca7 100644 --- a/src/renderer/src/pages/profile/profile-content/recent-games-box.scss +++ b/src/renderer/src/pages/profile/profile-content/recent-games-box.scss @@ -2,19 +2,9 @@ .recent-games { &__box { - background-color: globals.$background-color; - border-radius: 4px; - border: solid 1px globals.$border-color; padding: calc(globals.$spacing-unit * 2); } - &__section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: calc(globals.$spacing-unit * 2); - } - &__list { list-style: none; margin: 0; @@ -57,13 +47,15 @@ } &__game-title { - font-weight: bold; + font-size: 0.8rem; + font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } &__game-description { + font-size: 0.75rem; display: flex; align-items: center; gap: globals.$spacing-unit; diff --git a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx index 5e13b0a9..e61ca423 100644 --- a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx @@ -42,38 +42,32 @@ export function RecentGamesBox() { if (!userProfile?.recentGames.length) return null; return ( -
-
-

{t("activity")}

-
+
+
    + {userProfile?.recentGames.map((game) => ( +
  • + + {game.title} -
    -
      - {userProfile?.recentGames.map((game) => ( -
    • - - {game.title} +
      + {game.title} -
      - {game.title} - -
      - - {formatPlayTime(game)} -
      +
      + + {formatPlayTime(game)}
      - -
    • - ))} -
    -
    +
+ + + ))} +
); } diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss b/src/renderer/src/pages/profile/profile-content/user-karma-box.scss deleted file mode 100644 index 63015b4d..00000000 --- a/src/renderer/src/pages/profile/profile-content/user-karma-box.scss +++ /dev/null @@ -1,47 +0,0 @@ -@use "../../../scss/globals.scss"; - -.user-karma { - &__box { - background-color: globals.$background-color; - border-radius: 4px; - border: solid 1px globals.$border-color; - padding: calc(globals.$spacing-unit * 2); - } - - &__section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: calc(globals.$spacing-unit * 2); - } - - &__content { - display: flex; - flex-direction: column; - gap: calc(globals.$spacing-unit * 1.5); - } - - &__stats-row { - display: flex; - align-items: center; - color: globals.$body-color; - } - - &__description { - display: flex; - align-items: center; - gap: globals.$spacing-unit; - font-weight: 600; - font-size: 1.1rem; - } - - &__info { - padding-top: calc(globals.$spacing-unit * 0.5); - } - - &__info-text { - color: globals.$muted-color; - font-size: 0.85rem; - line-height: 1.4; - } -} diff --git a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx b/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx deleted file mode 100644 index d2232276..00000000 --- a/src/renderer/src/pages/profile/profile-content/user-karma-box.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useContext } from "react"; -import { userProfileContext } from "@renderer/context"; -import { useTranslation } from "react-i18next"; -import { useFormat, useUserDetails } from "@renderer/hooks"; -import { Award } from "lucide-react"; -import "./user-karma-box.scss"; - -export function UserKarmaBox() { - const { isMe, userProfile } = useContext(userProfileContext); - const { userDetails } = useUserDetails(); - const { t } = useTranslation("user_profile"); - const { numberFormatter } = useFormat(); - - // Get karma from userDetails (for current user) or userProfile (for other users) - const karma = isMe ? userDetails?.karma : userProfile?.karma; - - // Don't show if karma is not available - if (karma === undefined || karma === null) return null; - - return ( -
-
-

{t("karma")}

-
- -
-
-
-

- {numberFormatter.format(karma)}{" "} - {t("karma_count")} -

-
-
- - {t("karma_description")} - -
-
-
-
- ); -} diff --git a/src/renderer/src/pages/profile/profile-content/user-stats-box.scss b/src/renderer/src/pages/profile/profile-content/user-stats-box.scss index c19fb612..72a4d580 100644 --- a/src/renderer/src/pages/profile/profile-content/user-stats-box.scss +++ b/src/renderer/src/pages/profile/profile-content/user-stats-box.scss @@ -2,19 +2,9 @@ .user-stats { &__box { - background-color: globals.$background-color; - border-radius: 4px; - border: solid 1px globals.$border-color; padding: calc(globals.$spacing-unit * 2); } - &__section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: calc(globals.$spacing-unit * 2); - } - &__list { list-style: none; margin: 0; @@ -42,13 +32,15 @@ } &__list-title { - font-weight: bold; + font-size: 0.8rem; + font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } &__list-description { + font-size: 0.75rem; display: flex; align-items: center; gap: globals.$spacing-unit; @@ -72,4 +64,10 @@ cursor: pointer; } } + + &__karma-info-text { + color: globals.$muted-color; + font-size: 0.75rem; + line-height: 1.4; + } } diff --git a/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx b/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx index 26ec79f4..6fbabdca 100644 --- a/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx @@ -1,16 +1,18 @@ import { useCallback, useContext } from "react"; import { userProfileContext } from "@renderer/context"; import { useTranslation } from "react-i18next"; -import { useFormat } from "@renderer/hooks"; +import { useFormat, useUserDetails } from "@renderer/hooks"; import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; import HydraIcon from "@renderer/assets/icons/hydra.svg?react"; import { useSubscription } from "@renderer/hooks/use-subscription"; import { ClockIcon, TrophyIcon } from "@primer/octicons-react"; +import { Award } from "lucide-react"; import "./user-stats-box.scss"; export function UserStatsBox() { const { showHydraCloudModal } = useSubscription(); - const { userStats, isMe } = useContext(userProfileContext); + const { userStats, isMe, userProfile } = useContext(userProfileContext); + const { userDetails } = useUserDetails(); const { t } = useTranslation("user_profile"); const { numberFormatter } = useFormat(); @@ -33,88 +35,102 @@ export function UserStatsBox() { if (!userStats) return null; + const karma = isMe ? userDetails?.karma : userProfile?.karma; + const hasKarma = karma !== undefined && karma !== null; + return ( -
-
-

{t("stats")}

-
- -
-
    - {(isMe || userStats.unlockedAchievementSum !== undefined) && ( -
  • -

    - {t("achievements_unlocked")} -

    - {userStats.unlockedAchievementSum !== undefined ? ( -
    -

    - {userStats.unlockedAchievementSum}{" "} - {t("achievements")} -

    -
    - ) : ( - - )} -
  • - )} - - {(isMe || userStats.achievementsPointsEarnedSum !== undefined) && ( -
  • -

    {t("earned_points")}

    - {userStats.achievementsPointsEarnedSum !== undefined ? ( -
    -

    - - {numberFormatter.format( - userStats.achievementsPointsEarnedSum.value - )} -

    -

    - {t("top_percentile", { - percentile: - userStats.achievementsPointsEarnedSum.topPercentile, - })} -

    -
    - ) : ( - - )} -
  • - )} - +
    +
      + {(isMe || userStats.unlockedAchievementSum !== undefined) && (
    • -

      {t("total_play_time")}

      +

      + {t("achievements_unlocked")} +

      + {userStats.unlockedAchievementSum !== undefined ? ( +
      +

      + {userStats.unlockedAchievementSum}{" "} + {t("achievements")} +

      +
      + ) : ( + + )} +
    • + )} + + {(isMe || userStats.achievementsPointsEarnedSum !== undefined) && ( +
    • +

      {t("earned_points")}

      + {userStats.achievementsPointsEarnedSum !== undefined ? ( +
      +

      + + {numberFormatter.format( + userStats.achievementsPointsEarnedSum.value + )} +

      +

      + {t("top_percentile", { + percentile: + userStats.achievementsPointsEarnedSum.topPercentile, + })} +

      +
      + ) : ( + + )} +
    • + )} + +
    • +

      {t("total_play_time")}

      +
      +

      + + {formatPlayTime(userStats.totalPlayTimeInSeconds.value)} +

      +

      + {t("top_percentile", { + percentile: userStats.totalPlayTimeInSeconds.topPercentile, + })} +

      +
      +
    • + + {hasKarma && karma !== undefined && karma !== null && ( +
    • +

      {t("karma")}

      - - {formatPlayTime(userStats.totalPlayTimeInSeconds.value)} -

      -

      - {t("top_percentile", { - percentile: userStats.totalPlayTimeInSeconds.topPercentile, - })} + {numberFormatter.format(karma)}{" "} + {t("karma_count")}

      +
      + + {t("karma_description")} + +
    • -
    -
    + )} +
); } 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 12ea116b..e3459823 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,33 @@ } } - &__badges { + &__copy-button { display: flex; - gap: calc(globals.$spacing-unit / 2); + align-items: center; + justify-content: flex-end; + background: none; + border: none; + color: globals.$body-color; + background-color: rgba(0, 0, 0, 0.3); + cursor: pointer; + padding: calc(globals.$spacing-unit / 1.5); + border-radius: 6px; + transition: background-color ease 0.2s; + overflow: hidden; + white-space: nowrap; + flex-shrink: 0; + box-sizing: border-box; + + &:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.4); + } + } + + &__friend-code { + font-size: 0.875rem; + font-family: monospace; + white-space: nowrap; } &__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 c833caf5..6fafc95e 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, @@ -25,13 +26,13 @@ import { } from "@renderer/hooks"; import { addSeconds } from "date-fns"; import { useNavigate } from "react-router-dom"; +import { motion } from "framer-motion"; import type { FriendRequestAction } from "@types"; import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal"; import { WrappedFullscreenModal } from "../profile-content/wrapped-tab"; 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 = @@ -43,15 +44,10 @@ export function ProfileHero() { const [showWrappedModal, setShowWrappedModal] = useState(false); const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false); + const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false); - const { - isMe, - badges, - getUserProfile, - userProfile, - heroBackground, - backgroundImage, - } = useContext(userProfileContext); + const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } = + useContext(userProfileContext); const { signOut, updateFriendRequestState, @@ -262,6 +258,13 @@ export function ProfileHero() { } }, [isMe, userProfile?.profileImageUrl]); + 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) @@ -336,28 +339,32 @@ export function ProfileHero() { {userProfile?.displayName} -
- {userProfile.badges.map((badgeName) => { - const badge = badges.find((b) => b.name === badgeName); - - if (!badge) return null; - - return ( - {badge.name} - ); - })} - - -
+ setIsCopyButtonHovered(true)} + onMouseLeave={() => setIsCopyButtonHovered(false)} + initial={{ width: 28 }} + animate={{ + width: isCopyButtonHovered ? 105 : 28, + }} + transition={{ duration: 0.2, ease: "easeInOut" }} + > + + {userProfile?.id} + + +
) : ( diff --git a/src/renderer/src/pages/profile/profile-section/profile-section.scss b/src/renderer/src/pages/profile/profile-section/profile-section.scss new file mode 100644 index 00000000..dfc6abf9 --- /dev/null +++ b/src/renderer/src/pages/profile/profile-section/profile-section.scss @@ -0,0 +1,71 @@ +@use "../../../scss/globals.scss"; + +.profile-section { + background-color: globals.$background-color; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + background-color: globals.$background-color; + padding-right: calc(globals.$spacing-unit * 2); + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } + + &__button { + padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2); + display: flex; + align-items: center; + background-color: transparent; + color: globals.$muted-color; + flex: 1; + cursor: pointer; + transition: all ease 0.2s; + gap: globals.$spacing-unit; + font-size: globals.$body-font-size; + font-weight: bold; + border: none; + + &:active { + opacity: globals.$active-opacity; + } + } + + &__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; + } + + &__action { + display: flex; + align-items: center; + } + + &__chevron { + transition: transform ease 0.2s; + + &--open { + transform: rotate(180deg); + } + } + + &__content { + overflow: hidden; + transition: max-height 0.4s cubic-bezier(0, 1, 0, 1); + background-color: globals.$dark-background-color; + position: relative; + } +} diff --git a/src/renderer/src/pages/profile/profile-section/profile-section.tsx b/src/renderer/src/pages/profile/profile-section/profile-section.tsx new file mode 100644 index 00000000..4586d58f --- /dev/null +++ b/src/renderer/src/pages/profile/profile-section/profile-section.tsx @@ -0,0 +1,64 @@ +import { ChevronDownIcon } from "@primer/octicons-react"; +import { useEffect, useRef, useState } from "react"; +import "./profile-section.scss"; + +export interface ProfileSectionProps { + title: string; + count?: number; + action?: React.ReactNode; + children: React.ReactNode; + defaultOpen?: boolean; +} + +export function ProfileSection({ + title, + count, + action, + children, + defaultOpen = true, +}: ProfileSectionProps) { + const content = useRef(null); + const [isOpen, setIsOpen] = useState(defaultOpen); + const [height, setHeight] = useState(0); + + useEffect(() => { + if (content.current && content.current.scrollHeight !== height) { + setHeight(isOpen ? content.current.scrollHeight : 0); + } else if (!isOpen) { + setHeight(0); + } + }, [isOpen, children, height]); + + return ( +
+
+ + {action &&
{action}
} +
+ +
+ {children} +
+
+ ); +} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts b/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts deleted file mode 100644 index c7484512..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./user-friend-modal"; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.scss b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.scss deleted file mode 100644 index 216538ff..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.scss +++ /dev/null @@ -1,79 +0,0 @@ -@use "../../../scss/globals.scss"; - -.user-friend-item { - &__container { - display: flex; - gap: calc(globals.$spacing-unit * 3); - align-items: center; - border-radius: 4px; - border: solid 1px globals.$border-color; - width: 100%; - height: 54px; - min-height: 54px; - transition: all ease 0.2s; - position: relative; - - &:hover { - background-color: rgba(255, 255, 255, 0.15); - } - } - - &__button { - display: flex; - align-items: center; - position: absolute; - cursor: pointer; - height: 100%; - width: 100%; - flex-direction: row; - color: globals.$body-color; - gap: calc(globals.$spacing-unit + globals.$spacing-unit / 2); - padding: 0 globals.$spacing-unit; - - &__content { - display: flex; - flex-direction: column; - align-items: flex-start; - flex: 1; - min-width: 0; - } - - &__actions { - position: absolute; - right: 8px; - display: flex; - gap: 8px; - } - } - - &__display-name { - font-weight: bold; - font-size: globals.$body-font-size; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__accept-button { - cursor: pointer; - color: globals.$body-color; - width: 28px; - height: 28px; - - &:hover { - color: globals.$success-color; - } - } - - &__cancel-button { - cursor: pointer; - color: globals.$body-color; - width: 28px; - height: 28px; - - &:hover { - color: globals.$danger-color; - } - } -} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx deleted file mode 100644 index 0538e717..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { CheckCircleIcon, XCircleIcon } from "@primer/octicons-react"; -import { useTranslation } from "react-i18next"; -import { Avatar } from "@renderer/components"; -import "./user-friend-item.scss"; - -export type UserFriendItemProps = { - userId: string; - profileImageUrl: string | null; - displayName: string; -} & ( - | { - type: "ACCEPTED"; - onClickUndoFriendship: (userId: string) => void; - onClickItem: (userId: string) => void; - } - | { type: "BLOCKED"; onClickUnblock: (userId: string) => void } - | { - type: "SENT" | "RECEIVED"; - onClickCancelRequest: (userId: string) => void; - onClickAcceptRequest: (userId: string) => void; - onClickRefuseRequest: (userId: string) => void; - onClickItem: (userId: string) => void; - } - | { type: null; onClickItem: (userId: string) => void } -); - -export const UserFriendItem = (props: UserFriendItemProps) => { - const { t } = useTranslation("user_profile"); - const { userId, profileImageUrl, displayName, type } = props; - - const getRequestDescription = () => { - if (type === "ACCEPTED" || type === null) return null; - - return ( - - {type == "SENT" ? t("request_sent") : t("request_received")} - - ); - }; - - const getRequestActions = () => { - if (type === null) return null; - - if (type === "SENT") { - return ( - - ); - } - - if (type === "RECEIVED") { - return ( - <> - - - - ); - } - - if (type === "ACCEPTED") { - return ( - - ); - } - - if (type === "BLOCKED") { - return ( - - ); - } - - return null; - }; - - if (type === "BLOCKED") { - return ( -
-
- -
-

{displayName}

-
-
-
- {getRequestActions()} -
-
- ); - } - - return ( -
- -
- {getRequestActions()} -
-
- ); -}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.scss b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.scss deleted file mode 100644 index 8c896a9e..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.scss +++ /dev/null @@ -1,21 +0,0 @@ -@use "../../../scss/globals.scss"; - -.user-friend-modal-add-friend { - &__actions { - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - gap: globals.$spacing-unit; - } - - &__button { - align-self: end; - } - - &__pending-container { - display: flex; - flex-direction: column; - gap: calc(globals.$spacing-unit * 2); - } -} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx deleted file mode 100644 index 84248522..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Button, TextField } from "@renderer/components"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { UserFriendItem } from "./user-friend-item"; -import "./user-friend-modal-add-friend.scss"; - -export interface UserFriendModalAddFriendProps { - closeModal: () => void; -} - -export const UserFriendModalAddFriend = ({ - closeModal, -}: UserFriendModalAddFriendProps) => { - const { t } = useTranslation("user_profile"); - - const [friendCode, setFriendCode] = useState(""); - const [isAddingFriend, setIsAddingFriend] = useState(false); - - const navigate = useNavigate(); - - const { sendFriendRequest, updateFriendRequestState, friendRequests } = - useUserDetails(); - - const { showSuccessToast, showErrorToast } = useToast(); - - const handleClickAddFriend = () => { - setIsAddingFriend(true); - sendFriendRequest(friendCode) - .then(() => { - setFriendCode(""); - }) - .catch(() => { - showErrorToast(t("error_adding_friend")); - }) - .finally(() => { - setIsAddingFriend(false); - }); - }; - - const handleClickRequest = (userId: string) => { - closeModal(); - navigate(`/profile/${userId}`); - }; - - const handleClickSeeProfile = () => { - if (friendCode.length === 8) { - closeModal(); - navigate(`/profile/${friendCode}`); - } - }; - - const validateFriendCode = (callback: () => void) => { - if (friendCode.length === 8) { - return callback(); - } - - showErrorToast(t("friend_code_length_error")); - }; - - const handleCancelFriendRequest = (userId: string) => { - updateFriendRequestState(userId, "CANCEL").catch(() => { - showErrorToast(t("try_again")); - }); - }; - - const handleAcceptFriendRequest = (userId: string) => { - updateFriendRequestState(userId, "ACCEPTED") - .then(() => { - showSuccessToast(t("request_accepted")); - }) - .catch(() => { - showErrorToast(t("try_again")); - }); - }; - - const handleRefuseFriendRequest = (userId: string) => { - updateFriendRequestState(userId, "REFUSED").catch(() => { - showErrorToast(t("try_again")); - }); - }; - - const handleChangeFriendCode = (e: React.ChangeEvent) => { - const friendCode = e.target.value.trim().slice(0, 8); - setFriendCode(friendCode); - }; - - return ( - <> -
- - - - -
- -
-

{t("pending")}

- {friendRequests.length === 0 &&

{t("no_pending_invites")}

} - {friendRequests.map((request) => { - return ( - - ); - })} -
- - ); -}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.scss b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.scss deleted file mode 100644 index eaf0e527..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.scss +++ /dev/null @@ -1,16 +0,0 @@ -@use "../../../scss/globals.scss"; - -.user-friend-modal-list { - display: flex; - flex-direction: column; - gap: calc(globals.$spacing-unit * 2); - max-height: 400px; - overflow-y: scroll; - - &__skeleton { - width: 100%; - height: 54px; - overflow: hidden; - border-radius: 4px; - } -} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx deleted file mode 100644 index 6ae91cfd..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import type { UserFriend } from "@types"; -import { useEffect, useRef, useState } from "react"; -import { UserFriendItem } from "./user-friend-item"; -import { useNavigate } from "react-router-dom"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { useTranslation } from "react-i18next"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "./user-friend-modal-list.scss"; - -export interface UserFriendModalListProps { - userId: string; - closeModal: () => void; -} - -const pageSize = 12; - -export const UserFriendModalList = ({ - userId, - closeModal, -}: UserFriendModalListProps) => { - const { t } = useTranslation("user_profile"); - const { showErrorToast } = useToast(); - const navigate = useNavigate(); - - const [page, setPage] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [maxPage, setMaxPage] = useState(0); - const [friends, setFriends] = useState([]); - const listContainer = useRef(null); - - const { userDetails, undoFriendship } = useUserDetails(); - const isMe = userDetails?.id == userId; - - const loadNextPage = () => { - if (page > maxPage) return; - setIsLoading(true); - - const url = isMe ? "/profile/friends" : `/users/${userId}/friends`; - - window.electron.hydraApi - .get<{ totalFriends: number; friends: UserFriend[] }>(url, { - params: { take: pageSize, skip: page * pageSize }, - }) - .then((newPage) => { - if (page === 0) { - setMaxPage(newPage.totalFriends / pageSize); - } - - setFriends([...friends, ...newPage.friends]); - setPage(page + 1); - }) - .catch(() => {}) - .finally(() => setIsLoading(false)); - }; - - const handleScroll = () => { - const scrollTop = listContainer.current?.scrollTop || 0; - const scrollHeight = listContainer.current?.scrollHeight || 0; - const clientHeight = listContainer.current?.clientHeight || 0; - const maxScrollTop = scrollHeight - clientHeight; - - if (scrollTop < maxScrollTop * 0.9 || isLoading) { - return; - } - - loadNextPage(); - }; - - useEffect(() => { - const container = listContainer.current; - container?.addEventListener("scroll", handleScroll); - return () => container?.removeEventListener("scroll", handleScroll); - }, [isLoading]); - - const reloadList = () => { - setPage(0); - setMaxPage(0); - setFriends([]); - loadNextPage(); - }; - - useEffect(() => { - reloadList(); - }, [userId]); - - const handleClickFriend = (userId: string) => { - closeModal(); - navigate(`/profile/${userId}`); - }; - - const handleUndoFriendship = (userId: string) => { - undoFriendship(userId) - .then(() => { - reloadList(); - }) - .catch(() => { - showErrorToast(t("try_again")); - }); - }; - - return ( - -
- {!isLoading && friends.length === 0 &&

{t("no_friends_added")}

} - {friends.map((friend) => ( - - ))} - {isLoading && } -
-
- ); -}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.scss b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.scss deleted file mode 100644 index 550c0fd9..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.scss +++ /dev/null @@ -1,34 +0,0 @@ -@use "../../../scss/globals.scss"; - -.user-friend-modal { - &__container { - display: flex; - width: 500px; - flex-direction: column; - gap: calc(globals.$spacing-unit * 2); - } - - &__header { - display: flex; - gap: globals.$spacing-unit; - align-items: center; - } - - &__friend-code-button { - color: globals.$body-color; - cursor: pointer; - display: flex; - gap: calc(globals.$spacing-unit / 2); - align-items: center; - transition: all ease 0.2s; - - &:hover { - color: globals.$muted-color; - } - } - - &__tabs { - display: flex; - gap: globals.$spacing-unit; - } -} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx deleted file mode 100644 index 7c045394..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Button, Modal } from "@renderer/components"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend"; -import { useToast, useUserDetails } from "@renderer/hooks"; -import { UserFriendModalList } from "./user-friend-modal-list"; -import { CopyIcon } from "@primer/octicons-react"; -import "./user-friend-modal.scss"; - -export enum UserFriendModalTab { - FriendsList, - AddFriend, -} - -export interface UserFriendsModalProps { - visible: boolean; - onClose: () => void; - initialTab: UserFriendModalTab | null; - userId: string; -} - -export const UserFriendModal = ({ - visible, - onClose, - initialTab, - userId, -}: UserFriendsModalProps) => { - const { t } = useTranslation("user_profile"); - - const tabs = [t("friends_list"), t("add_friends")]; - - const [currentTab, setCurrentTab] = useState( - initialTab || UserFriendModalTab.FriendsList - ); - - const { showSuccessToast } = useToast(); - - const { userDetails } = useUserDetails(); - const isMe = userDetails?.id == userId; - - useEffect(() => { - if (initialTab != null) { - setCurrentTab(initialTab); - } - }, [initialTab]); - - const renderTab = () => { - if (currentTab == UserFriendModalTab.FriendsList) { - return ; - } - - if (currentTab == UserFriendModalTab.AddFriend) { - return ; - } - - return <>; - }; - - const copyToClipboard = useCallback(() => { - navigator.clipboard.writeText(userDetails!.id); - showSuccessToast(t("friend_code_copied")); - }, [userDetails, showSuccessToast, t]); - - return ( - -
- {isMe && ( - <> -
-

{t("your_friend_code")}

- -
-
- {tabs.map((tab, index) => ( - - ))} -
- - )} - {renderTab()} -
-
- ); -}; diff --git a/src/types/index.ts b/src/types/index.ts index 69450569..de792b05 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; @@ -166,6 +170,7 @@ export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS"; export interface Badge { name: string; + title: string; description: string; badge: { url: string; @@ -313,6 +318,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: {