mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Merge branch 'main' into feat/disabling-update-badges
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"game_has_no_executable": "Game has no executable selected",
|
"game_has_no_executable": "Game has no executable selected",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
|
"notifications": "Notifications",
|
||||||
"need_help": "Need help?",
|
"need_help": "Need help?",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"playable_button_title": "Show only games you can play now",
|
"playable_button_title": "Show only games you can play now",
|
||||||
@@ -663,6 +664,7 @@
|
|||||||
"sending": "Sending",
|
"sending": "Sending",
|
||||||
"friend_request_sent": "Friend request sent",
|
"friend_request_sent": "Friend request sent",
|
||||||
"friends": "Friends",
|
"friends": "Friends",
|
||||||
|
"badges": "Badges",
|
||||||
"friends_list": "Friends list",
|
"friends_list": "Friends list",
|
||||||
"user_not_found": "User not found",
|
"user_not_found": "User not found",
|
||||||
"block_user": "Block user",
|
"block_user": "Block user",
|
||||||
@@ -673,12 +675,16 @@
|
|||||||
"ignore_request": "Ignore request",
|
"ignore_request": "Ignore request",
|
||||||
"cancel_request": "Cancel request",
|
"cancel_request": "Cancel request",
|
||||||
"undo_friendship": "Undo friendship",
|
"undo_friendship": "Undo friendship",
|
||||||
|
"friendship_removed": "Friend removed",
|
||||||
"request_accepted": "Request accepted",
|
"request_accepted": "Request accepted",
|
||||||
"user_blocked_successfully": "User blocked successfully",
|
"user_blocked_successfully": "User blocked successfully",
|
||||||
"user_block_modal_text": "This will block {{displayName}}",
|
"user_block_modal_text": "This will block {{displayName}}",
|
||||||
"blocked_users": "Blocked users",
|
"blocked_users": "Blocked users",
|
||||||
"unblock": "Unblock",
|
"unblock": "Unblock",
|
||||||
"no_friends_added": "You have no added friends",
|
"no_friends_added": "You have no added friends",
|
||||||
|
"view_all": "View all",
|
||||||
|
"load_more": "Load more",
|
||||||
|
"loading": "Loading",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"no_pending_invites": "You have no pending invites",
|
"no_pending_invites": "You have no pending invites",
|
||||||
"no_blocked_users": "You have no blocked users",
|
"no_blocked_users": "You have no blocked users",
|
||||||
@@ -702,6 +708,7 @@
|
|||||||
"report_reason_other": "Other",
|
"report_reason_other": "Other",
|
||||||
"profile_reported": "Profile reported",
|
"profile_reported": "Profile reported",
|
||||||
"your_friend_code": "Your friend code:",
|
"your_friend_code": "Your friend code:",
|
||||||
|
"copy_friend_code": "Copy friend code",
|
||||||
"upload_banner": "Upload banner",
|
"upload_banner": "Upload banner",
|
||||||
"uploading_banner": "Uploading banner…",
|
"uploading_banner": "Uploading banner…",
|
||||||
"background_image_updated": "Background image updated",
|
"background_image_updated": "Background image updated",
|
||||||
@@ -778,5 +785,40 @@
|
|||||||
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
"hydra_cloud_feature_found": "You've just discovered a Hydra Cloud feature!",
|
||||||
"learn_more": "Learn More",
|
"learn_more": "Learn More",
|
||||||
"debrid_description": "Download up to 4x faster with Nimbus"
|
"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 "./publish-new-repacks-notification";
|
||||||
import "./show-achievement-test-notification";
|
import "./show-achievement-test-notification";
|
||||||
import "./update-achievement-notification-window";
|
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,3 @@
|
|||||||
import "./get-me";
|
import "./get-me";
|
||||||
import "./process-profile-image";
|
import "./process-profile-image";
|
||||||
import "./sync-friend-requests";
|
|
||||||
import "./update-profile";
|
import "./update-profile";
|
||||||
|
|||||||
@@ -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<FriendRequestSync>(`/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);
|
|
||||||
@@ -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)
|
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||||
// tslint:disable
|
// tslint:disable
|
||||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||||
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
|
|||||||
*/
|
*/
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
* @generated from protobuf field: int32 friend_request_count = 1
|
||||||
*/
|
*/
|
||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: optional string sender_id = 2;
|
* @generated from protobuf field: optional string sender_id = 2
|
||||||
*/
|
*/
|
||||||
senderId?: string;
|
senderId?: string;
|
||||||
}
|
}
|
||||||
@@ -28,18 +28,27 @@ export interface FriendRequest {
|
|||||||
*/
|
*/
|
||||||
export interface FriendGameSession {
|
export interface FriendGameSession {
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string object_id = 1;
|
* @generated from protobuf field: string object_id = 1
|
||||||
*/
|
*/
|
||||||
objectId: string;
|
objectId: string;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string shop = 2;
|
* @generated from protobuf field: string shop = 2
|
||||||
*/
|
*/
|
||||||
shop: string;
|
shop: string;
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: string friend_id = 3;
|
* @generated from protobuf field: string friend_id = 3
|
||||||
*/
|
*/
|
||||||
friendId: string;
|
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
|
* @generated from protobuf message Envelope
|
||||||
*/
|
*/
|
||||||
@@ -51,17 +60,24 @@ export interface Envelope {
|
|||||||
| {
|
| {
|
||||||
oneofKind: "friendRequest";
|
oneofKind: "friendRequest";
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
* @generated from protobuf field: FriendRequest friend_request = 1
|
||||||
*/
|
*/
|
||||||
friendRequest: FriendRequest;
|
friendRequest: FriendRequest;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
oneofKind: "friendGameSession";
|
oneofKind: "friendGameSession";
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
* @generated from protobuf field: FriendGameSession friend_game_session = 2
|
||||||
*/
|
*/
|
||||||
friendGameSession: FriendGameSession;
|
friendGameSession: FriendGameSession;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
oneofKind: "notification";
|
||||||
|
/**
|
||||||
|
* @generated from protobuf field: Notification notification = 3
|
||||||
|
*/
|
||||||
|
notification: Notification;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
oneofKind: undefined;
|
oneofKind: undefined;
|
||||||
};
|
};
|
||||||
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
|||||||
*/
|
*/
|
||||||
export const FriendGameSession = new FriendGameSession$Type();
|
export const FriendGameSession = new FriendGameSession$Type();
|
||||||
// @generated message type with reflection information, may provide speed optimized methods
|
// @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> {
|
class Envelope$Type extends MessageType<Envelope> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Envelope", [
|
super("Envelope", [
|
||||||
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
oneof: "payload",
|
oneof: "payload",
|
||||||
T: () => FriendGameSession,
|
T: () => FriendGameSession,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
no: 3,
|
||||||
|
name: "notification",
|
||||||
|
kind: "message",
|
||||||
|
oneof: "payload",
|
||||||
|
T: () => Notification,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
create(value?: PartialMessage<Envelope>): Envelope {
|
create(value?: PartialMessage<Envelope>): Envelope {
|
||||||
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case /* Notification notification */ 3:
|
||||||
|
message.payload = {
|
||||||
|
oneofKind: "notification",
|
||||||
|
notification: Notification.internalBinaryRead(
|
||||||
|
reader,
|
||||||
|
reader.uint32(),
|
||||||
|
options,
|
||||||
|
(message.payload as any).notification
|
||||||
|
),
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
let u = options.readUnknownField;
|
let u = options.readUnknownField;
|
||||||
if (u === "throw")
|
if (u === "throw")
|
||||||
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
|||||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||||
options
|
options
|
||||||
).join();
|
).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;
|
let u = options.writeUnknownFields;
|
||||||
if (u !== false)
|
if (u !== false)
|
||||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
|||||||
export * from "./themes";
|
export * from "./themes";
|
||||||
export * from "./download-sources";
|
export * from "./download-sources";
|
||||||
export * from "./downloadSourcesCheckTimestamp";
|
export * from "./downloadSourcesCheckTimestamp";
|
||||||
|
export * from "./local-notifications";
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export const levelKeys = {
|
|||||||
downloadSources: "downloadSources",
|
downloadSources: "downloadSources",
|
||||||
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||||
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
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",
|
||||||
|
});
|
||||||
@@ -29,20 +29,23 @@ import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
|||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
private static downloadingGameId: string | null = null;
|
private static downloadingGameId: string | null = null;
|
||||||
|
|
||||||
private static extractFilename(url: string, originalUrl?: string): string | undefined {
|
private static extractFilename(
|
||||||
if (originalUrl?.includes('#')) {
|
url: string,
|
||||||
const hashPart = originalUrl.split('#')[1];
|
originalUrl?: string
|
||||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
): string | undefined {
|
||||||
|
if (originalUrl?.includes("#")) {
|
||||||
|
const hashPart = originalUrl.split("#")[1];
|
||||||
|
if (hashPart && !hashPart.startsWith("http")) return hashPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.includes('#')) {
|
if (url.includes("#")) {
|
||||||
const hashPart = url.split('#')[1];
|
const hashPart = url.split("#")[1];
|
||||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
if (hashPart && !hashPart.startsWith("http")) return hashPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const filename = urlObj.pathname.split('/').pop();
|
const filename = urlObj.pathname.split("/").pop();
|
||||||
if (filename?.length) return filename;
|
if (filename?.length) return filename;
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid URL
|
// Invalid URL
|
||||||
@@ -52,13 +55,20 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static sanitizeFilename(filename: string): string {
|
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 filename = this.extractFilename(directUrl, originalUrl);
|
||||||
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
|
const sanitizedFilename = filename
|
||||||
|
? this.sanitizeFilename(filename)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (sanitizedFilename) {
|
if (sanitizedFilename) {
|
||||||
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||||
}
|
}
|
||||||
@@ -98,7 +108,9 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async getDownloadStatus() {
|
private static async getDownloadStatus() {
|
||||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
|
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||||
|
"/status"
|
||||||
|
);
|
||||||
if (response.data === null || !this.downloadingGameId) return null;
|
if (response.data === null || !this.downloadingGameId) return null;
|
||||||
const downloadId = this.downloadingGameId;
|
const downloadId = this.downloadingGameId;
|
||||||
|
|
||||||
@@ -114,7 +126,8 @@ export class DownloadManager {
|
|||||||
status,
|
status,
|
||||||
} = response.data;
|
} = response.data;
|
||||||
|
|
||||||
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
|
const isDownloadingMetadata =
|
||||||
|
status === LibtorrentStatus.DownloadingMetadata;
|
||||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||||
|
|
||||||
const download = await downloadsSublevel.get(downloadId);
|
const download = await downloadsSublevel.get(downloadId);
|
||||||
@@ -179,7 +192,10 @@ export class DownloadManager {
|
|||||||
if (progress === 1 && download) {
|
if (progress === 1 && download) {
|
||||||
publishDownloadCompleteNotification(game);
|
publishDownloadCompleteNotification(game);
|
||||||
|
|
||||||
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
|
if (
|
||||||
|
userPreferences?.seedAfterDownloadComplete &&
|
||||||
|
download.downloader === Downloader.Torrent
|
||||||
|
) {
|
||||||
await downloadsSublevel.put(gameId, {
|
await downloadsSublevel.put(gameId, {
|
||||||
...download,
|
...download,
|
||||||
status: "seeding",
|
status: "seeding",
|
||||||
@@ -200,13 +216,22 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldExtract) {
|
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();
|
gameFilesManager.extractDownloadedFile();
|
||||||
} else {
|
} else {
|
||||||
gameFilesManager
|
gameFilesManager
|
||||||
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
|
.extractFilesInDirectory(
|
||||||
|
path.join(download.downloadPath, download.folderName!)
|
||||||
|
)
|
||||||
.then(() => gameFilesManager.setExtractionComplete());
|
.then(() => gameFilesManager.setExtractionComplete());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,11 +239,13 @@ export class DownloadManager {
|
|||||||
const downloads = await downloadsSublevel
|
const downloads = await downloadsSublevel
|
||||||
.values()
|
.values()
|
||||||
.all()
|
.all()
|
||||||
.then((games) => sortBy(
|
.then((games) =>
|
||||||
games.filter((game) => game.status === "paused" && game.queued),
|
sortBy(
|
||||||
"timestamp",
|
games.filter((game) => game.status === "paused" && game.queued),
|
||||||
"DESC"
|
"timestamp",
|
||||||
));
|
"DESC"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const [nextItemOnQueue] = downloads;
|
const [nextItemOnQueue] = downloads;
|
||||||
|
|
||||||
@@ -245,7 +272,9 @@ export class DownloadManager {
|
|||||||
|
|
||||||
if (!download) return;
|
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) {
|
if (totalSize < status.fileSize) {
|
||||||
await this.cancelDownload(status.gameId);
|
await this.cancelDownload(status.gameId);
|
||||||
@@ -266,7 +295,10 @@ export class DownloadManager {
|
|||||||
|
|
||||||
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
||||||
await PythonRPC.rpc
|
await PythonRPC.rpc
|
||||||
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
|
.post("/action", {
|
||||||
|
action: "pause",
|
||||||
|
game_id: downloadKey,
|
||||||
|
} as PauseDownloadPayload)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
if (downloadKey === this.downloadingGameId) {
|
if (downloadKey === this.downloadingGameId) {
|
||||||
@@ -357,24 +389,44 @@ export class DownloadManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case Downloader.Buzzheavier: {
|
case Downloader.Buzzheavier: {
|
||||||
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
|
logger.log(
|
||||||
|
`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||||
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
|
logger.error(
|
||||||
|
`[DownloadManager] Error processing Buzzheavier download:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case Downloader.FuckingFast: {
|
case Downloader.FuckingFast: {
|
||||||
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
|
logger.log(
|
||||||
|
`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||||
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
|
logger.error(
|
||||||
|
`[DownloadManager] Error processing FuckingFast download:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -419,7 +471,9 @@ export class DownloadManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case Downloader.Hydra: {
|
case Downloader.Hydra: {
|
||||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
|
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||||
|
download.uri
|
||||||
|
);
|
||||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export class BuzzheavierApi {
|
|||||||
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const baseUrl = url.split("#")[0];
|
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, {
|
await axios.get(baseUrl, {
|
||||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||||
@@ -46,7 +48,9 @@ export class BuzzheavierApi {
|
|||||||
const hxRedirect = headResponse.headers["hx-redirect"];
|
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||||
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||||
if (!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(
|
throw new Error(
|
||||||
"Could not extract download link. File may be deleted or is a directory."
|
"Could not extract download link. File may be deleted or is a directory."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
|||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
|
|
||||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
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) {
|
private static secondsToMilliseconds(seconds: number) {
|
||||||
return seconds * 1000;
|
return seconds * 1000;
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ export * from "./lock";
|
|||||||
export * from "./decky-plugin";
|
export * from "./decky-plugin";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./download-sources-checker";
|
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 { SystemPath } from "../system-path";
|
||||||
import { getThemeSoundPath } from "@main/helpers";
|
import { getThemeSoundPath } from "@main/helpers";
|
||||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||||
|
import { LocalNotificationManager } from "./local-notifications";
|
||||||
|
|
||||||
const getStaticImage = async (path: string) => {
|
const getStaticImage = async (path: string) => {
|
||||||
return processProfileImage(path, "jpg")
|
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) {
|
if (userPreferences?.downloadNotificationsEnabled) {
|
||||||
new Notification({
|
new Notification({
|
||||||
title: t("download_complete", {
|
title,
|
||||||
ns: "notifications",
|
body,
|
||||||
}),
|
|
||||||
body: t("game_ready_to_install", {
|
|
||||||
ns: "notifications",
|
|
||||||
title: game.title,
|
|
||||||
}),
|
|
||||||
icon: await downloadImage(game.iconUrl),
|
icon: await downloadImage(game.iconUrl),
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"DOWNLOAD_COMPLETE",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
pictureUrl: game.iconUrl,
|
||||||
|
url: `/game/${game.shop}/${game.objectId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNotificationUpdateReadyToInstall = async (
|
export const publishNotificationUpdateReadyToInstall = async (
|
||||||
version: string
|
version: string
|
||||||
) => {
|
) => {
|
||||||
|
const title = t("new_update_available", {
|
||||||
|
ns: "notifications",
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
const body = t("restart_to_install_update", {
|
||||||
|
ns: "notifications",
|
||||||
|
});
|
||||||
|
|
||||||
new Notification({
|
new Notification({
|
||||||
title: t("new_update_available", {
|
title,
|
||||||
ns: "notifications",
|
body,
|
||||||
version,
|
|
||||||
}),
|
|
||||||
body: t("restart_to_install_update", {
|
|
||||||
ns: "notifications",
|
|
||||||
}),
|
|
||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
})
|
})
|
||||||
.on("click", () => {
|
.on("click", () => {
|
||||||
restartAndInstallUpdate();
|
restartAndInstallUpdate();
|
||||||
})
|
})
|
||||||
.show();
|
.show();
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"UPDATE_AVAILABLE",
|
||||||
|
title,
|
||||||
|
body
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNewFriendRequestNotification = async (
|
export const publishNewFriendRequestNotification = async (
|
||||||
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
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({
|
new Notification({
|
||||||
title: t("extraction_complete", { ns: "notifications" }),
|
title,
|
||||||
body: t("game_extracted", {
|
body,
|
||||||
ns: "notifications",
|
|
||||||
title: game.title,
|
|
||||||
}),
|
|
||||||
icon: trayIcon,
|
icon: trayIcon,
|
||||||
}).show();
|
}).show();
|
||||||
|
|
||||||
|
// Create local notification
|
||||||
|
await LocalNotificationManager.createNotification(
|
||||||
|
"EXTRACTION_COMPLETE",
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
{
|
||||||
|
url: `/game/${game.shop}/${game.objectId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const publishNewAchievementNotification = async (info: {
|
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 { logger } from "../logger";
|
||||||
import { friendRequestEvent } from "./events/friend-request";
|
import { friendRequestEvent } from "./events/friend-request";
|
||||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||||
|
import { notificationEvent } from "./events/notification";
|
||||||
|
|
||||||
export class WSClient {
|
export class WSClient {
|
||||||
private static ws: WebSocket | null = null;
|
private static ws: WebSocket | null = null;
|
||||||
@@ -51,6 +52,10 @@ export class WSClient {
|
|||||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envelope.payload.oneofKind === "notification") {
|
||||||
|
notificationEvent(envelope.payload.notification);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
GameAchievement,
|
GameAchievement,
|
||||||
Theme,
|
Theme,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
ShortcutLocation,
|
ShortcutLocation,
|
||||||
AchievementCustomNotificationPosition,
|
AchievementCustomNotificationPosition,
|
||||||
AchievementNotificationInfo,
|
AchievementNotificationInfo,
|
||||||
@@ -497,7 +498,6 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
ipcRenderer.invoke("updateProfile", updateProfile),
|
ipcRenderer.invoke("updateProfile", updateProfile),
|
||||||
processProfileImage: (imagePath: string) =>
|
processProfileImage: (imagePath: string) =>
|
||||||
ipcRenderer.invoke("processProfileImage", imagePath),
|
ipcRenderer.invoke("processProfileImage", imagePath),
|
||||||
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
|
|
||||||
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
|
||||||
const listener = (
|
const listener = (
|
||||||
_event: Electron.IpcRendererEvent,
|
_event: Electron.IpcRendererEvent,
|
||||||
@@ -507,6 +507,15 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
return () =>
|
return () =>
|
||||||
ipcRenderer.removeListener("on-sync-friend-requests", listener);
|
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) =>
|
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
|
||||||
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
ipcRenderer.invoke("updateFriendRequest", userId, action),
|
||||||
|
|
||||||
@@ -550,6 +559,26 @@ contextBridge.exposeInMainWorld("electron", {
|
|||||||
/* Notifications */
|
/* Notifications */
|
||||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
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: (
|
onAchievementUnlocked: (
|
||||||
cb: (
|
cb: (
|
||||||
position?: AchievementCustomNotificationPosition,
|
position?: AchievementCustomNotificationPosition,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
clearExtraction,
|
clearExtraction,
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
|
||||||
import { useSubscription } from "./hooks/use-subscription";
|
import { useSubscription } from "./hooks/use-subscription";
|
||||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||||
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
|
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
|
||||||
@@ -54,12 +53,7 @@ export function App() {
|
|||||||
const { clearDownload, setLastPacket } = useDownload();
|
const { clearDownload, setLastPacket } = useDownload();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userDetails,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
isFriendsModalVisible,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
friendModalUserId,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
updateUserDetails,
|
updateUserDetails,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
@@ -135,7 +129,6 @@ export function App() {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
updateUserDetails(response);
|
updateUserDetails(response);
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -152,7 +145,6 @@ export function App() {
|
|||||||
fetchUserDetails().then((response) => {
|
fetchUserDetails().then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
updateUserDetails(response);
|
updateUserDetails(response);
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
showSuccessToast(t("successfully_signed_in"));
|
showSuccessToast(t("successfully_signed_in"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -305,15 +297,6 @@ export function App() {
|
|||||||
onClose={() => setShowArchiveDeletionModal(false)}
|
onClose={() => setShowArchiveDeletionModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{userDetails && (
|
|
||||||
<UserFriendModal
|
|
||||||
visible={isFriendsModalVisible}
|
|
||||||
initialTab={friendRequetsModalTab}
|
|
||||||
onClose={hideFriendsModal}
|
|
||||||
userId={friendModalUserId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export function Header() {
|
|||||||
if (location.pathname.startsWith("/game")) return headerTitle;
|
if (location.pathname.startsWith("/game")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
if (location.pathname.startsWith("/achievements")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/profile")) return headerTitle;
|
if (location.pathname.startsWith("/profile")) return headerTitle;
|
||||||
|
if (location.pathname.startsWith("/notifications")) return headerTitle;
|
||||||
if (location.pathname.startsWith("/library"))
|
if (location.pathname.startsWith("/library"))
|
||||||
return headerTitle || t("library");
|
return headerTitle || t("library");
|
||||||
if (location.pathname.startsWith("/search")) return t("search_results");
|
if (location.pathname.startsWith("/search")) return t("search_results");
|
||||||
@@ -323,7 +324,8 @@ export function Header() {
|
|||||||
<SearchDropdown
|
<SearchDropdown
|
||||||
visible={
|
visible={
|
||||||
isDropdownVisible &&
|
isDropdownVisible &&
|
||||||
(historyItems.length > 0 ||
|
(searchValue.trim().length > 0 ||
|
||||||
|
historyItems.length > 0 ||
|
||||||
suggestions.length > 0 ||
|
suggestions.length > 0 ||
|
||||||
isLoadingSuggestions)
|
isLoadingSuggestions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import cn from "classnames";
|
|||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: React.ReactNode;
|
||||||
description?: string;
|
description?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
@@ -115,7 +115,6 @@ export function Modal({
|
|||||||
"modal--large": large,
|
"modal--large": large,
|
||||||
})}
|
})}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby={title}
|
|
||||||
aria-describedby={description}
|
aria-describedby={description}
|
||||||
ref={modalContentRef}
|
ref={modalContentRef}
|
||||||
data-hydra-dialog
|
data-hydra-dialog
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
background-color: globals.$dark-background-color;
|
background-color: globals.$dark-background-color;
|
||||||
border: 1px solid globals.$border-color;
|
border: 1px solid globals.$border-color;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-height: 300px;
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
import { ClockIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
@@ -92,23 +92,8 @@ export function SearchDropdown({
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [visible, onClose, searchContainerRef]);
|
}, [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;
|
if (!visible) return null;
|
||||||
|
|
||||||
const totalItems = historyItems.length + suggestions.length;
|
|
||||||
const hasHistory = historyItems.length > 0;
|
const hasHistory = historyItems.length > 0;
|
||||||
const hasSuggestions = suggestions.length > 0;
|
const hasSuggestions = suggestions.length > 0;
|
||||||
|
|
||||||
@@ -158,7 +143,7 @@ export function SearchDropdown({
|
|||||||
activeIndex === getItemIndex("history", index),
|
activeIndex === getItemIndex("history", index),
|
||||||
})}
|
})}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleItemClick("history", item)}
|
onClick={() => onSelectHistory(item.query)}
|
||||||
>
|
>
|
||||||
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
<ClockIcon size={16} className="search-dropdown__item-icon" />
|
||||||
<span className="search-dropdown__item-text">
|
<span className="search-dropdown__item-text">
|
||||||
@@ -200,7 +185,7 @@ export function SearchDropdown({
|
|||||||
activeIndex === getItemIndex("suggestion", index),
|
activeIndex === getItemIndex("suggestion", index),
|
||||||
})}
|
})}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => handleItemClick("suggestion", item)}
|
onClick={() => onSelectSuggestion(item)}
|
||||||
>
|
>
|
||||||
{item.iconUrl ? (
|
{item.iconUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -227,13 +212,6 @@ export function SearchDropdown({
|
|||||||
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
{isLoadingSuggestions && !hasSuggestions && !hasHistory && (
|
||||||
<div className="search-dropdown__loading">{t("loading")}</div>
|
<div className="search-dropdown__loading">{t("loading")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingSuggestions &&
|
|
||||||
!hasHistory &&
|
|
||||||
!hasSuggestions &&
|
|
||||||
totalItems === 0 && (
|
|
||||||
<div className="search-dropdown__empty">{t("no_results")}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__friends-button {
|
&__notification-button {
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__friends-button-badge {
|
&__notification-button-badge {
|
||||||
background-color: globals.$success-color;
|
background-color: globals.$success-color;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -73,6 +73,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__game-running-icon {
|
&__game-running-icon {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
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 { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
import { Avatar } from "../avatar/avatar";
|
import { Avatar } from "../avatar/avatar";
|
||||||
import { AuthPage } from "@shared";
|
import { AuthPage } from "@shared";
|
||||||
|
import { logger } from "@renderer/logger";
|
||||||
|
import type { NotificationCountResponse } from "@types";
|
||||||
import "./sidebar-profile.scss";
|
import "./sidebar-profile.scss";
|
||||||
|
|
||||||
export function SidebarProfile() {
|
export function SidebarProfile() {
|
||||||
@@ -14,11 +15,75 @@ export function SidebarProfile() {
|
|||||||
|
|
||||||
const { t } = useTranslation("sidebar");
|
const { t } = useTranslation("sidebar");
|
||||||
|
|
||||||
const { userDetails, friendRequestCount, showFriendsModal } =
|
const { userDetails } = useUserDetails();
|
||||||
useUserDetails();
|
|
||||||
|
|
||||||
const { gameRunning } = useAppSelector((state) => state.gameRunning);
|
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 = () => {
|
const handleProfileClick = () => {
|
||||||
if (userDetails === null) {
|
if (userDetails === null) {
|
||||||
window.electron.openAuthWindow(AuthPage.SignIn);
|
window.electron.openAuthWindow(AuthPage.SignIn);
|
||||||
@@ -28,28 +93,24 @@ export function SidebarProfile() {
|
|||||||
navigate(`/profile/${userDetails.id}`);
|
navigate(`/profile/${userDetails.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const friendsButton = useMemo(() => {
|
const notificationsButton = useMemo(() => {
|
||||||
if (!userDetails) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sidebar-profile__friends-button"
|
className="sidebar-profile__notification-button"
|
||||||
onClick={() =>
|
onClick={() => navigate("/notifications")}
|
||||||
showFriendsModal(UserFriendModalTab.AddFriend, userDetails.id)
|
title={t("notifications")}
|
||||||
}
|
|
||||||
title={t("friends")}
|
|
||||||
>
|
>
|
||||||
{friendRequestCount > 0 && (
|
{notificationCount > 0 && (
|
||||||
<small className="sidebar-profile__friends-button-badge">
|
<small className="sidebar-profile__notification-button-badge">
|
||||||
{friendRequestCount > 99 ? "99+" : friendRequestCount}
|
{notificationCount > 99 ? "99+" : notificationCount}
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PeopleIcon size={16} />
|
<BellIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}, [userDetails, t, friendRequestCount, showFriendsModal]);
|
}, [t, notificationCount, navigate]);
|
||||||
|
|
||||||
const gameRunningDetails = () => {
|
const gameRunningDetails = () => {
|
||||||
if (!userDetails || !gameRunning) return null;
|
if (!userDetails || !gameRunning) return null;
|
||||||
@@ -98,7 +159,7 @@ export function SidebarProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{friendsButton}
|
{notificationsButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/renderer/src/declaration.d.ts
vendored
15
src/renderer/src/declaration.d.ts
vendored
@@ -14,6 +14,7 @@ import type {
|
|||||||
GameStats,
|
GameStats,
|
||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequestSync,
|
FriendRequestSync,
|
||||||
|
NotificationSync,
|
||||||
GameArtifact,
|
GameArtifact,
|
||||||
LudusaviBackup,
|
LudusaviBackup,
|
||||||
UserAchievement,
|
UserAchievement,
|
||||||
@@ -31,6 +32,7 @@ import type {
|
|||||||
Game,
|
Game,
|
||||||
DiskUsage,
|
DiskUsage,
|
||||||
DownloadSource,
|
DownloadSource,
|
||||||
|
LocalNotification,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
|
||||||
@@ -387,10 +389,12 @@ declare global {
|
|||||||
processProfileImage: (
|
processProfileImage: (
|
||||||
path: string
|
path: string
|
||||||
) => Promise<{ imagePath: string; mimeType: string }>;
|
) => Promise<{ imagePath: string; mimeType: string }>;
|
||||||
syncFriendRequests: () => Promise<void>;
|
|
||||||
onSyncFriendRequests: (
|
onSyncFriendRequests: (
|
||||||
cb: (friendRequests: FriendRequestSync) => void
|
cb: (friendRequests: FriendRequestSync) => void
|
||||||
) => () => Electron.IpcRenderer;
|
) => () => Electron.IpcRenderer;
|
||||||
|
onSyncNotificationCount: (
|
||||||
|
cb: (notification: NotificationSync) => void
|
||||||
|
) => () => Electron.IpcRenderer;
|
||||||
updateFriendRequest: (
|
updateFriendRequest: (
|
||||||
userId: string,
|
userId: string,
|
||||||
action: FriendRequestAction
|
action: FriendRequestAction
|
||||||
@@ -398,6 +402,15 @@ declare global {
|
|||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
|
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: (
|
onAchievementUnlocked: (
|
||||||
cb: (
|
cb: (
|
||||||
position?: AchievementCustomNotificationPosition,
|
position?: AchievementCustomNotificationPosition,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
import type { FriendRequest, UserDetails } from "@types";
|
import type { FriendRequest, UserDetails } from "@types";
|
||||||
|
|
||||||
export interface UserDetailsState {
|
export interface UserDetailsState {
|
||||||
@@ -7,9 +6,6 @@ export interface UserDetailsState {
|
|||||||
profileBackground: null | string;
|
profileBackground: null | string;
|
||||||
friendRequests: FriendRequest[];
|
friendRequests: FriendRequest[];
|
||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
isFriendsModalVisible: boolean;
|
|
||||||
friendRequetsModalTab: UserFriendModalTab | null;
|
|
||||||
friendModalUserId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UserDetailsState = {
|
const initialState: UserDetailsState = {
|
||||||
@@ -17,9 +13,6 @@ const initialState: UserDetailsState = {
|
|||||||
profileBackground: null,
|
profileBackground: null,
|
||||||
friendRequests: [],
|
friendRequests: [],
|
||||||
friendRequestCount: 0,
|
friendRequestCount: 0,
|
||||||
isFriendsModalVisible: false,
|
|
||||||
friendRequetsModalTab: null,
|
|
||||||
friendModalUserId: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userDetailsSlice = createSlice({
|
export const userDetailsSlice = createSlice({
|
||||||
@@ -38,18 +31,6 @@ export const userDetailsSlice = createSlice({
|
|||||||
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
setFriendRequestCount: (state, action: PayloadAction<number>) => {
|
||||||
state.friendRequestCount = action.payload;
|
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,
|
setProfileBackground,
|
||||||
setFriendRequests,
|
setFriendRequests,
|
||||||
setFriendRequestCount,
|
setFriendRequestCount,
|
||||||
setFriendsModalVisible,
|
|
||||||
setFriendsModalHidden,
|
|
||||||
} = userDetailsSlice.actions;
|
} = userDetailsSlice.actions;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
setProfileBackground,
|
setProfileBackground,
|
||||||
setUserDetails,
|
setUserDetails,
|
||||||
setFriendRequests,
|
setFriendRequests,
|
||||||
setFriendsModalVisible,
|
|
||||||
setFriendsModalHidden,
|
|
||||||
} from "@renderer/features";
|
} from "@renderer/features";
|
||||||
import type {
|
import type {
|
||||||
FriendRequestAction,
|
FriendRequestAction,
|
||||||
@@ -13,20 +11,12 @@ import type {
|
|||||||
UserDetails,
|
UserDetails,
|
||||||
FriendRequest,
|
FriendRequest,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
|
||||||
|
|
||||||
export function useUserDetails() {
|
export function useUserDetails() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const {
|
const { userDetails, profileBackground, friendRequests, friendRequestCount } =
|
||||||
userDetails,
|
useAppSelector((state) => state.userDetails);
|
||||||
profileBackground,
|
|
||||||
friendRequests,
|
|
||||||
friendRequestCount,
|
|
||||||
isFriendsModalVisible,
|
|
||||||
friendModalUserId,
|
|
||||||
friendRequetsModalTab,
|
|
||||||
} = useAppSelector((state) => state.userDetails);
|
|
||||||
|
|
||||||
const clearUserDetails = useCallback(async () => {
|
const clearUserDetails = useCallback(async () => {
|
||||||
dispatch(setUserDetails(null));
|
dispatch(setUserDetails(null));
|
||||||
@@ -85,24 +75,11 @@ export function useUserDetails() {
|
|||||||
return window.electron.hydraApi
|
return window.electron.hydraApi
|
||||||
.get<FriendRequest[]>("/profile/friend-requests")
|
.get<FriendRequest[]>("/profile/friend-requests")
|
||||||
.then((friendRequests) => {
|
.then((friendRequests) => {
|
||||||
window.electron.syncFriendRequests();
|
|
||||||
dispatch(setFriendRequests(friendRequests));
|
dispatch(setFriendRequests(friendRequests));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const showFriendsModal = useCallback(
|
|
||||||
(initialTab: UserFriendModalTab, userId: string) => {
|
|
||||||
dispatch(setFriendsModalVisible({ initialTab, userId }));
|
|
||||||
fetchFriendRequests();
|
|
||||||
},
|
|
||||||
[dispatch, fetchFriendRequests]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideFriendsModal = useCallback(() => {
|
|
||||||
dispatch(setFriendsModalHidden());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const sendFriendRequest = useCallback(
|
const sendFriendRequest = useCallback(
|
||||||
async (userId: string) => {
|
async (userId: string) => {
|
||||||
return window.electron.hydraApi
|
return window.electron.hydraApi
|
||||||
@@ -152,12 +129,7 @@ export function useUserDetails() {
|
|||||||
profileBackground,
|
profileBackground,
|
||||||
friendRequests,
|
friendRequests,
|
||||||
friendRequestCount,
|
friendRequestCount,
|
||||||
friendRequetsModalTab,
|
|
||||||
isFriendsModalVisible,
|
|
||||||
friendModalUserId,
|
|
||||||
hasActiveSubscription,
|
hasActiveSubscription,
|
||||||
showFriendsModal,
|
|
||||||
hideFriendsModal,
|
|
||||||
fetchUserDetails,
|
fetchUserDetails,
|
||||||
signOut,
|
signOut,
|
||||||
clearUserDetails,
|
clearUserDetails,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import Profile from "./pages/profile/profile";
|
|||||||
import Achievements from "./pages/achievements/achievements";
|
import Achievements from "./pages/achievements/achievements";
|
||||||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||||
import Library from "./pages/library/library";
|
import Library from "./pages/library/library";
|
||||||
|
import Notifications from "./pages/notifications/notifications";
|
||||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||||
|
|
||||||
console.log = logger.log;
|
console.log = logger.log;
|
||||||
@@ -76,6 +77,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/profile/:userId" element={<Profile />} />
|
<Route path="/profile/:userId" element={<Profile />} />
|
||||||
<Route path="/achievements" element={<Achievements />} />
|
<Route path="/achievements" element={<Achievements />} />
|
||||||
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
<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 { PencilIcon } from "@primer/octicons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { HeroPanel } from "./hero";
|
import { HeroPanel } from "./hero";
|
||||||
import { DescriptionHeader } from "./description-header/description-header";
|
import { DescriptionHeader } from "./description-header/description-header";
|
||||||
@@ -55,6 +56,8 @@ const getImageWithCustomPriority = (
|
|||||||
|
|
||||||
export function GameDetailsContent() {
|
export function GameDetailsContent() {
|
||||||
const { t } = useTranslation("game_details");
|
const { t } = useTranslation("game_details");
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const reviewsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
objectId,
|
objectId,
|
||||||
@@ -137,6 +140,16 @@ export function GameDetailsContent() {
|
|||||||
getGameArtifacts();
|
getGameArtifacts();
|
||||||
}, [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 isCustomGame = game?.shop === "custom";
|
||||||
|
|
||||||
const heroImage = isCustomGame
|
const heroImage = isCustomGame
|
||||||
@@ -229,15 +242,17 @@ export function GameDetailsContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{shop !== "custom" && shop && objectId && (
|
{shop !== "custom" && shop && objectId && (
|
||||||
<GameReviews
|
<div ref={reviewsRef}>
|
||||||
shop={shop}
|
<GameReviews
|
||||||
objectId={objectId}
|
shop={shop}
|
||||||
game={game}
|
objectId={objectId}
|
||||||
userDetailsId={userDetails?.id}
|
game={game}
|
||||||
isGameInLibrary={isGameInLibrary}
|
userDetailsId={userDetails?.id}
|
||||||
hasUserReviewed={hasUserReviewed}
|
isGameInLibrary={isGameInLibrary}
|
||||||
onUserReviewedChange={setHasUserReviewed}
|
hasUserReviewed={hasUserReviewed}
|
||||||
/>
|
onUserReviewedChange={setHasUserReviewed}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
103
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
103
src/renderer/src/pages/notifications/local-notification-item.tsx
Normal file
@@ -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<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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("notification-item", {
|
||||||
|
"notification-item--unread": !notification.isRead,
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
228
src/renderer/src/pages/notifications/notification-item.tsx
Normal file
@@ -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<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.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 <img src={notification.pictureUrl} alt="" />;
|
||||||
|
}
|
||||||
|
if (isReview) {
|
||||||
|
return <StarFillIcon size={24} />;
|
||||||
|
}
|
||||||
|
return <PersonIcon size={24} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn("notification-item", {
|
||||||
|
"notification-item--unread":
|
||||||
|
!notification.isRead ||
|
||||||
|
notification.type === "FRIEND_REQUEST_RECEIVED",
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/renderer/src/pages/notifications/notifications.scss
Normal file
58
src/renderer/src/pages/notifications/notifications.scss
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
src/renderer/src/pages/notifications/notifications.tsx
Normal file
400
src/renderer/src/pages/notifications/notifications.tsx
Normal file
@@ -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<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isLoading && mergedNotifications.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="notifications__loading">
|
||||||
|
<span>{t("loading")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergedNotifications.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="notifications__empty">
|
||||||
|
<div className="notifications__icon-container">
|
||||||
|
<BellIcon size={24} />
|
||||||
|
</div>
|
||||||
|
<h2>{t("empty_title")}</h2>
|
||||||
|
<p>{t("empty_description")}</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>{renderContent()}</>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modal visible={visible} title={t("add_friends")} onClose={onClose}>
|
||||||
|
<div className="add-friend-modal">
|
||||||
|
{userDetails?.id && (
|
||||||
|
<div className="add-friend-modal__my-code">
|
||||||
|
<span className="add-friend-modal__my-code-label">
|
||||||
|
{t("your_friend_code")}
|
||||||
|
</span>
|
||||||
|
<span className="add-friend-modal__my-code-value">
|
||||||
|
{userDetails.id}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={copyMyFriendCode}
|
||||||
|
type="button"
|
||||||
|
className="add-friend-modal__copy-icon-button"
|
||||||
|
title={t("copy_friend_code")}
|
||||||
|
>
|
||||||
|
<CopyIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="add-friend-modal__actions">
|
||||||
|
<TextField
|
||||||
|
label={t("friend_code")}
|
||||||
|
value={friendCode}
|
||||||
|
containerProps={{ style: { flex: 1 } }}
|
||||||
|
onChange={handleChangeFriendCode}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={isAddingFriend}
|
||||||
|
type="button"
|
||||||
|
className="add-friend-modal__button"
|
||||||
|
onClick={() => validateFriendCode(handleClickAddFriend)}
|
||||||
|
>
|
||||||
|
{isAddingFriend ? t("sending") : t("add")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() => validateFriendCode(handleClickSeeProfile)}
|
||||||
|
disabled={isAddingFriend}
|
||||||
|
className="add-friend-modal__button"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("see_profile")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{currentRequest && (
|
||||||
|
<div className="add-friend-modal__pending-status">{t("pending")}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sentRequests.length > 0 && (
|
||||||
|
<div className="add-friend-modal__pending-container">
|
||||||
|
<h3>{t("pending")}</h3>
|
||||||
|
<div className="add-friend-modal__pending-list">
|
||||||
|
{sentRequests.map((request) => (
|
||||||
|
<button
|
||||||
|
key={request.id}
|
||||||
|
type="button"
|
||||||
|
className="add-friend-modal__friend-item"
|
||||||
|
onClick={() => handleClickRequest(request.id)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={request.profileImageUrl}
|
||||||
|
alt={request.displayName}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
<span className="add-friend-modal__friend-name">
|
||||||
|
{request.displayName}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancelFriendRequest(request.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t("cancel_request")}
|
||||||
|
</Button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AllBadgesModalProps>) {
|
||||||
|
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 = (
|
||||||
|
<div className="all-badges-modal__title">
|
||||||
|
{t("badges")}
|
||||||
|
{userBadges && userBadges.length > 0 && (
|
||||||
|
<span className="all-badges-modal__count">{userBadges.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} title={modalTitle} onClose={onClose}>
|
||||||
|
<div className="all-badges-modal">
|
||||||
|
<div className="all-badges-modal__list">
|
||||||
|
{userBadges?.map((badge) => (
|
||||||
|
<div key={badge.name} className="all-badges-modal__item">
|
||||||
|
<div className="all-badges-modal__item-icon">
|
||||||
|
<img
|
||||||
|
src={badge.badge.url}
|
||||||
|
alt={badge.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="all-badges-modal__item-content">
|
||||||
|
<h3 className="all-badges-modal__item-title">{badge.title}</h3>
|
||||||
|
<p className="all-badges-modal__item-description">
|
||||||
|
{badge.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserFriend[]>([]);
|
||||||
|
const [totalFriends, setTotalFriends] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 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 <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)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
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>
|
||||||
|
</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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="badges-box__box">
|
||||||
|
<div className="badges-box__list">
|
||||||
|
{visibleBadges.map((badgeName) => {
|
||||||
|
const badge = badges.find((b) => b.name === badgeName);
|
||||||
|
|
||||||
|
if (!badge) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={badge.name} className="badges-box__item">
|
||||||
|
<div className="badges-box__item-icon">
|
||||||
|
<img
|
||||||
|
src={badge.badge.url}
|
||||||
|
alt={badge.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="badges-box__item-content">
|
||||||
|
<h3 className="badges-box__item-title">{badge.title}</h3>
|
||||||
|
<p className="badges-box__item-description">
|
||||||
|
{badge.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hasMoreBadges && (
|
||||||
|
<div className="badges-box__view-all-container">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="badges-box__view-all"
|
||||||
|
onClick={() => setShowAllBadgesModal(true)}
|
||||||
|
>
|
||||||
|
{t("view_all")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AllBadgesModal
|
||||||
|
visible={showAllBadgesModal}
|
||||||
|
onClose={() => setShowAllBadgesModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,34 @@
|
|||||||
@use "../../../scss/globals.scss";
|
@use "../../../scss/globals.scss";
|
||||||
|
|
||||||
.friends-box {
|
.friends-box {
|
||||||
&__section-header {
|
&__box {
|
||||||
display: flex;
|
padding: calc(globals.$spacing-unit * 2);
|
||||||
align-items: center;
|
position: relative;
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__box {
|
&__add-friend-button {
|
||||||
background-color: globals.$background-color;
|
background: none;
|
||||||
border-radius: 4px;
|
border: none;
|
||||||
border: solid 1px globals.$border-color;
|
color: globals.$body-color;
|
||||||
padding: calc(globals.$spacing-unit * 2);
|
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 {
|
&__list {
|
||||||
@@ -44,11 +60,12 @@
|
|||||||
|
|
||||||
&__friend-name {
|
&__friend-name {
|
||||||
color: globals.$muted-color;
|
color: globals.$muted-color;
|
||||||
font-weight: bold;
|
font-size: 0.8rem;
|
||||||
font-size: globals.$body-font-size;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__game-info {
|
&__game-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: globals.$spacing-unit;
|
gap: globals.$spacing-unit;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -63,4 +80,19 @@
|
|||||||
&__game-image {
|
&__game-image {
|
||||||
border-radius: 4px;
|
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,24 @@
|
|||||||
import { userProfileContext } from "@renderer/context";
|
import { userProfileContext } from "@renderer/context";
|
||||||
import { useFormat } from "@renderer/hooks";
|
import { useUserDetails } from "@renderer/hooks";
|
||||||
import { useContext } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PlusIcon } from "@primer/octicons-react";
|
||||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||||
import { Avatar, Link } from "@renderer/components";
|
import { Avatar, Link } from "@renderer/components";
|
||||||
|
import { AllFriendsModal } from "./all-friends-modal";
|
||||||
|
import { AddFriendModal } from "./add-friend-modal";
|
||||||
import "./friends-box.scss";
|
import "./friends-box.scss";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_FRIENDS = 5;
|
||||||
|
|
||||||
export function FriendsBox() {
|
export function FriendsBox() {
|
||||||
const { userProfile, userStats } = useContext(userProfileContext);
|
const { userProfile } = useContext(userProfileContext);
|
||||||
|
const { userDetails } = useUserDetails();
|
||||||
const { t } = useTranslation("user_profile");
|
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 }) => {
|
const getGameImage = (game: { iconUrl: string | null; title: string }) => {
|
||||||
if (game.iconUrl) {
|
if (game.iconUrl) {
|
||||||
@@ -28,22 +37,15 @@ export function FriendsBox() {
|
|||||||
|
|
||||||
if (!userProfile?.friends.length) return null;
|
if (!userProfile?.friends.length) return null;
|
||||||
|
|
||||||
return (
|
const visibleFriends = userProfile.friends.slice(0, MAX_VISIBLE_FRIENDS);
|
||||||
<div>
|
const totalFriends = userProfile.friends.length;
|
||||||
<div className="friends-box__section-header">
|
const showViewAllButton = totalFriends > MAX_VISIBLE_FRIENDS;
|
||||||
<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>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div className="friends-box__box">
|
<div className="friends-box__box">
|
||||||
<ul className="friends-box__list">
|
<ul className="friends-box__list">
|
||||||
{userProfile?.friends.map((friend) => (
|
{visibleFriends.map((friend) => (
|
||||||
<li
|
<li
|
||||||
key={friend.id}
|
key={friend.id}
|
||||||
title={
|
title={
|
||||||
@@ -77,7 +79,61 @@ export function FriendsBox() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{showViewAllButton && (
|
||||||
|
<div className="friends-box__view-all-container">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="friends-box__view-all"
|
||||||
|
onClick={() => setShowAllFriendsModal(true)}
|
||||||
|
>
|
||||||
|
{t("view_all")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{userProfile && (
|
||||||
|
<>
|
||||||
|
<AllFriendsModal
|
||||||
|
visible={showAllFriendsModal}
|
||||||
|
onClose={() => setShowAllFriendsModal(false)}
|
||||||
|
userId={userProfile.id}
|
||||||
|
isMe={isMe}
|
||||||
|
/>
|
||||||
|
<AddFriendModal
|
||||||
|
visible={showAddFriendModal}
|
||||||
|
onClose={() => 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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="friends-box__add-friend-button"
|
||||||
|
onClick={() => setShowAddFriendModal(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon size={16} />
|
||||||
|
{t("add_friends")}
|
||||||
|
</button>
|
||||||
|
<AddFriendModal
|
||||||
|
visible={showAddFriendModal}
|
||||||
|
onClose={() => setShowAddFriendModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
import { LockedProfile } from "./locked-profile";
|
import { LockedProfile } from "./locked-profile";
|
||||||
import { ReportProfile } from "../report-profile/report-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 { RecentGamesBox } from "./recent-games-box";
|
||||||
import { UserStatsBox } from "./user-stats-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 { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
|
||||||
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
|
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
|
||||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
@@ -186,8 +187,6 @@ export function ProfileContent() {
|
|||||||
);
|
);
|
||||||
setReviews(response.reviews);
|
setReviews(response.reviews);
|
||||||
setReviewsTotalCount(response.totalCount);
|
setReviewsTotalCount(response.totalCount);
|
||||||
} catch (error) {
|
|
||||||
// Error handling for fetching reviews
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingReviews(false);
|
setIsLoadingReviews(false);
|
||||||
}
|
}
|
||||||
@@ -426,10 +425,35 @@ export function ProfileContent() {
|
|||||||
|
|
||||||
{shouldShowRightContent && (
|
{shouldShowRightContent && (
|
||||||
<div className="profile-content__right-content">
|
<div className="profile-content__right-content">
|
||||||
<UserStatsBox />
|
{userStats && (
|
||||||
<UserKarmaBox />
|
<ProfileSection title={t("stats")} defaultOpen={true}>
|
||||||
<RecentGamesBox />
|
<UserStatsBox />
|
||||||
<FriendsBox />
|
</ProfileSection>
|
||||||
|
)}
|
||||||
|
{userProfile?.badges.length > 0 && (
|
||||||
|
<ProfileSection
|
||||||
|
title={t("badges")}
|
||||||
|
count={userProfile.badges.length}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<BadgesBox />
|
||||||
|
</ProfileSection>
|
||||||
|
)}
|
||||||
|
{userProfile?.recentGames.length > 0 && (
|
||||||
|
<ProfileSection title={t("activity")} defaultOpen={true}>
|
||||||
|
<RecentGamesBox />
|
||||||
|
</ProfileSection>
|
||||||
|
)}
|
||||||
|
{userProfile?.friends.length > 0 && (
|
||||||
|
<ProfileSection
|
||||||
|
title={t("friends")}
|
||||||
|
count={userStats?.friendsCount || userProfile.friends.length}
|
||||||
|
action={<FriendsBoxAddButton />}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<FriendsBox />
|
||||||
|
</ProfileSection>
|
||||||
|
)}
|
||||||
<ReportProfile />
|
<ReportProfile />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,19 +2,9 @@
|
|||||||
|
|
||||||
.recent-games {
|
.recent-games {
|
||||||
&__box {
|
&__box {
|
||||||
background-color: globals.$background-color;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: solid 1px globals.$border-color;
|
|
||||||
padding: calc(globals.$spacing-unit * 2);
|
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 {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -57,13 +47,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__game-title {
|
&__game-title {
|
||||||
font-weight: bold;
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__game-description {
|
&__game-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: globals.$spacing-unit;
|
gap: globals.$spacing-unit;
|
||||||
|
|||||||
@@ -42,38 +42,32 @@ export function RecentGamesBox() {
|
|||||||
if (!userProfile?.recentGames.length) return null;
|
if (!userProfile?.recentGames.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="recent-games__box">
|
||||||
<div className="recent-games__section-header">
|
<ul className="recent-games__list">
|
||||||
<h2>{t("activity")}</h2>
|
{userProfile?.recentGames.map((game) => (
|
||||||
</div>
|
<li key={`${game.shop}-${game.objectId}`}>
|
||||||
|
<Link
|
||||||
|
to={buildUserGameDetailsPath(game)}
|
||||||
|
className="recent-games__list-item"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={game.iconUrl!}
|
||||||
|
alt={game.title}
|
||||||
|
className="recent-games__game-image"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="recent-games__box">
|
<div className="recent-games__game-details">
|
||||||
<ul className="recent-games__list">
|
<span className="recent-games__game-title">{game.title}</span>
|
||||||
{userProfile?.recentGames.map((game) => (
|
|
||||||
<li key={`${game.shop}-${game.objectId}`}>
|
|
||||||
<Link
|
|
||||||
to={buildUserGameDetailsPath(game)}
|
|
||||||
className="recent-games__list-item"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={game.iconUrl!}
|
|
||||||
alt={game.title}
|
|
||||||
className="recent-games__game-image"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="recent-games__game-details">
|
<div className="recent-games__game-description">
|
||||||
<span className="recent-games__game-title">{game.title}</span>
|
<ClockIcon />
|
||||||
|
<small>{formatPlayTime(game)}</small>
|
||||||
<div className="recent-games__game-description">
|
|
||||||
<ClockIcon />
|
|
||||||
<small>{formatPlayTime(game)}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</li>
|
</Link>
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div>
|
|
||||||
<div className="user-karma__section-header">
|
|
||||||
<h2>{t("karma")}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="user-karma__box">
|
|
||||||
<div className="user-karma__content">
|
|
||||||
<div className="user-karma__stats-row">
|
|
||||||
<p className="user-karma__description">
|
|
||||||
<Award size={20} /> {numberFormatter.format(karma)}{" "}
|
|
||||||
{t("karma_count")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="user-karma__info">
|
|
||||||
<small className="user-karma__info-text">
|
|
||||||
{t("karma_description")}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,19 +2,9 @@
|
|||||||
|
|
||||||
.user-stats {
|
.user-stats {
|
||||||
&__box {
|
&__box {
|
||||||
background-color: globals.$background-color;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: solid 1px globals.$border-color;
|
|
||||||
padding: calc(globals.$spacing-unit * 2);
|
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 {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -42,13 +32,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__list-title {
|
&__list-title {
|
||||||
font-weight: bold;
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list-description {
|
&__list-description {
|
||||||
|
font-size: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: globals.$spacing-unit;
|
gap: globals.$spacing-unit;
|
||||||
@@ -72,4 +64,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__karma-info-text {
|
||||||
|
color: globals.$muted-color;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useContext } from "react";
|
import { useCallback, useContext } from "react";
|
||||||
import { userProfileContext } from "@renderer/context";
|
import { userProfileContext } from "@renderer/context";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||||
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
|
||||||
|
import { Award } from "lucide-react";
|
||||||
import "./user-stats-box.scss";
|
import "./user-stats-box.scss";
|
||||||
|
|
||||||
export function UserStatsBox() {
|
export function UserStatsBox() {
|
||||||
const { showHydraCloudModal } = useSubscription();
|
const { showHydraCloudModal } = useSubscription();
|
||||||
const { userStats, isMe } = useContext(userProfileContext);
|
const { userStats, isMe, userProfile } = useContext(userProfileContext);
|
||||||
|
const { userDetails } = useUserDetails();
|
||||||
const { t } = useTranslation("user_profile");
|
const { t } = useTranslation("user_profile");
|
||||||
const { numberFormatter } = useFormat();
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
@@ -33,88 +35,102 @@ export function UserStatsBox() {
|
|||||||
|
|
||||||
if (!userStats) return null;
|
if (!userStats) return null;
|
||||||
|
|
||||||
|
const karma = isMe ? userDetails?.karma : userProfile?.karma;
|
||||||
|
const hasKarma = karma !== undefined && karma !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="user-stats__box">
|
||||||
<div className="user-stats__section-header">
|
<ul className="user-stats__list">
|
||||||
<h2>{t("stats")}</h2>
|
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="user-stats__box">
|
|
||||||
<ul className="user-stats__list">
|
|
||||||
{(isMe || userStats.unlockedAchievementSum !== undefined) && (
|
|
||||||
<li className="user-stats__list-item">
|
|
||||||
<h3 className="user-stats__list-title">
|
|
||||||
{t("achievements_unlocked")}
|
|
||||||
</h3>
|
|
||||||
{userStats.unlockedAchievementSum !== undefined ? (
|
|
||||||
<div className="user-stats__stats-row">
|
|
||||||
<p className="user-stats__list-description">
|
|
||||||
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
|
|
||||||
{t("achievements")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showHydraCloudModal("achievements")}
|
|
||||||
className="user-stats__link"
|
|
||||||
>
|
|
||||||
<small style={{ color: "var(--color-warning)" }}>
|
|
||||||
{t("show_achievements_on_profile")}
|
|
||||||
</small>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
|
|
||||||
<li className="user-stats__list-item">
|
|
||||||
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
|
|
||||||
{userStats.achievementsPointsEarnedSum !== undefined ? (
|
|
||||||
<div className="user-stats__stats-row">
|
|
||||||
<p className="user-stats__list-description">
|
|
||||||
<HydraIcon width={20} height={20} />
|
|
||||||
{numberFormatter.format(
|
|
||||||
userStats.achievementsPointsEarnedSum.value
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p title={t("ranking_updated_weekly")}>
|
|
||||||
{t("top_percentile", {
|
|
||||||
percentile:
|
|
||||||
userStats.achievementsPointsEarnedSum.topPercentile,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showHydraCloudModal("achievements-points")}
|
|
||||||
className="user-stats__link"
|
|
||||||
>
|
|
||||||
<small className="user-stats__link--warning">
|
|
||||||
{t("show_points_on_profile")}
|
|
||||||
</small>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<li className="user-stats__list-item">
|
<li className="user-stats__list-item">
|
||||||
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
|
<h3 className="user-stats__list-title">
|
||||||
|
{t("achievements_unlocked")}
|
||||||
|
</h3>
|
||||||
|
{userStats.unlockedAchievementSum !== undefined ? (
|
||||||
|
<div className="user-stats__stats-row">
|
||||||
|
<p className="user-stats__list-description">
|
||||||
|
<TrophyIcon /> {userStats.unlockedAchievementSum}{" "}
|
||||||
|
{t("achievements")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => showHydraCloudModal("achievements")}
|
||||||
|
className="user-stats__link"
|
||||||
|
>
|
||||||
|
<small style={{ color: "var(--color-warning)" }}>
|
||||||
|
{t("show_achievements_on_profile")}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isMe || userStats.achievementsPointsEarnedSum !== undefined) && (
|
||||||
|
<li className="user-stats__list-item">
|
||||||
|
<h3 className="user-stats__list-title">{t("earned_points")}</h3>
|
||||||
|
{userStats.achievementsPointsEarnedSum !== undefined ? (
|
||||||
|
<div className="user-stats__stats-row">
|
||||||
|
<p className="user-stats__list-description">
|
||||||
|
<HydraIcon width={20} height={20} />
|
||||||
|
{numberFormatter.format(
|
||||||
|
userStats.achievementsPointsEarnedSum.value
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p title={t("ranking_updated_weekly")}>
|
||||||
|
{t("top_percentile", {
|
||||||
|
percentile:
|
||||||
|
userStats.achievementsPointsEarnedSum.topPercentile,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => showHydraCloudModal("achievements-points")}
|
||||||
|
className="user-stats__link"
|
||||||
|
>
|
||||||
|
<small className="user-stats__link--warning">
|
||||||
|
{t("show_points_on_profile")}
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<li className="user-stats__list-item">
|
||||||
|
<h3 className="user-stats__list-title">{t("total_play_time")}</h3>
|
||||||
|
<div className="user-stats__stats-row">
|
||||||
|
<p className="user-stats__list-description">
|
||||||
|
<ClockIcon />
|
||||||
|
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
|
||||||
|
</p>
|
||||||
|
<p title={t("ranking_updated_weekly")}>
|
||||||
|
{t("top_percentile", {
|
||||||
|
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{hasKarma && karma !== undefined && karma !== null && (
|
||||||
|
<li className="user-stats__list-item user-stats__list-item--karma">
|
||||||
|
<h3 className="user-stats__list-title">{t("karma")}</h3>
|
||||||
<div className="user-stats__stats-row">
|
<div className="user-stats__stats-row">
|
||||||
<p className="user-stats__list-description">
|
<p className="user-stats__list-description">
|
||||||
<ClockIcon />
|
<Award size={20} /> {numberFormatter.format(karma)}{" "}
|
||||||
{formatPlayTime(userStats.totalPlayTimeInSeconds.value)}
|
{t("karma_count")}
|
||||||
</p>
|
|
||||||
<p title={t("ranking_updated_weekly")}>
|
|
||||||
{t("top_percentile", {
|
|
||||||
percentile: userStats.totalPlayTimeInSeconds.topPercentile,
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="user-stats__karma-info">
|
||||||
|
<small className="user-stats__karma-info-text">
|
||||||
|
{t("karma_description")}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
)}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__badges {
|
&__copy-button {
|
||||||
display: flex;
|
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 {
|
&__user-information {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { userProfileContext } from "@renderer/context";
|
|||||||
import {
|
import {
|
||||||
BlockedIcon,
|
BlockedIcon,
|
||||||
CheckCircleFillIcon,
|
CheckCircleFillIcon,
|
||||||
|
CopyIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PersonAddIcon,
|
PersonAddIcon,
|
||||||
SignOutIcon,
|
SignOutIcon,
|
||||||
@@ -25,13 +26,13 @@ import {
|
|||||||
} from "@renderer/hooks";
|
} from "@renderer/hooks";
|
||||||
import { addSeconds } from "date-fns";
|
import { addSeconds } from "date-fns";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import type { FriendRequestAction } from "@types";
|
import type { FriendRequestAction } from "@types";
|
||||||
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
|
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
|
||||||
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
|
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
|
||||||
import Skeleton from "react-loading-skeleton";
|
import Skeleton from "react-loading-skeleton";
|
||||||
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
|
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
|
||||||
import { Tooltip } from "react-tooltip";
|
|
||||||
import "./profile-hero.scss";
|
import "./profile-hero.scss";
|
||||||
|
|
||||||
type FriendAction =
|
type FriendAction =
|
||||||
@@ -43,15 +44,10 @@ export function ProfileHero() {
|
|||||||
const [showWrappedModal, setShowWrappedModal] = useState(false);
|
const [showWrappedModal, setShowWrappedModal] = useState(false);
|
||||||
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
|
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
|
||||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||||
|
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false);
|
||||||
|
|
||||||
const {
|
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
|
||||||
isMe,
|
useContext(userProfileContext);
|
||||||
badges,
|
|
||||||
getUserProfile,
|
|
||||||
userProfile,
|
|
||||||
heroBackground,
|
|
||||||
backgroundImage,
|
|
||||||
} = useContext(userProfileContext);
|
|
||||||
const {
|
const {
|
||||||
signOut,
|
signOut,
|
||||||
updateFriendRequestState,
|
updateFriendRequestState,
|
||||||
@@ -262,6 +258,13 @@ export function ProfileHero() {
|
|||||||
}
|
}
|
||||||
}, [isMe, userProfile?.profileImageUrl]);
|
}, [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(() => {
|
const currentGame = useMemo(() => {
|
||||||
if (isMe) {
|
if (isMe) {
|
||||||
if (gameRunning)
|
if (gameRunning)
|
||||||
@@ -336,28 +339,32 @@ export function ProfileHero() {
|
|||||||
{userProfile?.displayName}
|
{userProfile?.displayName}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="profile-hero__badges">
|
<motion.button
|
||||||
{userProfile.badges.map((badgeName) => {
|
type="button"
|
||||||
const badge = badges.find((b) => b.name === badgeName);
|
className="profile-hero__copy-button"
|
||||||
|
onClick={copyFriendCode}
|
||||||
if (!badge) return null;
|
title={t("copy_friend_code")}
|
||||||
|
onMouseEnter={() => setIsCopyButtonHovered(true)}
|
||||||
return (
|
onMouseLeave={() => setIsCopyButtonHovered(false)}
|
||||||
<img
|
initial={{ width: 28 }}
|
||||||
key={badge.name}
|
animate={{
|
||||||
src={badge.badge.url}
|
width: isCopyButtonHovered ? 105 : 28,
|
||||||
alt={badge.name}
|
}}
|
||||||
width={24}
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
height={24}
|
>
|
||||||
data-tooltip-place="top"
|
<motion.span
|
||||||
data-tooltip-content={badge.description}
|
className="profile-hero__friend-code"
|
||||||
data-tooltip-id="badge-name"
|
initial={{ opacity: 0, marginRight: 0 }}
|
||||||
/>
|
animate={{
|
||||||
);
|
opacity: isCopyButtonHovered ? 1 : 0,
|
||||||
})}
|
marginRight: isCopyButtonHovered ? 8 : 0,
|
||||||
|
}}
|
||||||
<Tooltip id="badge-name" />
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
</div>
|
>
|
||||||
|
{userProfile?.id}
|
||||||
|
</motion.span>
|
||||||
|
<CopyIcon size={16} />
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton width={150} height={28} />
|
<Skeleton width={150} height={28} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="profile-section__header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="profile-section__button"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`profile-section__chevron ${
|
||||||
|
isOpen ? "profile-section__chevron--open" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{title}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="profile-section__count">{count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{action && <div className="profile-section__action">{action}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={content}
|
||||||
|
className="profile-section__content"
|
||||||
|
style={{
|
||||||
|
maxHeight: `${height}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./user-friend-modal";
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<small>
|
|
||||||
{type == "SENT" ? t("request_sent") : t("request_received")}
|
|
||||||
</small>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRequestActions = () => {
|
|
||||||
if (type === null) return null;
|
|
||||||
|
|
||||||
if (type === "SENT") {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="user-friend-item__cancel-button"
|
|
||||||
onClick={() => props.onClickCancelRequest(userId)}
|
|
||||||
title={t("cancel_request")}
|
|
||||||
>
|
|
||||||
<XCircleIcon size={28} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "RECEIVED") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="user-friend-item__accept-button"
|
|
||||||
onClick={() => props.onClickAcceptRequest(userId)}
|
|
||||||
title={t("accept_request")}
|
|
||||||
>
|
|
||||||
<CheckCircleIcon size={28} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="user-friend-item__cancel-button"
|
|
||||||
onClick={() => props.onClickRefuseRequest(userId)}
|
|
||||||
title={t("ignore_request")}
|
|
||||||
>
|
|
||||||
<XCircleIcon size={28} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "ACCEPTED") {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="user-friend-item__cancel-button"
|
|
||||||
onClick={() => props.onClickUndoFriendship(userId)}
|
|
||||||
title={t("undo_friendship")}
|
|
||||||
>
|
|
||||||
<XCircleIcon size={28} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "BLOCKED") {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="user-friend-item__cancel-button"
|
|
||||||
onClick={() => props.onClickUnblock(userId)}
|
|
||||||
title={t("unblock")}
|
|
||||||
>
|
|
||||||
<XCircleIcon size={28} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type === "BLOCKED") {
|
|
||||||
return (
|
|
||||||
<div className="user-friend-item__container">
|
|
||||||
<div className="user-friend-item__button">
|
|
||||||
<Avatar size={35} src={profileImageUrl} alt={displayName} />
|
|
||||||
<div className="user-friend-item__button__content">
|
|
||||||
<p className="user-friend-item__display-name">{displayName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="user-friend-item__button__actions">
|
|
||||||
{getRequestActions()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="user-friend-item__container">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="user-friend-item__button"
|
|
||||||
onClick={() => props.onClickItem(userId)}
|
|
||||||
>
|
|
||||||
<Avatar size={35} src={profileImageUrl} alt={displayName} />
|
|
||||||
<div className="user-friend-item__button__content">
|
|
||||||
<p className="user-friend-item__display-name">{displayName}</p>
|
|
||||||
|
|
||||||
{getRequestDescription()}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div className="user-friend-item__button__actions">
|
|
||||||
{getRequestActions()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<HTMLInputElement>) => {
|
|
||||||
const friendCode = e.target.value.trim().slice(0, 8);
|
|
||||||
setFriendCode(friendCode);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="user-friend-modal-add-friend__actions">
|
|
||||||
<TextField
|
|
||||||
label={t("friend_code")}
|
|
||||||
value={friendCode}
|
|
||||||
containerProps={{ style: { width: "100%" } }}
|
|
||||||
onChange={handleChangeFriendCode}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
disabled={isAddingFriend}
|
|
||||||
className="user-friend-modal-add-friend__button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => validateFriendCode(handleClickAddFriend)}
|
|
||||||
>
|
|
||||||
{isAddingFriend ? t("sending") : t("add")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => validateFriendCode(handleClickSeeProfile)}
|
|
||||||
disabled={isAddingFriend}
|
|
||||||
className="user-friend-modal-add-friend__button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{t("see_profile")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="user-friend-modal-add-friend__pending-container">
|
|
||||||
<h3>{t("pending")}</h3>
|
|
||||||
{friendRequests.length === 0 && <p>{t("no_pending_invites")}</p>}
|
|
||||||
{friendRequests.map((request) => {
|
|
||||||
return (
|
|
||||||
<UserFriendItem
|
|
||||||
key={request.id}
|
|
||||||
displayName={request.displayName}
|
|
||||||
type={request.type}
|
|
||||||
profileImageUrl={request.profileImageUrl}
|
|
||||||
userId={request.id}
|
|
||||||
onClickAcceptRequest={handleAcceptFriendRequest}
|
|
||||||
onClickCancelRequest={handleCancelFriendRequest}
|
|
||||||
onClickRefuseRequest={handleRefuseFriendRequest}
|
|
||||||
onClickItem={handleClickRequest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<UserFriend[]>([]);
|
|
||||||
const listContainer = useRef<HTMLDivElement>(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 (
|
|
||||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
|
||||||
<div ref={listContainer} className="user-friend-modal-list">
|
|
||||||
{!isLoading && friends.length === 0 && <p>{t("no_friends_added")}</p>}
|
|
||||||
{friends.map((friend) => (
|
|
||||||
<UserFriendItem
|
|
||||||
userId={friend.id}
|
|
||||||
displayName={friend.displayName}
|
|
||||||
profileImageUrl={friend.profileImageUrl}
|
|
||||||
onClickItem={handleClickFriend}
|
|
||||||
onClickUndoFriendship={handleUndoFriendship}
|
|
||||||
type={isMe ? "ACCEPTED" : null}
|
|
||||||
key={"modal" + friend.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{isLoading && <Skeleton className="user-friend-modal-list__skeleton" />}
|
|
||||||
</div>
|
|
||||||
</SkeletonTheme>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 <UserFriendModalList userId={userId} closeModal={onClose} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTab == UserFriendModalTab.AddFriend) {
|
|
||||||
return <UserFriendModalAddFriend closeModal={onClose} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <></>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = useCallback(() => {
|
|
||||||
navigator.clipboard.writeText(userDetails!.id);
|
|
||||||
showSuccessToast(t("friend_code_copied"));
|
|
||||||
}, [userDetails, showSuccessToast, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={visible} title={t("friends")} onClose={onClose}>
|
|
||||||
<div className="user-friend-modal__container">
|
|
||||||
{isMe && (
|
|
||||||
<>
|
|
||||||
<div className="user-friend-modal__header">
|
|
||||||
<p>{t("your_friend_code")}</p>
|
|
||||||
<button
|
|
||||||
className="user-friend-modal__friend-code-button"
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
>
|
|
||||||
<h3>{userDetails.id}</h3>
|
|
||||||
<CopyIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<section className="user-friend-modal__tabs">
|
|
||||||
{tabs.map((tab, index) => (
|
|
||||||
<Button
|
|
||||||
key={tab}
|
|
||||||
theme={index === currentTab ? "primary" : "outline"}
|
|
||||||
onClick={() => setCurrentTab(index)}
|
|
||||||
>
|
|
||||||
{tab}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{renderTab()}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -144,6 +144,10 @@ export interface FriendRequestSync {
|
|||||||
friendRequestCount: number;
|
friendRequestCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NotificationSync {
|
||||||
|
notificationCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -166,6 +170,7 @@ export type ProfileVisibility = "PUBLIC" | "PRIVATE" | "FRIENDS";
|
|||||||
|
|
||||||
export interface Badge {
|
export interface Badge {
|
||||||
name: string;
|
name: string;
|
||||||
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
badge: {
|
badge: {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -313,6 +318,58 @@ export interface GameArtifact {
|
|||||||
isFrozen: boolean;
|
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 {
|
export interface ComparedAchievements {
|
||||||
achievementsPointsTotal: number;
|
achievementsPointsTotal: number;
|
||||||
owner: {
|
owner: {
|
||||||
|
|||||||
Reference in New Issue
Block a user