mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-27 12:51:03 +00:00
feat: add local notifications management and UI integration
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const clearAllLocalNotifications = async () => {
|
||||
await LocalNotificationManager.clearAll();
|
||||
};
|
||||
|
||||
registerEvent("clearAllLocalNotifications", clearAllLocalNotifications);
|
||||
11
src/main/events/notifications/delete-local-notification.ts
Normal file
11
src/main/events/notifications/delete-local-notification.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const deleteLocalNotification = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: string
|
||||
) => {
|
||||
await LocalNotificationManager.deleteNotification(id);
|
||||
};
|
||||
|
||||
registerEvent("deleteLocalNotification", deleteLocalNotification);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotificationsCount = async () => {
|
||||
return LocalNotificationManager.getUnreadCount();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotificationsCount", getLocalNotificationsCount);
|
||||
8
src/main/events/notifications/get-local-notifications.ts
Normal file
8
src/main/events/notifications/get-local-notifications.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const getLocalNotifications = async () => {
|
||||
return LocalNotificationManager.getNotifications();
|
||||
};
|
||||
|
||||
registerEvent("getLocalNotifications", getLocalNotifications);
|
||||
@@ -1,3 +1,9 @@
|
||||
import "./publish-new-repacks-notification";
|
||||
import "./show-achievement-test-notification";
|
||||
import "./update-achievement-notification-window";
|
||||
import "./get-local-notifications";
|
||||
import "./get-local-notifications-count";
|
||||
import "./mark-local-notification-read";
|
||||
import "./mark-all-local-notifications-read";
|
||||
import "./delete-local-notification";
|
||||
import "./clear-all-local-notifications";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const markAllLocalNotificationsRead = async () => {
|
||||
await LocalNotificationManager.markAllAsRead();
|
||||
};
|
||||
|
||||
registerEvent("markAllLocalNotificationsRead", markAllLocalNotificationsRead);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { LocalNotificationManager } from "@main/services";
|
||||
|
||||
const markLocalNotificationRead = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
id: string
|
||||
) => {
|
||||
await LocalNotificationManager.markAsRead(id);
|
||||
};
|
||||
|
||||
registerEvent("markLocalNotificationRead", markLocalNotificationRead);
|
||||
@@ -1,4 +1,4 @@
|
||||
// @generated by protobuf-ts 2.10.0
|
||||
// @generated by protobuf-ts 2.11.1
|
||||
// @generated from protobuf file "envelope.proto" (syntax proto3)
|
||||
// tslint:disable
|
||||
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
|
||||
@@ -15,11 +15,11 @@ import { MessageType } from "@protobuf-ts/runtime";
|
||||
*/
|
||||
export interface FriendRequest {
|
||||
/**
|
||||
* @generated from protobuf field: int32 friend_request_count = 1;
|
||||
* @generated from protobuf field: int32 friend_request_count = 1
|
||||
*/
|
||||
friendRequestCount: number;
|
||||
/**
|
||||
* @generated from protobuf field: optional string sender_id = 2;
|
||||
* @generated from protobuf field: optional string sender_id = 2
|
||||
*/
|
||||
senderId?: string;
|
||||
}
|
||||
@@ -28,18 +28,27 @@ export interface FriendRequest {
|
||||
*/
|
||||
export interface FriendGameSession {
|
||||
/**
|
||||
* @generated from protobuf field: string object_id = 1;
|
||||
* @generated from protobuf field: string object_id = 1
|
||||
*/
|
||||
objectId: string;
|
||||
/**
|
||||
* @generated from protobuf field: string shop = 2;
|
||||
* @generated from protobuf field: string shop = 2
|
||||
*/
|
||||
shop: string;
|
||||
/**
|
||||
* @generated from protobuf field: string friend_id = 3;
|
||||
* @generated from protobuf field: string friend_id = 3
|
||||
*/
|
||||
friendId: string;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Notification
|
||||
*/
|
||||
export interface Notification {
|
||||
/**
|
||||
* @generated from protobuf field: int32 notification_count = 1
|
||||
*/
|
||||
notificationCount: number;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message Envelope
|
||||
*/
|
||||
@@ -51,17 +60,24 @@ export interface Envelope {
|
||||
| {
|
||||
oneofKind: "friendRequest";
|
||||
/**
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1;
|
||||
* @generated from protobuf field: FriendRequest friend_request = 1
|
||||
*/
|
||||
friendRequest: FriendRequest;
|
||||
}
|
||||
| {
|
||||
oneofKind: "friendGameSession";
|
||||
/**
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
|
||||
* @generated from protobuf field: FriendGameSession friend_game_session = 2
|
||||
*/
|
||||
friendGameSession: FriendGameSession;
|
||||
}
|
||||
| {
|
||||
oneofKind: "notification";
|
||||
/**
|
||||
* @generated from protobuf field: Notification notification = 3
|
||||
*/
|
||||
notification: Notification;
|
||||
}
|
||||
| {
|
||||
oneofKind: undefined;
|
||||
};
|
||||
@@ -239,6 +255,80 @@ class FriendGameSession$Type extends MessageType<FriendGameSession> {
|
||||
*/
|
||||
export const FriendGameSession = new FriendGameSession$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Notification$Type extends MessageType<Notification> {
|
||||
constructor() {
|
||||
super("Notification", [
|
||||
{
|
||||
no: 1,
|
||||
name: "notification_count",
|
||||
kind: "scalar",
|
||||
T: 5 /*ScalarType.INT32*/,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Notification>): Notification {
|
||||
const message = globalThis.Object.create(this.messagePrototype!);
|
||||
message.notificationCount = 0;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<Notification>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(
|
||||
reader: IBinaryReader,
|
||||
length: number,
|
||||
options: BinaryReadOptions,
|
||||
target?: Notification
|
||||
): Notification {
|
||||
let message = target ?? this.create(),
|
||||
end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* int32 notification_count */ 1:
|
||||
message.notificationCount = reader.int32();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(
|
||||
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
|
||||
);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
fieldNo,
|
||||
wireType,
|
||||
d
|
||||
);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(
|
||||
message: Notification,
|
||||
writer: IBinaryWriter,
|
||||
options: BinaryWriteOptions
|
||||
): IBinaryWriter {
|
||||
/* int32 notification_count = 1; */
|
||||
if (message.notificationCount !== 0)
|
||||
writer.tag(1, WireType.Varint).int32(message.notificationCount);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
this.typeName,
|
||||
message,
|
||||
writer
|
||||
);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message Notification
|
||||
*/
|
||||
export const Notification = new Notification$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class Envelope$Type extends MessageType<Envelope> {
|
||||
constructor() {
|
||||
super("Envelope", [
|
||||
@@ -256,6 +346,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
oneof: "payload",
|
||||
T: () => FriendGameSession,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
name: "notification",
|
||||
kind: "message",
|
||||
oneof: "payload",
|
||||
T: () => Notification,
|
||||
},
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<Envelope>): Envelope {
|
||||
@@ -298,6 +395,17 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
),
|
||||
};
|
||||
break;
|
||||
case /* Notification notification */ 3:
|
||||
message.payload = {
|
||||
oneofKind: "notification",
|
||||
notification: Notification.internalBinaryRead(
|
||||
reader,
|
||||
reader.uint32(),
|
||||
options,
|
||||
(message.payload as any).notification
|
||||
),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
@@ -336,6 +444,13 @@ class Envelope$Type extends MessageType<Envelope> {
|
||||
writer.tag(2, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
/* Notification notification = 3; */
|
||||
if (message.payload.oneofKind === "notification")
|
||||
Notification.internalBinaryWrite(
|
||||
message.payload.notification,
|
||||
writer.tag(3, WireType.LengthDelimited).fork(),
|
||||
options
|
||||
).join();
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./keys";
|
||||
export * from "./themes";
|
||||
export * from "./download-sources";
|
||||
export * from "./downloadSourcesCheckTimestamp";
|
||||
export * from "./local-notifications";
|
||||
|
||||
@@ -20,4 +20,5 @@ export const levelKeys = {
|
||||
downloadSources: "downloadSources",
|
||||
downloadSourcesCheckBaseline: "downloadSourcesCheckBaseline", // When we last started the app
|
||||
downloadSourcesSinceValue: "downloadSourcesSinceValue", // The 'since' value API used (for modal comparison)
|
||||
localNotifications: "localNotifications",
|
||||
};
|
||||
|
||||
11
src/main/level/sublevels/local-notifications.ts
Normal file
11
src/main/level/sublevels/local-notifications.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LocalNotification } from "@types";
|
||||
|
||||
import { db } from "../level";
|
||||
import { levelKeys } from "./keys";
|
||||
|
||||
export const localNotificationsSublevel = db.sublevel<
|
||||
string,
|
||||
LocalNotification
|
||||
>(levelKeys.localNotifications, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
@@ -20,3 +20,4 @@ export * from "./lock";
|
||||
export * from "./decky-plugin";
|
||||
export * from "./user";
|
||||
export * from "./download-sources-checker";
|
||||
export * from "./notifications/local-notifications";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-in
|
||||
import { SystemPath } from "../system-path";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
import { processProfileImage } from "@main/events/profile/process-profile-image";
|
||||
import { LocalNotificationManager } from "./local-notifications";
|
||||
|
||||
const getStaticImage = async (path: string) => {
|
||||
return processProfileImage(path, "jpg")
|
||||
@@ -78,37 +79,59 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
|
||||
}
|
||||
);
|
||||
|
||||
const title = t("download_complete", { ns: "notifications" });
|
||||
const body = t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
});
|
||||
|
||||
if (userPreferences?.downloadNotificationsEnabled) {
|
||||
new Notification({
|
||||
title: t("download_complete", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
body: t("game_ready_to_install", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: await downloadImage(game.iconUrl),
|
||||
}).show();
|
||||
}
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"DOWNLOAD_COMPLETE",
|
||||
title,
|
||||
body,
|
||||
{
|
||||
pictureUrl: game.iconUrl,
|
||||
url: `/game/${game.shop}/${game.objectId}`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNotificationUpdateReadyToInstall = async (
|
||||
version: string
|
||||
) => {
|
||||
const title = t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
});
|
||||
const body = t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
});
|
||||
|
||||
new Notification({
|
||||
title: t("new_update_available", {
|
||||
ns: "notifications",
|
||||
version,
|
||||
}),
|
||||
body: t("restart_to_install_update", {
|
||||
ns: "notifications",
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: trayIcon,
|
||||
})
|
||||
.on("click", () => {
|
||||
restartAndInstallUpdate();
|
||||
})
|
||||
.show();
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"UPDATE_AVAILABLE",
|
||||
title,
|
||||
body
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNewFriendRequestNotification = async (
|
||||
@@ -181,14 +204,27 @@ export const publishCombinedNewAchievementNotification = async (
|
||||
};
|
||||
|
||||
export const publishExtractionCompleteNotification = async (game: Game) => {
|
||||
const title = t("extraction_complete", { ns: "notifications" });
|
||||
const body = t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
});
|
||||
|
||||
new Notification({
|
||||
title: t("extraction_complete", { ns: "notifications" }),
|
||||
body: t("game_extracted", {
|
||||
ns: "notifications",
|
||||
title: game.title,
|
||||
}),
|
||||
title,
|
||||
body,
|
||||
icon: trayIcon,
|
||||
}).show();
|
||||
|
||||
// Create local notification
|
||||
await LocalNotificationManager.createNotification(
|
||||
"EXTRACTION_COMPLETE",
|
||||
title,
|
||||
body,
|
||||
{
|
||||
url: `/game/${game.shop}/${game.objectId}`,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const publishNewAchievementNotification = async (info: {
|
||||
|
||||
99
src/main/services/notifications/local-notifications.ts
Normal file
99
src/main/services/notifications/local-notifications.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { localNotificationsSublevel } from "@main/level";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { LocalNotification, LocalNotificationType } from "@types";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export class LocalNotificationManager {
|
||||
private static generateId(): string {
|
||||
return crypto.randomBytes(8).toString("hex");
|
||||
}
|
||||
|
||||
static async createNotification(
|
||||
type: LocalNotificationType,
|
||||
title: string,
|
||||
description: string,
|
||||
options?: {
|
||||
pictureUrl?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
): Promise<LocalNotification> {
|
||||
const id = this.generateId();
|
||||
const notification: LocalNotification = {
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
pictureUrl: options?.pictureUrl ?? null,
|
||||
url: options?.url ?? null,
|
||||
isRead: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
|
||||
// Notify renderer about new notification
|
||||
if (WindowManager.mainWindow) {
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-local-notification-created",
|
||||
notification
|
||||
);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
static async getNotifications(): Promise<LocalNotification[]> {
|
||||
const notifications: LocalNotification[] = [];
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
notifications.push(value);
|
||||
}
|
||||
|
||||
// Sort by createdAt descending
|
||||
return notifications.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
static async getUnreadCount(): Promise<number> {
|
||||
let count = 0;
|
||||
|
||||
for await (const [, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
static async markAsRead(id: string): Promise<void> {
|
||||
const notification = await localNotificationsSublevel.get(id);
|
||||
if (notification) {
|
||||
notification.isRead = true;
|
||||
await localNotificationsSublevel.put(id, notification);
|
||||
}
|
||||
}
|
||||
|
||||
static async markAllAsRead(): Promise<void> {
|
||||
const batch = localNotificationsSublevel.batch();
|
||||
|
||||
for await (const [key, value] of localNotificationsSublevel.iterator()) {
|
||||
if (!value.isRead) {
|
||||
value.isRead = true;
|
||||
batch.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.write();
|
||||
}
|
||||
|
||||
static async deleteNotification(id: string): Promise<void> {
|
||||
await localNotificationsSublevel.del(id);
|
||||
}
|
||||
|
||||
static async clearAll(): Promise<void> {
|
||||
await localNotificationsSublevel.clear();
|
||||
}
|
||||
}
|
||||
8
src/main/services/ws/events/notification.ts
Normal file
8
src/main/services/ws/events/notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Notification } from "@main/generated/envelope";
|
||||
import { WindowManager } from "@main/services/window-manager";
|
||||
|
||||
export const notificationEvent = (payload: Notification) => {
|
||||
WindowManager.mainWindow?.webContents.send("on-sync-notification-count", {
|
||||
notificationCount: payload.notificationCount,
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
|
||||
import { logger } from "../logger";
|
||||
import { friendRequestEvent } from "./events/friend-request";
|
||||
import { friendGameSessionEvent } from "./events/friend-game-session";
|
||||
import { notificationEvent } from "./events/notification";
|
||||
|
||||
export class WSClient {
|
||||
private static ws: WebSocket | null = null;
|
||||
@@ -51,6 +52,10 @@ export class WSClient {
|
||||
if (envelope.payload.oneofKind === "friendGameSession") {
|
||||
friendGameSessionEvent(envelope.payload.friendGameSession);
|
||||
}
|
||||
|
||||
if (envelope.payload.oneofKind === "notification") {
|
||||
notificationEvent(envelope.payload.notification);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on("close", () => this.handleDisconnect("close"));
|
||||
|
||||
Reference in New Issue
Block a user