From bb8f4ba14912b5114d7d7fffc0e0c4d5b9e2c33a Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 6 Oct 2025 16:18:36 +0530 Subject: [PATCH 1/2] feat: support for user notificationsfrom server entitlments --- src/services/EntitlementsManager.js | 34 ++++ src/services/UserNotifications.js | 300 ++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 src/services/UserNotifications.js diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js index adf7d89111..e5b9acfacc 100644 --- a/src/services/EntitlementsManager.js +++ b/src/services/EntitlementsManager.js @@ -32,6 +32,7 @@ define(function (require, exports, module) { const EventDispatcher = require("utils/EventDispatcher"), AIControl = require("./ai-control"), + UserNotifications = require("./UserNotifications"), Strings = require("strings"), StringUtils = require("utils/StringUtils"); @@ -133,6 +134,36 @@ define(function (require, exports, module) { return await LoginService.getEntitlements(); } + /** + * Get notifications array from entitlements. Uses cached entitlements when available. + * Notifications are used to display in-app promotional messages, alerts, or announcements to users. + * + * @returns {Promise} Array of notification objects, empty array if no notifications available + * + * @description Each notification object has the following structure: + * ```javascript + * { + * notificationID: string, // Unique UUID to track if notification was shown + * title: string, // Notification title + * htmlContent: string, // HTML content of the notification message + * validTill: number, // Timestamp when notification expires + * options: { + * autoCloseTimeS: number, // Optional: Time in seconds for auto-close. Default: never + * dismissOnClick: boolean, // Optional: Close on click. Default: true + * toastStyle: string // Optional: Style class (NOTIFICATION_STYLES_CSS_CLASS.INFO, etc.) + * } + * } + * ``` + */ + async function getNotifications() { + const entitlements = await _getEffectiveEntitlements(); + + if (entitlements && entitlements.notifications) { + return entitlements.notifications; + } + return []; + } + /** * Get live edit is enabled for user, based on his logged in pro-user/trial status. * @@ -297,6 +328,7 @@ define(function (require, exports, module) { EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); }); AIControl.init(); + UserNotifications.init(); } // Test-only exports for integration testing @@ -308,6 +340,7 @@ define(function (require, exports, module) { isInProTrial, getTrialRemainingDays, getRawEntitlements, + getNotifications, getLiveEditEntitlement, loginToAccount }; @@ -323,6 +356,7 @@ define(function (require, exports, module) { EntitlementsManager.isInProTrial = isInProTrial; EntitlementsManager.getTrialRemainingDays = getTrialRemainingDays; EntitlementsManager.getRawEntitlements = getRawEntitlements; + EntitlementsManager.getNotifications = getNotifications; EntitlementsManager.getLiveEditEntitlement = getLiveEditEntitlement; EntitlementsManager.getAIEntitlement = getAIEntitlement; EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; diff --git a/src/services/UserNotifications.js b/src/services/UserNotifications.js new file mode 100644 index 0000000000..d7804b55db --- /dev/null +++ b/src/services/UserNotifications.js @@ -0,0 +1,300 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * User Notifications Service + * + * This module handles server-sent notifications from the entitlements API. + * Notifications are displayed as toast messages and acknowledged back to the server. + */ + +define(function (require, exports, module) { + const KernalModeTrust = window.KernalModeTrust; + if(!KernalModeTrust){ + throw new Error("UserNotifications should have access to KernalModeTrust. Cannot boot without trust ring"); + } + + const PreferencesManager = require("preferences/PreferencesManager"), + NotificationUI = require("widgets/NotificationUI"); + + const PREF_NOTIFICATIONS_SHOWN_LIST = "notificationsShownList"; + PreferencesManager.stateManager.definePreference(PREF_NOTIFICATIONS_SHOWN_LIST, "object", {}); + + let EntitlementsManager; + let LoginService; + + // In-memory tracking to prevent duplicate notifications during rapid EVENT_ENTITLEMENTS_CHANGED events + const currentlyShownNotifications = new Set(); + + // Save a copy of window.fetch so that extensions won't tamper with it + let fetchFn = window.fetch; + + /** + * Get the list of notification IDs that have been shown and acknowledged + * @returns {Object} Map of notificationID -> timestamp + */ + function getShownNotifications() { + return PreferencesManager.stateManager.get(PREF_NOTIFICATIONS_SHOWN_LIST) || {}; + } + + /** + * Mark a notification as shown and acknowledged + * @param {string} notificationID - The notification ID to mark as shown + */ + function markNotificationAsShown(notificationID) { + const shownNotifications = getShownNotifications(); + shownNotifications[notificationID] = Date.now(); + PreferencesManager.stateManager.set(PREF_NOTIFICATIONS_SHOWN_LIST, shownNotifications); + currentlyShownNotifications.delete(notificationID); + } + + /** + * Call the server API to acknowledge a notification + * @param {string} notificationID - The notification ID to acknowledge + * @returns {Promise} - True if successful, false otherwise + */ + async function acknowledgeNotificationToServer(notificationID) { + try { + const accountBaseURL = LoginService.getAccountBaseURL(); + let url = `${accountBaseURL}/notificationAcknowledged`; + + const requestBody = { + notificationID: notificationID + }; + + let fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(requestBody) + }; + + // Handle different authentication methods for browser vs desktop + if (Phoenix.isNativeApp) { + // Desktop app: use appSessionID and validationCode + const profile = LoginService.getProfile(); + if (profile && profile.apiKey && profile.validationCode) { + requestBody.appSessionID = profile.apiKey; + requestBody.validationCode = profile.validationCode; + fetchOptions.body = JSON.stringify(requestBody); + } + } else { + // Browser app: use session cookies + fetchOptions.credentials = 'include'; + } + + const response = await fetchFn(url, fetchOptions); + + if (response.ok) { + const result = await response.json(); + if (result.isSuccess) { + console.log(`Notification ${notificationID} acknowledged successfully`); + return true; + } + } + + console.warn(`Failed to acknowledge notification ${notificationID}:`, response.status); + return false; + } catch (error) { + console.error(`Error acknowledging notification ${notificationID}:`, error); + return false; + } + } + + /** + * Handle notification dismissal + * @param {string} notificationID - The notification ID that was dismissed + */ + async function handleNotificationDismiss(notificationID) { + // Call server API to acknowledge (don't wait for success) + await acknowledgeNotificationToServer(notificationID); + + // Always mark as shown locally to prevent re-showing, even if API fails + markNotificationAsShown(notificationID); + } + + /** + * Check if a notification should be shown + * @param {Object} notification - The notification object from server + * @returns {boolean} - True if should be shown, false otherwise + */ + function shouldShowNotification(notification) { + if (!notification || !notification.notificationID) { + return false; + } + + // Check if expired + if (notification.validTill && Date.now() > notification.validTill) { + return false; + } + + // Check if already shown (persistent storage) + const shownNotifications = getShownNotifications(); + if (shownNotifications[notification.notificationID]) { + return false; + } + + // Check if currently being shown (in-memory) + if (currentlyShownNotifications.has(notification.notificationID)) { + return false; + } + + return true; + } + + /** + * Display a single notification + * @param {Object} notification - The notification object from server + */ + function displayNotification(notification) { + const { + notificationID, + title, + htmlContent, + options = {} + } = notification; + + // Mark as currently showing to prevent duplicates + currentlyShownNotifications.add(notificationID); + + // Prepare options for NotificationUI + const toastOptions = { + dismissOnClick: options.dismissOnClick !== undefined ? options.dismissOnClick : true, + toastStyle: options.toastStyle || NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.INFO + }; + + // Add autoCloseTimeS if provided + if (options.autoCloseTimeS) { + toastOptions.autoCloseTimeS = options.autoCloseTimeS; + } + + // Create and show the toast notification + const notificationInstance = NotificationUI.createToastFromTemplate( + title, + htmlContent, + toastOptions + ); + + // Handle notification dismissal + notificationInstance.done(() => { + handleNotificationDismiss(notificationID); + }); + } + + /** + * Clean up stale notification IDs from state manager + * Removes notification IDs that are no longer in the remote notifications list + * @param {Array} remoteNotifications - The current notifications from server + */ + function cleanupStaleNotifications(remoteNotifications) { + if (!remoteNotifications || remoteNotifications.length === 0) { + return; + } + + // Build a set of remote notification IDs for quick lookup + const remoteIDs = new Set(); + for (const notification of remoteNotifications) { + if (notification.notificationID) { + remoteIDs.add(notification.notificationID); + } + } + + // Keep only notification IDs that are still in remote notifications + const shownNotifications = getShownNotifications(); + const updatedShownNotifications = {}; + for (const id in shownNotifications) { + if (remoteIDs.has(id)) { + updatedShownNotifications[id] = shownNotifications[id]; + } + } + + // Update state if we removed any stale IDs + const oldCount = Object.keys(shownNotifications).length; + const newCount = Object.keys(updatedShownNotifications).length; + if (newCount < oldCount) { + console.log(`Cleaning up ${oldCount - newCount} stale notification ID(s) from state`); + PreferencesManager.stateManager.set(PREF_NOTIFICATIONS_SHOWN_LIST, updatedShownNotifications); + } + } + + /** + * Process notifications from entitlements + */ + async function processNotifications() { + try { + const notifications = await EntitlementsManager.getNotifications(); + + if (!notifications || !Array.isArray(notifications)) { + return; + } + + // Clean up stale notification IDs if we have at least 1 notification from server + if (notifications.length > 0) { + cleanupStaleNotifications(notifications); + } + + // Filter and show new notifications + const notificationsToShow = notifications.filter(shouldShowNotification); + + if (notificationsToShow.length > 0) { + console.log(`Showing ${notificationsToShow.length} new notification(s)`); + notificationsToShow.forEach(displayNotification); + } + } catch (error) { + console.error('Error processing notifications:', error); + } + } + + /** + * Initialize the UserNotifications service + */ + function init() { + EntitlementsManager = KernalModeTrust.EntitlementsManager; + LoginService = KernalModeTrust.loginService; + + if (!EntitlementsManager || !LoginService) { + throw new Error("UserNotifications requires EntitlementsManager and LoginService in KernalModeTrust"); + } + + // Listen for entitlements changes + EntitlementsManager.on(EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, processNotifications); + + console.log('UserNotifications service initialized'); + } + + // Test-only exports for integration testing + if (Phoenix.isTestWindow) { + window._test_user_notifications_exports = { + getShownNotifications, + markNotificationAsShown, + shouldShowNotification, + acknowledgeNotificationToServer, + processNotifications, + cleanupStaleNotifications, + currentlyShownNotifications, + setFetchFn: function (fn) { + fetchFn = fn; + } + }; + } + + exports.init = init; + // no public exports to prevent extension tampering +}); From c614dda8c6142cd4c389862fcdcf54c2ae59d3ca Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 6 Oct 2025 16:23:14 +0530 Subject: [PATCH 2/2] chore: change ordering for notification commit --- src/services/UserNotifications.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/UserNotifications.js b/src/services/UserNotifications.js index d7804b55db..98888fbe11 100644 --- a/src/services/UserNotifications.js +++ b/src/services/UserNotifications.js @@ -123,11 +123,11 @@ define(function (require, exports, module) { * @param {string} notificationID - The notification ID that was dismissed */ async function handleNotificationDismiss(notificationID) { - // Call server API to acknowledge (don't wait for success) - await acknowledgeNotificationToServer(notificationID); - // Always mark as shown locally to prevent re-showing, even if API fails markNotificationAsShown(notificationID); + + // Call server API to acknowledge + return acknowledgeNotificationToServer(notificationID); } /**