mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
feat: add local notifications management and UI integration
This commit is contained in:
2
proto
2
proto
Submodule proto updated: 7a23620f93...6f11c99c57
@@ -26,6 +26,7 @@
|
||||
"game_has_no_executable": "Game has no executable selected",
|
||||
"sign_in": "Sign in",
|
||||
"friends": "Friends",
|
||||
"notifications": "Notifications",
|
||||
"need_help": "Need help?",
|
||||
"favorites": "Favorites",
|
||||
"playable_button_title": "Show only games you can play now",
|
||||
@@ -660,6 +661,7 @@
|
||||
"sending": "Sending",
|
||||
"friend_request_sent": "Friend request sent",
|
||||
"friends": "Friends",
|
||||
"badges": "Badges",
|
||||
"friends_list": "Friends list",
|
||||
"user_not_found": "User not found",
|
||||
"block_user": "Block user",
|
||||
@@ -670,12 +672,16 @@
|
||||
"ignore_request": "Ignore request",
|
||||
"cancel_request": "Cancel request",
|
||||
"undo_friendship": "Undo friendship",
|
||||
"friendship_removed": "Friend removed",
|
||||
"request_accepted": "Request accepted",
|
||||
"user_blocked_successfully": "User blocked successfully",
|
||||
"user_block_modal_text": "This will block {{displayName}}",
|
||||
"blocked_users": "Blocked users",
|
||||
"unblock": "Unblock",
|
||||
"no_friends_added": "You have no added friends",
|
||||
"view_all": "View all",
|
||||
"load_more": "Load more",
|
||||
"loading": "Loading",
|
||||
"pending": "Pending",
|
||||
"no_pending_invites": "You have no pending invites",
|
||||
"no_blocked_users": "You have no blocked users",
|
||||
@@ -699,6 +705,7 @@
|
||||
"report_reason_other": "Other",
|
||||
"profile_reported": "Profile reported",
|
||||
"your_friend_code": "Your friend code:",
|
||||
"copy_friend_code": "Copy friend code",
|
||||
"upload_banner": "Upload banner",
|
||||
"uploading_banner": "Uploading banner…",
|
||||
"background_image_updated": "Background image updated",
|
||||
@@ -772,5 +779,40 @@
|
||||
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
||||
"learn_more": "Learn More",
|
||||
"debrid_description": "Download up to 4x faster with Nimbus"
|
||||
},
|
||||
"notifications_page": {
|
||||
"title": "Notifications",
|
||||
"mark_all_as_read": "Mark all as read",
|
||||
"clear_all": "Clear All",
|
||||
"loading": "Loading...",
|
||||
"empty_title": "No notifications",
|
||||
"empty_description": "You're all caught up! Check back later for new updates.",
|
||||
"empty_filter_description": "No notifications match this filter.",
|
||||
"filter_all": "All",
|
||||
"filter_friends": "Friends",
|
||||
"filter_badges": "Badges",
|
||||
"filter_upvotes": "Upvotes",
|
||||
"filter_local": "Local",
|
||||
"load_more": "Load more",
|
||||
"dismiss": "Dismiss",
|
||||
"accept": "Accept",
|
||||
"refuse": "Refuse",
|
||||
"notification": "Notification",
|
||||
"friend_request_received_title": "New friend request!",
|
||||
"friend_request_received_description": "{{displayName}} wants to be your friend",
|
||||
"friend_request_accepted_title": "Friend request accepted!",
|
||||
"friend_request_accepted_description": "{{displayName}} accepted your friend request",
|
||||
"badge_received_title": "You got a new badge!",
|
||||
"badge_received_description": "{{badgeName}}",
|
||||
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
|
||||
"review_upvote_description": "Your review received {{count}} new upvotes",
|
||||
"marked_all_as_read": "All notifications marked as read",
|
||||
"failed_to_mark_as_read": "Failed to mark notifications as read",
|
||||
"cleared_all": "All notifications cleared",
|
||||
"failed_to_clear": "Failed to clear notifications",
|
||||
"failed_to_load": "Failed to load notifications",
|
||||
"failed_to_dismiss": "Failed to dismiss notification",
|
||||
"friend_request_accepted": "Friend request accepted",
|
||||
"friend_request_refused": "Friend request refused"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const clearAllLocalNotifications = async () => {
|
||||
await LocalNotificationManager.clearAll();
|
||||
};
|
||||
|
||||
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);
|
||||
11
src/main/events/notifications/delete-local-notification.ts
Normal file
11
src/main/events/notifications/delete-local-notification.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const deleteLocalNotification = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: string
|
||||
) => {
|
||||
await LocalNotificationManager.deleteNotification(id);
|
||||
};
|
||||
|
||||
registerEvent("deleteLocalNotification", deleteLocalNotification);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotificationsCount = async () => {
|
||||
return LocalNotificationManager.getUnreadCount();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);
|
||||
8
src/main/events/notifications/get-local-notifications.ts
Normal file
8
src/main/events/notifications/get-local-notifications.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotifications = async () => {
|
||||
return LocalNotificationManager.getNotifications();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotifications", getLocalNotifications);
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const markAllLocalNotificationsRead = async () => {
|
||||
await LocalNotificationManager.markAllAsRead();
|
||||
};
|
||||
|
||||
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||
@@ -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);
|
||||
@@ -1,4 +1,4 @@
|
||||
// @generated by protobuf-ts 2.10.0
|
||||
// @generated by protobuf-ts 2.11.1
|
||||
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||
// tslint:disable
|
||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
|
||||
*/
|
||||
export interface FriendRequest {
|
||||
/**
|
||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
||||
* @generated from protobuf field: int32 friend_request_count = 1
|
||||
*/
|
||||
friendRequestCount: number;
|
||||
/**
|
||||
* @generated from protobuf field: optional string sender_id = 2;
|
||||
* @generated from protobuf field: optional string sender_id = 2
|
||||
*/
|
||||
senderId?: string;
|
||||
}
|
||||
@@ -28,18 +28,27 @@ export interface FriendRequest {
|
||||
*/
|
||||
export interface FriendGameSession {
|
||||
/**
|
||||
* @generated from protobuf field: string object_id = 1;
|
||||
* @generated from protobuf field: string object_id = 1
|
||||
*/
|
||||
objectId: string;
|
||||
/**
|
||||
* @generated from protobuf field: string shop = 2;
|
||||
* @generated from protobuf field: string shop = 2
|
||||
*/
|
||||
shop: string;
|
||||
/**
|
||||
* @generated from protobuf field: string friend_id = 3;
|
||||
* @generated from protobuf field: string friend_id = 3
|
||||
*/
|
||||
friendId: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Notification
|
||||
*/
|
||||
export interface Notification {
|
||||
/**
|
||||
* @generated from protobuf field: int32 notification_count = 1
|
||||
*/
|
||||
notificationCount: number;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Envelope
|
||||
*/
|
||||
@@ -51,17 +60,24 @@ export interface Envelope {
|
||||
| {
|
||||
oneofKind: "friendRequest";
|
||||
/**
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1
|
||||
*/
|
||||
friendRequest: FriendRequest;
|
||||
}
|
||||
| {
|
||||
oneofKind: "friendGameSession";
|
||||
/**
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2
|
||||
*/
|
||||
friendGameSession: FriendGameSession;
|
||||
}
|
||||
| {
|
||||
oneofKind: "notification";
|
||||
/**
|
||||
* @generated from protobuf field: Notification notification = 3
|
||||
*/
|
||||
notification: Notification;
|
||||
}
|
||||
| {
|
||||
oneofKind: undefined;
|
||||
};
|
||||
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
||||
*/
|
||||
export const FriendGameSession = new FriendGameSession$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Notification$Type extends MessageType<Notification> {
|
||||
constructor() {
|
||||
super("Notification", [
|
||||
{
|
||||
no: 1,
|
||||
name: "notification_count",
|
||||
kind: "scalar",
|
||||
T: 5 /*ScalarType.INT32*/,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Notification>): Notification {
|
||||
const message = globalThis.Object.create(this.messagePrototype!);
|
||||
message.notificationCount = 0;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<Notification>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(
|
||||
reader: IBinaryReader,
|
||||
length: number,
|
||||
options: BinaryReadOptions,
|
||||
target?: Notification
|
||||
): Notification {
|
||||
let message = target ?? this.create(),
|
||||
end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* int32 notification_count */ 1:
|
||||
message.notificationCount = reader.int32();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(
|
||||
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||
);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
fieldNo,
|
||||
wireType,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(
|
||||
message: Notification,
|
||||
writer: IBinaryWriter,
|
||||
options: BinaryWriteOptions
|
||||
): IBinaryWriter {
|
||||
/* int32 notification_count = 1; */
|
||||
if (message.notificationCount !== 0)
|
||||
writer.tag(1, WireType.Varint).int32(message.notificationCount);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
writer
|
||||
);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message Notification
|
||||
*/
|
||||
export const Notification = new Notification$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Envelope$Type extends MessageType<Envelope> {
|
||||
constructor() {
|
||||
super("Envelope", [
|
||||
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
oneof: "payload",
|
||||
T: () => FriendGameSession,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
name: "notification",
|
||||
kind: "message",
|
||||
oneof: "payload",
|
||||
T: () => Notification,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Envelope>): Envelope {
|
||||
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
),
|
||||
};
|
||||
break;
|
||||
case /* Notification notification */ 3:
|
||||
message.payload = {
|
||||
oneofKind: "notification",
|
||||
notification: Notification.internalBinaryRead(
|
||||
reader,
|
||||
reader.uint32(),
|
||||
options,
|
||||
(message.payload as any).notification
|
||||
),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
/* Notification notification = 3; */
|
||||
if (message.payload.oneofKind === "notification")
|
||||
Notification.internalBinaryWrite(
|
||||
message.payload.notification,
|
||||
writer.tag(3, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
export * from "./local-notifications";
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
11
src/main/level/sublevels/local-notifications.ts
Normal file
11
src/main/level/sublevels/local-notifications.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LocalNotification } from "@types";
|
||||
|
||||
import { db } from "../level";
|
||||
import { levelKeys } from "./keys";
|
||||
|
||||
export const localNotificationsSublevel = db.sublevel<
|
||||
string,
|
||||
LocalNotification
|
||||
>(levelKeys.localNotifications, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
@@ -20,3 +20,4 @@ export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
export * from "./notifications/local-notifications";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { localNotificationsSublevel } from "@main/level";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { LocalNotification, LocalNotificationType } from "@types";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export class LocalNotificationManager {
|
||||
private static generateId(): string {
|
||||
return crypto.randomBytes(8).toString("hex");
|
||||
}
|
||||
|
||||
static async createNotification(
|
||||
type: LocalNotificationType,
|
||||
title: string,
|
||||
description: string,
|
||||
options?: {
|
||||
pictureUrl?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
): Promise<LocalNotification> {
|
||||
const id = this.generateId();
|
||||
const notification: LocalNotification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
pictureUrl: options?.pictureUrl ?? null,
|
||||
url: options?.url ?? null,
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
|
||||
// Notify renderer about new notification
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-local-notification-created",
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
static async getNotifications(): Promise<LocalNotification[]> {
|
||||
const notifications: LocalNotification[] = [];
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
notifications.push(value);
|
||||
}
|
||||
|
||||
// Sort by createdAt descending
|
||||
return notifications.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
static async getUnreadCount(): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static async markAsRead(id: string): Promise<void> {
|
||||
const notification = await localNotificationsSublevel.get(id);
|
||||
if (notification) {
|
||||
notification.isRead = true;
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
}
|
||||
}
|
||||
|
||||
static async markAllAsRead(): Promise<void> {
|
||||
const batch = localNotificationsSublevel.batch();
|
||||
|
||||
for await (const [key, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
value.isRead = true;
|
||||
batch.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.write();
|
||||
}
|
||||
|
||||
static async deleteNotification(id: string): Promise<void> {
|
||||
await localNotificationsSublevel.del(id);
|
||||
}
|
||||
|
||||
static async clearAll(): Promise<void> {
|
||||
await localNotificationsSublevel.clear();
|
||||
}
|
||||
}
|
||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Notification } from "@main/generated/envelope";
|
||||
import { WindowManager } from "@main/services/window-manager";
|
||||
|
||||
export const notificationEvent = (payload: Notification) => {
|
||||
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
|
||||
notificationCount: payload.notificationCount,
|
||||
});
|
||||
};
|
||||
@@ -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"));
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
GameAchievement,
|
||||
Theme,
|
||||
FriendRequestSync,
|
||||
NotificationSync,
|
||||
ShortcutLocation,
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
@@ -507,6 +508,15 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
||||
},
|
||||
onSyncNotificationCount: (cb: (notification: NotificationSync) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
notification: NotificationSync
|
||||
) => cb(notification);
|
||||
ipcRenderer.on("on-sync-notification-count", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-sync-notification-count", listener);
|
||||
},
|
||||
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||
|
||||
@@ -550,6 +560,26 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
||||
getLocalNotifications: () => ipcRenderer.invoke("getLocalNotifications"),
|
||||
getLocalNotificationsCount: () =>
|
||||
ipcRenderer.invoke("getLocalNotificationsCount"),
|
||||
markLocalNotificationRead: (id: string) =>
|
||||
ipcRenderer.invoke("markLocalNotificationRead", id),
|
||||
markAllLocalNotificationsRead: () =>
|
||||
ipcRenderer.invoke("markAllLocalNotificationsRead"),
|
||||
deleteLocalNotification: (id: string) =>
|
||||
ipcRenderer.invoke("deleteLocalNotification", id),
|
||||
clearAllLocalNotifications: () =>
|
||||
ipcRenderer.invoke("clearAllLocalNotifications"),
|
||||
onLocalNotificationCreated: (cb: (notification: unknown) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
notification: unknown
|
||||
) => cb(notification);
|
||||
ipcRenderer.on("on-local-notification-created", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-local-notification-created", listener);
|
||||
},
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
position?: AchievementCustomNotificationPosition,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__notification-button,
|
||||
&__friends-button {
|
||||
color: globals.$muted-color;
|
||||
cursor: pointer;
|
||||
@@ -62,6 +63,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__notification-button-badge,
|
||||
&__friends-button-badge {
|
||||
background-color: globals.$success-color;
|
||||
display: flex;
|
||||
@@ -73,6 +75,8 @@
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__game-running-icon {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PeopleIcon } from "@primer/octicons-react";
|
||||
import { PeopleIcon, BellIcon } from "@primer/octicons-react";
|
||||
import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { Avatar } from "../avatar/avatar";
|
||||
import { AuthPage } from "@shared";
|
||||
import { logger } from "@renderer/logger";
|
||||
import type { NotificationCountResponse } from "@types";
|
||||
import "./sidebar-profile.scss";
|
||||
|
||||
export function SidebarProfile() {
|
||||
@@ -19,6 +21,71 @@ export function SidebarProfile() {
|
||||
|
||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
||||
|
||||
const [notificationCount, setNotificationCount] = useState(0);
|
||||
|
||||
const fetchNotificationCount = useCallback(async () => {
|
||||
try {
|
||||
// Always fetch local notification count
|
||||
const localCount = await window.electron.getLocalNotificationsCount();
|
||||
|
||||
// Fetch API notification count only if logged in
|
||||
let apiCount = 0;
|
||||
if (userDetails) {
|
||||
try {
|
||||
const response =
|
||||
await window.electron.hydraApi.get<NotificationCountResponse>(
|
||||
"/profile/notifications/count",
|
||||
{ needsAuth: true }
|
||||
);
|
||||
apiCount = response.count;
|
||||
} catch {
|
||||
// Ignore API errors
|
||||
}
|
||||
}
|
||||
|
||||
setNotificationCount(localCount + apiCount);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch notification count", error);
|
||||
}
|
||||
}, [userDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationCount();
|
||||
|
||||
const interval = setInterval(fetchNotificationCount, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLocalNotificationCreated(() => {
|
||||
fetchNotificationCount();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationsChange = () => {
|
||||
fetchNotificationCount();
|
||||
};
|
||||
|
||||
window.addEventListener("notificationsChanged", handleNotificationsChange);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"notificationsChanged",
|
||||
handleNotificationsChange
|
||||
);
|
||||
};
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onSyncNotificationCount(() => {
|
||||
fetchNotificationCount();
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [fetchNotificationCount]);
|
||||
|
||||
const handleProfileClick = () => {
|
||||
if (userDetails === null) {
|
||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||
@@ -28,6 +95,25 @@ export function SidebarProfile() {
|
||||
navigate(`/profile/${userDetails.id}`);
|
||||
};
|
||||
|
||||
const notificationsButton = useMemo(() => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-profile__notification-button"
|
||||
onClick={() => navigate("/notifications")}
|
||||
title={t("notifications")}
|
||||
>
|
||||
{notificationCount > 0 && (
|
||||
<small className="sidebar-profile__notification-button-badge">
|
||||
{notificationCount > 99 ? "99+" : notificationCount}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<BellIcon size={16} />
|
||||
</button>
|
||||
);
|
||||
}, [t, notificationCount, navigate]);
|
||||
|
||||
const friendsButton = useMemo(() => {
|
||||
if (!userDetails) return null;
|
||||
|
||||
@@ -98,6 +184,7 @@ export function SidebarProfile() {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{notificationsButton}
|
||||
{friendsButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
14
src/renderer/src/declaration.d.ts
vendored
14
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,7 @@ import type {
|
||||
GameStats,
|
||||
UserDetails,
|
||||
FriendRequestSync,
|
||||
NotificationSync,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
UserAchievement,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
Game,
|
||||
DiskUsage,
|
||||
DownloadSource,
|
||||
LocalNotification,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
@@ -391,6 +393,9 @@ declare global {
|
||||
onSyncFriendRequests: (
|
||||
cb: (friendRequests: FriendRequestSync) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onSyncNotificationCount: (
|
||||
cb: (notification: NotificationSync) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
updateFriendRequest: (
|
||||
userId: string,
|
||||
action: FriendRequestAction
|
||||
@@ -398,6 +403,15 @@ declare global {
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
||||
getLocalNotifications: () => Promise<LocalNotification[]>;
|
||||
getLocalNotificationsCount: () => Promise<number>;
|
||||
markLocalNotificationRead: (id: string) => Promise<void>;
|
||||
markAllLocalNotificationsRead: () => Promise<void>;
|
||||
deleteLocalNotification: (id: string) => Promise<void>;
|
||||
clearAllLocalNotifications: () => Promise<void>;
|
||||
onLocalNotificationCreated: (
|
||||
cb: (notification: LocalNotification) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
position?: AchievementCustomNotificationPosition,
|
||||
|
||||
@@ -31,6 +31,7 @@ import Profile from "./pages/profile/profile";
|
||||
import Achievements from "./pages/achievements/achievements";
|
||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||
import Library from "./pages/library/library";
|
||||
import Notifications from "./pages/notifications/notifications";
|
||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||
|
||||
console.log = logger.log;
|
||||
@@ -76,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/profile/:userId" element={<Profile />} />
|
||||
<Route path="/achievements" element={<Achievements />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PencilIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
import { DescriptionHeader } from "./description-header/description-header";
|
||||
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
|
||||
|
||||
export function GameDetailsContent() {
|
||||
const { t } = useTranslation("game_details");
|
||||
const [searchParams] = useSearchParams();
|
||||
const reviewsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
objectId,
|
||||
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
|
||||
getGameArtifacts();
|
||||
}, [getGameArtifacts]);
|
||||
|
||||
// Scroll to reviews section if reviews=true in URL
|
||||
useEffect(() => {
|
||||
const shouldScrollToReviews = searchParams.get("reviews") === "true";
|
||||
if (shouldScrollToReviews && reviewsRef.current) {
|
||||
setTimeout(() => {
|
||||
reviewsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, 500);
|
||||
}
|
||||
}, [searchParams, objectId]);
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
const heroImage = isCustomGame
|
||||
@@ -229,15 +242,17 @@ export function GameDetailsContent() {
|
||||
)}
|
||||
|
||||
{shop !== "custom" && shop && objectId && (
|
||||
<GameReviews
|
||||
shop={shop}
|
||||
objectId={objectId}
|
||||
game={game}
|
||||
userDetailsId={userDetails?.id}
|
||||
isGameInLibrary={isGameInLibrary}
|
||||
hasUserReviewed={hasUserReviewed}
|
||||
onUserReviewedChange={setHasUserReviewed}
|
||||
/>
|
||||
<div ref={reviewsRef}>
|
||||
<GameReviews
|
||||
shop={shop}
|
||||
objectId={objectId}
|
||||
game={game}
|
||||
userDetailsId={userDetails?.id}
|
||||
isGameInLibrary={isGameInLibrary}
|
||||
hasUserReviewed={hasUserReviewed}
|
||||
onUserReviewedChange={setHasUserReviewed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
104
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
104
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
XIcon,
|
||||
DownloadIcon,
|
||||
PackageIcon,
|
||||
SyncIcon,
|
||||
TrophyIcon,
|
||||
ClockIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDate } from "@renderer/hooks";
|
||||
import cn from "classnames";
|
||||
|
||||
import type { LocalNotification } from "@types";
|
||||
import "./notification-item.scss";
|
||||
|
||||
interface LocalNotificationItemProps {
|
||||
notification: LocalNotification;
|
||||
onDismiss: (id: string) => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LocalNotificationItem({
|
||||
notification,
|
||||
onDismiss,
|
||||
onMarkAsRead,
|
||||
}: LocalNotificationItemProps) {
|
||||
const { t } = useTranslation("notifications_page");
|
||||
const { formatDistance } = useDate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!notification.isRead) {
|
||||
onMarkAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.url) {
|
||||
navigate(notification.url);
|
||||
}
|
||||
}, [notification, onMarkAsRead, navigate]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(notification.id);
|
||||
},
|
||||
[notification.id, onDismiss]
|
||||
);
|
||||
|
||||
const getIcon = () => {
|
||||
switch (notification.type) {
|
||||
case "DOWNLOAD_COMPLETE":
|
||||
return <DownloadIcon size={24} />;
|
||||
case "EXTRACTION_COMPLETE":
|
||||
return <PackageIcon size={24} />;
|
||||
case "UPDATE_AVAILABLE":
|
||||
return <SyncIcon size={24} />;
|
||||
case "ACHIEVEMENT_UNLOCKED":
|
||||
return <TrophyIcon size={24} />;
|
||||
default:
|
||||
return <DownloadIcon size={24} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("notification-item", {
|
||||
"notification-item--unread": !notification.isRead,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="notification-item__picture">
|
||||
{notification.pictureUrl ? (
|
||||
<img src={notification.pictureUrl} alt="" />
|
||||
) : (
|
||||
getIcon()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notification-item__content">
|
||||
<span className="notification-item__title">{notification.title}</span>
|
||||
<span className="notification-item__description">
|
||||
{notification.description}
|
||||
</span>
|
||||
<span className="notification-item__time">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(new Date(notification.createdAt), new Date())}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item__dismiss"
|
||||
onClick={handleDismiss}
|
||||
title={t("dismiss")}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/renderer/src/pages/notifications/notification-item.scss
Normal file
120
src/renderer/src/pages/notifications/notification-item.scss
Normal file
@@ -0,0 +1,120 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
background-color: globals.$dark-background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 8px;
|
||||
transition: all ease 0.2s;
|
||||
position: relative;
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--unread {
|
||||
border-left: 3px solid globals.$brand-teal;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-item__title {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&__picture {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: globals.$background-color;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge-picture {
|
||||
border-radius: 8px;
|
||||
background-color: globals.$background-color;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-picture {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: globals.$body-font-size;
|
||||
font-weight: 600;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: globals.$small-font-size;
|
||||
color: globals.$body-color;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
font-size: globals.$small-font-size;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__dismiss {
|
||||
position: absolute;
|
||||
top: calc(globals.$spacing-unit / 2);
|
||||
right: calc(globals.$spacing-unit / 2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
padding: calc(globals.$spacing-unit / 2);
|
||||
border-radius: 50%;
|
||||
transition: all ease 0.2s;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
227
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
227
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
XIcon,
|
||||
PersonIcon,
|
||||
ClockIcon,
|
||||
StarFillIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDate, useUserDetails } from "@renderer/hooks";
|
||||
import cn from "classnames";
|
||||
|
||||
import type { Notification, Badge } from "@types";
|
||||
import "./notification-item.scss";
|
||||
|
||||
const parseNotificationUrl = (notificationUrl: string): string => {
|
||||
const url = new URL(notificationUrl, "http://localhost");
|
||||
const userId = url.searchParams.get("userId");
|
||||
const badgeName = url.searchParams.get("name");
|
||||
const gameTitle = url.searchParams.get("title");
|
||||
const showReviews = url.searchParams.get("reviews");
|
||||
|
||||
if (url.pathname === "/profile" && userId) {
|
||||
return `/profile/${userId}`;
|
||||
}
|
||||
|
||||
if (url.pathname === "/badges" && badgeName) {
|
||||
return `/badges/${badgeName}`;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/game/")) {
|
||||
const params = new URLSearchParams();
|
||||
if (gameTitle) params.set("title", gameTitle);
|
||||
if (showReviews) params.set("reviews", showReviews);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
|
||||
}
|
||||
|
||||
return notificationUrl;
|
||||
};
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
badges: Badge[];
|
||||
onDismiss: (id: string) => void;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
onAcceptFriendRequest?: (senderId: string) => void;
|
||||
onRefuseFriendRequest?: (senderId: string) => void;
|
||||
}
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
badges,
|
||||
onDismiss,
|
||||
onMarkAsRead,
|
||||
onAcceptFriendRequest,
|
||||
onRefuseFriendRequest,
|
||||
}: Readonly<NotificationItemProps>) {
|
||||
const { t } = useTranslation("notifications_page");
|
||||
const { formatDistance } = useDate();
|
||||
const navigate = useNavigate();
|
||||
const { updateFriendRequestState } = useUserDetails();
|
||||
|
||||
const badge = useMemo(() => {
|
||||
if (notification.type !== "BADGE_RECEIVED") return null;
|
||||
return badges.find((b) => b.name === notification.variables.badgeName);
|
||||
}, [notification, badges]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!notification.isRead) {
|
||||
onMarkAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.url) {
|
||||
navigate(parseNotificationUrl(notification.url));
|
||||
}
|
||||
}, [notification, onMarkAsRead, navigate]);
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const senderId = notification.variables.senderId;
|
||||
if (senderId) {
|
||||
await updateFriendRequestState(senderId, "ACCEPTED");
|
||||
onAcceptFriendRequest?.(senderId);
|
||||
onDismiss(notification.id);
|
||||
}
|
||||
},
|
||||
[notification, updateFriendRequestState, onAcceptFriendRequest, onDismiss]
|
||||
);
|
||||
|
||||
const handleRefuse = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const senderId = notification.variables.senderId;
|
||||
if (senderId) {
|
||||
await updateFriendRequestState(senderId, "REFUSED");
|
||||
onRefuseFriendRequest?.(senderId);
|
||||
onDismiss(notification.id);
|
||||
}
|
||||
},
|
||||
[notification, updateFriendRequestState, onRefuseFriendRequest, onDismiss]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(notification.id);
|
||||
},
|
||||
[notification.id, onDismiss]
|
||||
);
|
||||
|
||||
const getNotificationContent = () => {
|
||||
switch (notification.type) {
|
||||
case "FRIEND_REQUEST_RECEIVED":
|
||||
return {
|
||||
title: t("friend_request_received_title"),
|
||||
description: t("friend_request_received_description", {
|
||||
displayName: notification.variables.senderDisplayName,
|
||||
}),
|
||||
showActions: true,
|
||||
};
|
||||
case "FRIEND_REQUEST_ACCEPTED":
|
||||
return {
|
||||
title: t("friend_request_accepted_title"),
|
||||
description: t("friend_request_accepted_description", {
|
||||
displayName: notification.variables.senderDisplayName,
|
||||
}),
|
||||
showActions: false,
|
||||
};
|
||||
case "BADGE_RECEIVED":
|
||||
return {
|
||||
title: t("badge_received_title"),
|
||||
description: badge?.description || notification.variables.badgeName,
|
||||
showActions: false,
|
||||
};
|
||||
case "REVIEW_UPVOTE":
|
||||
return {
|
||||
title: t("review_upvote_title", {
|
||||
gameTitle: notification.variables.gameTitle,
|
||||
}),
|
||||
description: t("review_upvote_description", {
|
||||
count: Number.parseInt(
|
||||
notification.variables.upvoteCount || "1",
|
||||
10
|
||||
),
|
||||
}),
|
||||
showActions: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: t("notification"),
|
||||
description: "",
|
||||
showActions: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const content = getNotificationContent();
|
||||
const isBadge = notification.type === "BADGE_RECEIVED";
|
||||
const isReview = notification.type === "REVIEW_UPVOTE";
|
||||
|
||||
const getIcon = () => {
|
||||
if (notification.pictureUrl) {
|
||||
return <img src={notification.pictureUrl} alt="" />;
|
||||
}
|
||||
if (isReview) {
|
||||
return <StarFillIcon size={24} />;
|
||||
}
|
||||
return <PersonIcon size={24} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("notification-item", {
|
||||
"notification-item--unread": !notification.isRead,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={cn("notification-item__picture", {
|
||||
"notification-item__badge-picture": isBadge,
|
||||
"notification-item__review-picture": isReview,
|
||||
})}
|
||||
>
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
<div className="notification-item__content">
|
||||
<span className="notification-item__title">{content.title}</span>
|
||||
<span className="notification-item__description">
|
||||
{content.description}
|
||||
</span>
|
||||
<span className="notification-item__time">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(new Date(notification.createdAt), new Date())}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{content.showActions &&
|
||||
notification.type === "FRIEND_REQUEST_RECEIVED" && (
|
||||
<div className="notification-item__actions">
|
||||
<Button theme="primary" onClick={handleAccept}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
<Button theme="outline" onClick={handleRefuse}>
|
||||
{t("refuse")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification.type !== "FRIEND_REQUEST_RECEIVED" && (
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item__dismiss"
|
||||
onClick={handleDismiss}
|
||||
title={t("dismiss")}
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/renderer/src/pages/notifications/notifications.scss
Normal file
60
src/renderer/src/pages/notifications/notifications.scss
Normal file
@@ -0,0 +1,60 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.notifications {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 3);
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 6);
|
||||
text-align: center;
|
||||
color: globals.$body-color;
|
||||
|
||||
&-icon {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: globals.$body-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: calc(globals.$spacing-unit * 4);
|
||||
}
|
||||
|
||||
&__load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
392
src/renderer/src/pages/notifications/notifications.tsx
Normal file
392
src/renderer/src/pages/notifications/notifications.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BellIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useAppDispatch, useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import { LocalNotificationItem } from "./local-notification-item";
|
||||
import type {
|
||||
Notification,
|
||||
LocalNotification,
|
||||
NotificationsResponse,
|
||||
MergedNotification,
|
||||
Badge,
|
||||
} from "@types";
|
||||
import "./notifications.scss";
|
||||
|
||||
export default function Notifications() {
|
||||
const { t, i18n } = useTranslation("notifications_page");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { userDetails } = useUserDetails();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setHeaderTitle(t("title")));
|
||||
}, [dispatch, t]);
|
||||
|
||||
const [apiNotifications, setApiNotifications] = useState<Notification[]>([]);
|
||||
const [localNotifications, setLocalNotifications] = useState<
|
||||
LocalNotification[]
|
||||
>([]);
|
||||
const [badges, setBadges] = useState<Badge[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set());
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
const fetchLocalNotifications = useCallback(async () => {
|
||||
try {
|
||||
const notifications = await window.electron.getLocalNotifications();
|
||||
setLocalNotifications(notifications);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch local notifications", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchBadges = useCallback(async () => {
|
||||
try {
|
||||
const language = i18n.language.split("-")[0];
|
||||
const params = new URLSearchParams({ locale: language });
|
||||
const badgesResponse = await window.electron.hydraApi.get<Badge[]>(
|
||||
`/badges?${params.toString()}`,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
setBadges(badgesResponse);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch badges", error);
|
||||
}
|
||||
}, [i18n.language]);
|
||||
|
||||
const fetchApiNotifications = useCallback(
|
||||
async (skip = 0, append = false) => {
|
||||
if (!userDetails) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response =
|
||||
await window.electron.hydraApi.get<NotificationsResponse>(
|
||||
"/profile/notifications",
|
||||
{
|
||||
params: { filter: "all", take: 20, skip },
|
||||
needsAuth: true,
|
||||
}
|
||||
);
|
||||
|
||||
logger.log("Notifications API response:", response);
|
||||
|
||||
if (append) {
|
||||
setApiNotifications((prev) => [...prev, ...response.notifications]);
|
||||
} else {
|
||||
setApiNotifications(response.notifications);
|
||||
}
|
||||
|
||||
setPagination({
|
||||
total: response.pagination.total,
|
||||
hasMore: response.pagination.hasMore,
|
||||
skip: response.pagination.skip + response.pagination.take,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch API notifications", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[userDetails]
|
||||
);
|
||||
|
||||
const fetchAllNotifications = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
await Promise.all([
|
||||
fetchLocalNotifications(),
|
||||
fetchBadges(),
|
||||
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(),
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, [
|
||||
fetchLocalNotifications,
|
||||
fetchBadges,
|
||||
fetchApiNotifications,
|
||||
userDetails,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllNotifications();
|
||||
}, [fetchAllNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLocalNotificationCreated(
|
||||
(notification) => {
|
||||
setLocalNotifications((prev) => [notification, ...prev]);
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const mergedNotifications = useMemo<MergedNotification[]>(() => {
|
||||
const sortByDate = (a: MergedNotification, b: MergedNotification) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
|
||||
// High priority notifications (priority === 1) - keep in API order
|
||||
const highPriority: MergedNotification[] = apiNotifications
|
||||
.filter((n) => n.priority === 1)
|
||||
.map((n) => ({ ...n, source: "api" as const }));
|
||||
|
||||
// Low priority: other API notifications + local notifications, merged and sorted by date
|
||||
const lowPriorityApi: MergedNotification[] = apiNotifications
|
||||
.filter((n) => n.priority !== 1)
|
||||
.map((n) => ({ ...n, source: "api" as const }));
|
||||
|
||||
const localWithSource: MergedNotification[] = localNotifications.map(
|
||||
(n) => ({
|
||||
...n,
|
||||
source: "local" as const,
|
||||
})
|
||||
);
|
||||
|
||||
const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
|
||||
sortByDate
|
||||
);
|
||||
|
||||
return [...highPriority, ...lowPriority];
|
||||
}, [apiNotifications, localNotifications]);
|
||||
|
||||
const displayedNotifications = useMemo(() => {
|
||||
return mergedNotifications.filter((n) => !clearingIds.has(n.id));
|
||||
}, [mergedNotifications, clearingIds]);
|
||||
|
||||
const notifyCountChange = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent("notificationsChanged"));
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (id: string, source: "api" | "local") => {
|
||||
try {
|
||||
if (source === "api") {
|
||||
await window.electron.hydraApi.patch(
|
||||
`/profile/notifications/${id}/read`,
|
||||
{
|
||||
data: { id },
|
||||
needsAuth: true,
|
||||
}
|
||||
);
|
||||
setApiNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
|
||||
);
|
||||
} else {
|
||||
await window.electron.markLocalNotificationRead(id);
|
||||
setLocalNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
|
||||
);
|
||||
}
|
||||
notifyCountChange();
|
||||
} catch (error) {
|
||||
logger.error("Failed to mark notification as read", error);
|
||||
}
|
||||
},
|
||||
[notifyCountChange]
|
||||
);
|
||||
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
// Mark all API notifications as read
|
||||
if (userDetails && apiNotifications.some((n) => !n.isRead)) {
|
||||
await window.electron.hydraApi.patch(
|
||||
`/profile/notifications/all/read`,
|
||||
{ needsAuth: true }
|
||||
);
|
||||
setApiNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, isRead: true }))
|
||||
);
|
||||
}
|
||||
|
||||
// Mark all local notifications as read
|
||||
await window.electron.markAllLocalNotificationsRead();
|
||||
setLocalNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, isRead: true }))
|
||||
);
|
||||
|
||||
notifyCountChange();
|
||||
showSuccessToast(t("marked_all_as_read"));
|
||||
} catch (error) {
|
||||
logger.error("Failed to mark all as read", error);
|
||||
showErrorToast(t("failed_to_mark_as_read"));
|
||||
}
|
||||
}, [
|
||||
apiNotifications,
|
||||
userDetails,
|
||||
showSuccessToast,
|
||||
showErrorToast,
|
||||
t,
|
||||
notifyCountChange,
|
||||
]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (id: string, source: "api" | "local") => {
|
||||
try {
|
||||
if (source === "api") {
|
||||
await window.electron.hydraApi.delete(
|
||||
`/profile/notifications/${id}`,
|
||||
{ needsAuth: true }
|
||||
);
|
||||
setApiNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
setPagination((prev) => ({ ...prev, total: prev.total - 1 }));
|
||||
} else {
|
||||
await window.electron.deleteLocalNotification(id);
|
||||
setLocalNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}
|
||||
notifyCountChange();
|
||||
} catch (error) {
|
||||
logger.error("Failed to dismiss notification", error);
|
||||
showErrorToast(t("failed_to_dismiss"));
|
||||
}
|
||||
},
|
||||
[showErrorToast, t, notifyCountChange]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
try {
|
||||
// Mark all as clearing for animation
|
||||
const allIds = new Set([
|
||||
...apiNotifications.map((n) => n.id),
|
||||
...localNotifications.map((n) => n.id),
|
||||
]);
|
||||
setClearingIds(allIds);
|
||||
|
||||
// Wait for exit animation
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Clear all API notifications
|
||||
if (userDetails && apiNotifications.length > 0) {
|
||||
await window.electron.hydraApi.delete(`/profile/notifications/all`, {
|
||||
needsAuth: true,
|
||||
});
|
||||
setApiNotifications([]);
|
||||
}
|
||||
|
||||
// Clear all local notifications
|
||||
await window.electron.clearAllLocalNotifications();
|
||||
setLocalNotifications([]);
|
||||
|
||||
setClearingIds(new Set());
|
||||
setPagination({ total: 0, hasMore: false, skip: 0 });
|
||||
notifyCountChange();
|
||||
showSuccessToast(t("cleared_all"));
|
||||
} catch (error) {
|
||||
logger.error("Failed to clear all notifications", error);
|
||||
setClearingIds(new Set());
|
||||
showErrorToast(t("failed_to_clear"));
|
||||
}
|
||||
}, [
|
||||
apiNotifications,
|
||||
localNotifications,
|
||||
userDetails,
|
||||
showSuccessToast,
|
||||
showErrorToast,
|
||||
t,
|
||||
notifyCountChange,
|
||||
]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (pagination.hasMore && !isLoading) {
|
||||
fetchApiNotifications(pagination.skip, true);
|
||||
}
|
||||
}, [pagination, isLoading, fetchApiNotifications]);
|
||||
|
||||
const handleAcceptFriendRequest = useCallback(() => {
|
||||
showSuccessToast(t("friend_request_accepted"));
|
||||
}, [showSuccessToast, t]);
|
||||
|
||||
const handleRefuseFriendRequest = useCallback(() => {
|
||||
showSuccessToast(t("friend_request_refused"));
|
||||
}, [showSuccessToast, t]);
|
||||
|
||||
const renderNotification = (notification: MergedNotification) => {
|
||||
const key =
|
||||
notification.source === "local"
|
||||
? `local-${notification.id}`
|
||||
: `api-${notification.id}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={key}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{notification.source === "local" ? (
|
||||
<LocalNotificationItem
|
||||
notification={notification}
|
||||
onDismiss={(id) => handleDismiss(id, "local")}
|
||||
onMarkAsRead={(id) => handleMarkAsRead(id, "local")}
|
||||
/>
|
||||
) : (
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
badges={badges}
|
||||
onDismiss={(id) => handleDismiss(id, "api")}
|
||||
onMarkAsRead={(id) => handleMarkAsRead(id, "api")}
|
||||
onAcceptFriendRequest={handleAcceptFriendRequest}
|
||||
onRefuseFriendRequest={handleRefuseFriendRequest}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notifications">
|
||||
<div className="notifications__actions">
|
||||
<Button theme="outline" onClick={handleMarkAllAsRead}>
|
||||
{t("mark_all_as_read")}
|
||||
</Button>
|
||||
<Button theme="danger" onClick={handleClearAll}>
|
||||
{t("clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && mergedNotifications.length === 0 ? (
|
||||
<div className="notifications__loading">
|
||||
<span>{t("loading")}</span>
|
||||
</div>
|
||||
) : mergedNotifications.length === 0 ? (
|
||||
<div className="notifications__empty">
|
||||
<BellIcon size={48} className="notifications__empty-icon" />
|
||||
<span className="notifications__empty-title">{t("empty_title")}</span>
|
||||
<span className="notifications__empty-description">
|
||||
{t("empty_description")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="notifications__list">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayedNotifications.map(renderNotification)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{pagination.hasMore && (
|
||||
<div className="notifications__load-more">
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t("loading") : t("load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.all-friends-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
max-height: 400px;
|
||||
margin-top: calc(globals.$spacing-unit * -1);
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__count {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
overflow-y: auto;
|
||||
padding-right: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$body-font-size;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__game {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
font-size: globals.$small-font-size;
|
||||
color: globals.$body-color;
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: calc(globals.$spacing-unit * 4);
|
||||
color: globals.$body-color;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
padding: globals.$spacing-unit;
|
||||
border-radius: 50%;
|
||||
transition: all ease 0.2s;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: globals.$error-color;
|
||||
background-color: rgba(globals.$error-color, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { XCircleIcon } from "@primer/octicons-react";
|
||||
import { Modal, Avatar, Button } from "@renderer/components";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { logger } from "@renderer/logger";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import type { UserFriend } from "@types";
|
||||
import "./all-friends-modal.scss";
|
||||
|
||||
interface AllFriendsModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
userId: string;
|
||||
isMe: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function AllFriendsModal({
|
||||
visible,
|
||||
onClose,
|
||||
userId,
|
||||
isMe,
|
||||
}: AllFriendsModalProps) {
|
||||
const { t } = useTranslation("user_profile");
|
||||
const navigate = useNavigate();
|
||||
const { undoFriendship } = useUserDetails();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [friends, setFriends] = useState<UserFriend[]>([]);
|
||||
const [totalFriends, setTotalFriends] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchFriends = useCallback(
|
||||
async (pageNum: number, append = false) => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const url = isMe ? "/profile/friends" : `/users/${userId}/friends`;
|
||||
const response = await window.electron.hydraApi.get<{
|
||||
totalFriends: number;
|
||||
friends: UserFriend[];
|
||||
}>(url, {
|
||||
params: { take: PAGE_SIZE, skip: pageNum * PAGE_SIZE },
|
||||
});
|
||||
|
||||
if (append) {
|
||||
setFriends((prev) => [...prev, ...response.friends]);
|
||||
} else {
|
||||
setFriends(response.friends);
|
||||
}
|
||||
|
||||
setTotalFriends(response.totalFriends);
|
||||
setHasMore((pageNum + 1) * PAGE_SIZE < response.totalFriends);
|
||||
setPage(pageNum + 1);
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch friends", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[userId, isMe, isLoading]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setFriends([]);
|
||||
setPage(0);
|
||||
setHasMore(true);
|
||||
fetchFriends(0, false);
|
||||
}
|
||||
}, [visible, userId]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current || isLoading || !hasMore) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||||
fetchFriends(page, true);
|
||||
}
|
||||
}, [isLoading, hasMore, page, fetchFriends]);
|
||||
|
||||
const handleFriendClick = (friendId: string) => {
|
||||
onClose();
|
||||
navigate(`/profile/${friendId}`);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!isLoading && hasMore) {
|
||||
fetchFriends(page, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFriend = useCallback(
|
||||
async (e: React.MouseEvent, friendId: string) => {
|
||||
e.stopPropagation();
|
||||
setRemovingId(friendId);
|
||||
|
||||
try {
|
||||
await undoFriendship(friendId);
|
||||
setFriends((prev) => prev.filter((f) => f.id !== friendId));
|
||||
setTotalFriends((prev) => prev - 1);
|
||||
showSuccessToast(t("friendship_removed"));
|
||||
} catch (error) {
|
||||
logger.error("Failed to remove friend", error);
|
||||
showErrorToast(t("try_again"));
|
||||
} finally {
|
||||
setRemovingId(null);
|
||||
}
|
||||
},
|
||||
[undoFriendship, showSuccessToast, showErrorToast, t]
|
||||
);
|
||||
|
||||
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
|
||||
if (game.iconUrl) {
|
||||
return <img alt={game.title} width={16} height={16} src={game.iconUrl} />;
|
||||
}
|
||||
return <SteamLogo width={16} height={16} />;
|
||||
};
|
||||
|
||||
const modalTitle = (
|
||||
<div className="all-friends-modal__title">
|
||||
{t("friends")}
|
||||
{totalFriends > 0 && (
|
||||
<span className="all-friends-modal__count">{totalFriends}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={modalTitle} onClose={onClose}>
|
||||
<div className="all-friends-modal">
|
||||
{friends.length === 0 && !isLoading ? (
|
||||
<div className="all-friends-modal__empty">
|
||||
{t("no_friends_added")}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="all-friends-modal__list"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{friends.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="all-friends-modal__item"
|
||||
onClick={() => handleFriendClick(friend.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Avatar
|
||||
size={40}
|
||||
src={friend.profileImageUrl}
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
<div className="all-friends-modal__info">
|
||||
<span className="all-friends-modal__name">
|
||||
{friend.displayName}
|
||||
</span>
|
||||
{friend.currentGame && (
|
||||
<div className="all-friends-modal__game">
|
||||
{getGameImage(friend.currentGame)}
|
||||
<small>{friend.currentGame.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="all-friends-modal__remove"
|
||||
onClick={(e) => handleRemoveFriend(e, friend.id)}
|
||||
disabled={removingId === friend.id}
|
||||
title={t("undo_friendship")}
|
||||
>
|
||||
<XCircleIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="all-friends-modal__loading">{t("loading")}...</div>
|
||||
)}
|
||||
|
||||
{hasMore && !isLoading && friends.length > 0 && (
|
||||
<div className="all-friends-modal__load-more">
|
||||
<Button theme="outline" onClick={handleLoadMore}>
|
||||
{t("load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.badges-box {
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__box {
|
||||
background-color: globals.$background-color;
|
||||
border-radius: 4px;
|
||||
border: solid 1px globals.$border-color;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import "./badges-box.scss";
|
||||
|
||||
export function BadgesBox() {
|
||||
const { userProfile, badges } = useContext(userProfileContext);
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
if (!userProfile?.badges.length) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="badges-box__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("badges")}</h2>
|
||||
<span className="profile-content__section-badge">
|
||||
{numberFormatter.format(userProfile.badges.length)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="badges-box__box">
|
||||
<div className="badges-box__list">
|
||||
{userProfile.badges.map((badgeName) => {
|
||||
const badge = badges.find((b) => b.name === badgeName);
|
||||
|
||||
if (!badge) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={badge.name}
|
||||
className="badges-box__item"
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={badge.description}
|
||||
data-tooltip-id="badges-box-tooltip"
|
||||
>
|
||||
<img
|
||||
src={badge.badge.url}
|
||||
alt={badge.name}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Tooltip id="badges-box-tooltip" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,4 +63,19 @@
|
||||
&__game-image {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__view-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
font-size: globals.$small-font-size;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
transition: color ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useContext } from "react";
|
||||
import { useFormat, useUserDetails } from "@renderer/hooks";
|
||||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { Avatar, Link } from "@renderer/components";
|
||||
import { AllFriendsModal } from "./all-friends-modal";
|
||||
import "./friends-box.scss";
|
||||
|
||||
export function FriendsBox() {
|
||||
const { userProfile, userStats } = useContext(userProfileContext);
|
||||
const { userDetails } = useUserDetails();
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
const [showAllFriendsModal, setShowAllFriendsModal] = useState(false);
|
||||
|
||||
const isMe = userDetails?.id === userProfile?.id;
|
||||
|
||||
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
|
||||
if (game.iconUrl) {
|
||||
@@ -29,55 +34,73 @@ export function FriendsBox() {
|
||||
if (!userProfile?.friends.length) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="friends-box__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("friends")}</h2>
|
||||
{userStats && (
|
||||
<span className="profile-content__section-badge">
|
||||
{numberFormatter.format(userStats.friendsCount)}
|
||||
</span>
|
||||
)}
|
||||
<>
|
||||
<div>
|
||||
<div className="friends-box__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("friends")}</h2>
|
||||
{userStats && (
|
||||
<span className="profile-content__section-badge">
|
||||
{numberFormatter.format(userStats.friendsCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="friends-box__view-all"
|
||||
onClick={() => setShowAllFriendsModal(true)}
|
||||
>
|
||||
{t("view_all")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="friends-box__box">
|
||||
<ul className="friends-box__list">
|
||||
{userProfile?.friends.map((friend) => (
|
||||
<li
|
||||
key={friend.id}
|
||||
title={
|
||||
friend.currentGame
|
||||
? t("playing", { game: friend.currentGame.title })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`/profile/${friend.id}`}
|
||||
className="friends-box__list-item"
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={friend.profileImageUrl}
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
|
||||
<div className="friends-box__friend-details">
|
||||
<span className="friends-box__friend-name">
|
||||
{friend.displayName}
|
||||
</span>
|
||||
{friend.currentGame && (
|
||||
<div className="friends-box__game-info">
|
||||
{getGameImage(friend.currentGame)}
|
||||
<small>{friend.currentGame.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="friends-box__box">
|
||||
<ul className="friends-box__list">
|
||||
{userProfile?.friends.map((friend) => (
|
||||
<li
|
||||
key={friend.id}
|
||||
title={
|
||||
friend.currentGame
|
||||
? t("playing", { game: friend.currentGame.title })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`/profile/${friend.id}`}
|
||||
className="friends-box__list-item"
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={friend.profileImageUrl}
|
||||
alt={friend.displayName}
|
||||
/>
|
||||
|
||||
<div className="friends-box__friend-details">
|
||||
<span className="friends-box__friend-name">
|
||||
{friend.displayName}
|
||||
</span>
|
||||
{friend.currentGame && (
|
||||
<div className="friends-box__game-info">
|
||||
{getGameImage(friend.currentGame)}
|
||||
<small>{friend.currentGame.title}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{userProfile && (
|
||||
<AllFriendsModal
|
||||
visible={showAllFriendsModal}
|
||||
onClose={() => setShowAllFriendsModal(false)}
|
||||
userId={userProfile.id}
|
||||
isMe={isMe}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { GameShop } from "@types";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
import { ReportProfile } from "../report-profile/report-profile";
|
||||
import { BadgesBox } from "./badges-box";
|
||||
import { FriendsBox } from "./friends-box";
|
||||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
@@ -429,6 +430,7 @@ export function ProfileContent() {
|
||||
<UserStatsBox />
|
||||
<UserKarmaBox />
|
||||
<RecentGamesBox />
|
||||
<BadgesBox />
|
||||
<FriendsBox />
|
||||
<ReportProfile />
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__badges {
|
||||
&__copy-button {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
padding: calc(globals.$spacing-unit / 2);
|
||||
border-radius: 4px;
|
||||
transition: all ease 0.2s;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__user-information {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { userProfileContext } from "@renderer/context";
|
||||
import {
|
||||
BlockedIcon,
|
||||
CheckCircleFillIcon,
|
||||
CopyIcon,
|
||||
PencilIcon,
|
||||
PersonAddIcon,
|
||||
SignOutIcon,
|
||||
@@ -24,7 +25,6 @@ import type { FriendRequestAction } from "@types";
|
||||
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import "./profile-hero.scss";
|
||||
|
||||
type FriendAction =
|
||||
@@ -35,14 +35,8 @@ export function ProfileHero() {
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||
|
||||
const {
|
||||
isMe,
|
||||
badges,
|
||||
getUserProfile,
|
||||
userProfile,
|
||||
heroBackground,
|
||||
backgroundImage,
|
||||
} = useContext(userProfileContext);
|
||||
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
|
||||
useContext(userProfileContext);
|
||||
const {
|
||||
signOut,
|
||||
updateFriendRequestState,
|
||||
@@ -251,6 +245,13 @@ export function ProfileHero() {
|
||||
}
|
||||
}, [isMe]);
|
||||
|
||||
const copyFriendCode = useCallback(() => {
|
||||
if (userProfile?.id) {
|
||||
navigator.clipboard.writeText(userProfile.id);
|
||||
showSuccessToast(t("friend_code_copied"));
|
||||
}
|
||||
}, [userProfile, showSuccessToast, t]);
|
||||
|
||||
const currentGame = useMemo(() => {
|
||||
if (isMe) {
|
||||
if (gameRunning)
|
||||
@@ -311,28 +312,14 @@ export function ProfileHero() {
|
||||
{userProfile?.displayName}
|
||||
</h2>
|
||||
|
||||
<div className="profile-hero__badges">
|
||||
{userProfile.badges.map((badgeName) => {
|
||||
const badge = badges.find((b) => b.name === badgeName);
|
||||
|
||||
if (!badge) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
key={badge.name}
|
||||
src={badge.badge.url}
|
||||
alt={badge.name}
|
||||
width={24}
|
||||
height={24}
|
||||
data-tooltip-place="top"
|
||||
data-tooltip-content={badge.description}
|
||||
data-tooltip-id="badge-name"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Tooltip id="badge-name" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-hero__copy-button"
|
||||
onClick={copyFriendCode}
|
||||
title={t("copy_friend_code")}
|
||||
>
|
||||
<CopyIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton width={150} height={28} />
|
||||
|
||||
@@ -144,6 +144,10 @@ export interface FriendRequestSync {
|
||||
friendRequestCount: number;
|
||||
}
|
||||
|
||||
export interface NotificationSync {
|
||||
notificationCount: number;
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -312,6 +316,58 @@ export interface GameArtifact {
|
||||
isFrozen: boolean;
|
||||
}
|
||||
|
||||
export type NotificationType =
|
||||
| "FRIEND_REQUEST_RECEIVED"
|
||||
| "FRIEND_REQUEST_ACCEPTED"
|
||||
| "BADGE_RECEIVED"
|
||||
| "REVIEW_UPVOTE";
|
||||
|
||||
export type LocalNotificationType =
|
||||
| "EXTRACTION_COMPLETE"
|
||||
| "DOWNLOAD_COMPLETE"
|
||||
| "UPDATE_AVAILABLE"
|
||||
| "ACHIEVEMENT_UNLOCKED";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
variables: Record<string, string>;
|
||||
pictureUrl: string | null;
|
||||
url: string | null;
|
||||
isRead: boolean;
|
||||
priority: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LocalNotification {
|
||||
id: string;
|
||||
type: LocalNotificationType;
|
||||
title: string;
|
||||
description: string;
|
||||
pictureUrl: string | null;
|
||||
url: string | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type MergedNotification =
|
||||
| (Notification & { source: "api" })
|
||||
| (LocalNotification & { source: "local" });
|
||||
|
||||
export interface NotificationsResponse {
|
||||
notifications: Notification[];
|
||||
pagination: {
|
||||
total: number;
|
||||
take: number;
|
||||
skip: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationCountResponse {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ComparedAchievements {
|
||||
achievementsPointsTotal: number;
|
||||
owner: {
|
||||
|
||||
Reference in New Issue
Block a user