Skip to content

Commit bb8f4ba

Browse files
committed
feat: support for user notificationsfrom server entitlments
1 parent 9080d40 commit bb8f4ba

File tree

2 files changed

+334
-0
lines changed

2 files changed

+334
-0
lines changed

src/services/EntitlementsManager.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ define(function (require, exports, module) {
3232

3333
const EventDispatcher = require("utils/EventDispatcher"),
3434
AIControl = require("./ai-control"),
35+
UserNotifications = require("./UserNotifications"),
3536
Strings = require("strings"),
3637
StringUtils = require("utils/StringUtils");
3738

@@ -133,6 +134,36 @@ define(function (require, exports, module) {
133134
return await LoginService.getEntitlements();
134135
}
135136

137+
/**
138+
* Get notifications array from entitlements. Uses cached entitlements when available.
139+
* Notifications are used to display in-app promotional messages, alerts, or announcements to users.
140+
*
141+
* @returns {Promise<Array>} Array of notification objects, empty array if no notifications available
142+
*
143+
* @description Each notification object has the following structure:
144+
* ```javascript
145+
* {
146+
* notificationID: string, // Unique UUID to track if notification was shown
147+
* title: string, // Notification title
148+
* htmlContent: string, // HTML content of the notification message
149+
* validTill: number, // Timestamp when notification expires
150+
* options: {
151+
* autoCloseTimeS: number, // Optional: Time in seconds for auto-close. Default: never
152+
* dismissOnClick: boolean, // Optional: Close on click. Default: true
153+
* toastStyle: string // Optional: Style class (NOTIFICATION_STYLES_CSS_CLASS.INFO, etc.)
154+
* }
155+
* }
156+
* ```
157+
*/
158+
async function getNotifications() {
159+
const entitlements = await _getEffectiveEntitlements();
160+
161+
if (entitlements && entitlements.notifications) {
162+
return entitlements.notifications;
163+
}
164+
return [];
165+
}
166+
136167
/**
137168
* Get live edit is enabled for user, based on his logged in pro-user/trial status.
138169
*
@@ -297,6 +328,7 @@ define(function (require, exports, module) {
297328
EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED);
298329
});
299330
AIControl.init();
331+
UserNotifications.init();
300332
}
301333

302334
// Test-only exports for integration testing
@@ -308,6 +340,7 @@ define(function (require, exports, module) {
308340
isInProTrial,
309341
getTrialRemainingDays,
310342
getRawEntitlements,
343+
getNotifications,
311344
getLiveEditEntitlement,
312345
loginToAccount
313346
};
@@ -323,6 +356,7 @@ define(function (require, exports, module) {
323356
EntitlementsManager.isInProTrial = isInProTrial;
324357
EntitlementsManager.getTrialRemainingDays = getTrialRemainingDays;
325358
EntitlementsManager.getRawEntitlements = getRawEntitlements;
359+
EntitlementsManager.getNotifications = getNotifications;
326360
EntitlementsManager.getLiveEditEntitlement = getLiveEditEntitlement;
327361
EntitlementsManager.getAIEntitlement = getAIEntitlement;
328362
EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED;

src/services/UserNotifications.js

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it under
7+
* the terms of the GNU Affero General Public License as published by the Free
8+
* Software Foundation, either version 3 of the License, or (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
11+
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License along
15+
* with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
16+
*
17+
*/
18+
19+
/**
20+
* User Notifications Service
21+
*
22+
* This module handles server-sent notifications from the entitlements API.
23+
* Notifications are displayed as toast messages and acknowledged back to the server.
24+
*/
25+
26+
define(function (require, exports, module) {
27+
const KernalModeTrust = window.KernalModeTrust;
28+
if(!KernalModeTrust){
29+
throw new Error("UserNotifications should have access to KernalModeTrust. Cannot boot without trust ring");
30+
}
31+
32+
const PreferencesManager = require("preferences/PreferencesManager"),
33+
NotificationUI = require("widgets/NotificationUI");
34+
35+
const PREF_NOTIFICATIONS_SHOWN_LIST = "notificationsShownList";
36+
PreferencesManager.stateManager.definePreference(PREF_NOTIFICATIONS_SHOWN_LIST, "object", {});
37+
38+
let EntitlementsManager;
39+
let LoginService;
40+
41+
// In-memory tracking to prevent duplicate notifications during rapid EVENT_ENTITLEMENTS_CHANGED events
42+
const currentlyShownNotifications = new Set();
43+
44+
// Save a copy of window.fetch so that extensions won't tamper with it
45+
let fetchFn = window.fetch;
46+
47+
/**
48+
* Get the list of notification IDs that have been shown and acknowledged
49+
* @returns {Object} Map of notificationID -> timestamp
50+
*/
51+
function getShownNotifications() {
52+
return PreferencesManager.stateManager.get(PREF_NOTIFICATIONS_SHOWN_LIST) || {};
53+
}
54+
55+
/**
56+
* Mark a notification as shown and acknowledged
57+
* @param {string} notificationID - The notification ID to mark as shown
58+
*/
59+
function markNotificationAsShown(notificationID) {
60+
const shownNotifications = getShownNotifications();
61+
shownNotifications[notificationID] = Date.now();
62+
PreferencesManager.stateManager.set(PREF_NOTIFICATIONS_SHOWN_LIST, shownNotifications);
63+
currentlyShownNotifications.delete(notificationID);
64+
}
65+
66+
/**
67+
* Call the server API to acknowledge a notification
68+
* @param {string} notificationID - The notification ID to acknowledge
69+
* @returns {Promise<boolean>} - True if successful, false otherwise
70+
*/
71+
async function acknowledgeNotificationToServer(notificationID) {
72+
try {
73+
const accountBaseURL = LoginService.getAccountBaseURL();
74+
let url = `${accountBaseURL}/notificationAcknowledged`;
75+
76+
const requestBody = {
77+
notificationID: notificationID
78+
};
79+
80+
let fetchOptions = {
81+
method: 'POST',
82+
headers: {
83+
'Content-Type': 'application/json',
84+
'Accept': 'application/json'
85+
},
86+
body: JSON.stringify(requestBody)
87+
};
88+
89+
// Handle different authentication methods for browser vs desktop
90+
if (Phoenix.isNativeApp) {
91+
// Desktop app: use appSessionID and validationCode
92+
const profile = LoginService.getProfile();
93+
if (profile && profile.apiKey && profile.validationCode) {
94+
requestBody.appSessionID = profile.apiKey;
95+
requestBody.validationCode = profile.validationCode;
96+
fetchOptions.body = JSON.stringify(requestBody);
97+
}
98+
} else {
99+
// Browser app: use session cookies
100+
fetchOptions.credentials = 'include';
101+
}
102+
103+
const response = await fetchFn(url, fetchOptions);
104+
105+
if (response.ok) {
106+
const result = await response.json();
107+
if (result.isSuccess) {
108+
console.log(`Notification ${notificationID} acknowledged successfully`);
109+
return true;
110+
}
111+
}
112+
113+
console.warn(`Failed to acknowledge notification ${notificationID}:`, response.status);
114+
return false;
115+
} catch (error) {
116+
console.error(`Error acknowledging notification ${notificationID}:`, error);
117+
return false;
118+
}
119+
}
120+
121+
/**
122+
* Handle notification dismissal
123+
* @param {string} notificationID - The notification ID that was dismissed
124+
*/
125+
async function handleNotificationDismiss(notificationID) {
126+
// Call server API to acknowledge (don't wait for success)
127+
await acknowledgeNotificationToServer(notificationID);
128+
129+
// Always mark as shown locally to prevent re-showing, even if API fails
130+
markNotificationAsShown(notificationID);
131+
}
132+
133+
/**
134+
* Check if a notification should be shown
135+
* @param {Object} notification - The notification object from server
136+
* @returns {boolean} - True if should be shown, false otherwise
137+
*/
138+
function shouldShowNotification(notification) {
139+
if (!notification || !notification.notificationID) {
140+
return false;
141+
}
142+
143+
// Check if expired
144+
if (notification.validTill && Date.now() > notification.validTill) {
145+
return false;
146+
}
147+
148+
// Check if already shown (persistent storage)
149+
const shownNotifications = getShownNotifications();
150+
if (shownNotifications[notification.notificationID]) {
151+
return false;
152+
}
153+
154+
// Check if currently being shown (in-memory)
155+
if (currentlyShownNotifications.has(notification.notificationID)) {
156+
return false;
157+
}
158+
159+
return true;
160+
}
161+
162+
/**
163+
* Display a single notification
164+
* @param {Object} notification - The notification object from server
165+
*/
166+
function displayNotification(notification) {
167+
const {
168+
notificationID,
169+
title,
170+
htmlContent,
171+
options = {}
172+
} = notification;
173+
174+
// Mark as currently showing to prevent duplicates
175+
currentlyShownNotifications.add(notificationID);
176+
177+
// Prepare options for NotificationUI
178+
const toastOptions = {
179+
dismissOnClick: options.dismissOnClick !== undefined ? options.dismissOnClick : true,
180+
toastStyle: options.toastStyle || NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.INFO
181+
};
182+
183+
// Add autoCloseTimeS if provided
184+
if (options.autoCloseTimeS) {
185+
toastOptions.autoCloseTimeS = options.autoCloseTimeS;
186+
}
187+
188+
// Create and show the toast notification
189+
const notificationInstance = NotificationUI.createToastFromTemplate(
190+
title,
191+
htmlContent,
192+
toastOptions
193+
);
194+
195+
// Handle notification dismissal
196+
notificationInstance.done(() => {
197+
handleNotificationDismiss(notificationID);
198+
});
199+
}
200+
201+
/**
202+
* Clean up stale notification IDs from state manager
203+
* Removes notification IDs that are no longer in the remote notifications list
204+
* @param {Array} remoteNotifications - The current notifications from server
205+
*/
206+
function cleanupStaleNotifications(remoteNotifications) {
207+
if (!remoteNotifications || remoteNotifications.length === 0) {
208+
return;
209+
}
210+
211+
// Build a set of remote notification IDs for quick lookup
212+
const remoteIDs = new Set();
213+
for (const notification of remoteNotifications) {
214+
if (notification.notificationID) {
215+
remoteIDs.add(notification.notificationID);
216+
}
217+
}
218+
219+
// Keep only notification IDs that are still in remote notifications
220+
const shownNotifications = getShownNotifications();
221+
const updatedShownNotifications = {};
222+
for (const id in shownNotifications) {
223+
if (remoteIDs.has(id)) {
224+
updatedShownNotifications[id] = shownNotifications[id];
225+
}
226+
}
227+
228+
// Update state if we removed any stale IDs
229+
const oldCount = Object.keys(shownNotifications).length;
230+
const newCount = Object.keys(updatedShownNotifications).length;
231+
if (newCount < oldCount) {
232+
console.log(`Cleaning up ${oldCount - newCount} stale notification ID(s) from state`);
233+
PreferencesManager.stateManager.set(PREF_NOTIFICATIONS_SHOWN_LIST, updatedShownNotifications);
234+
}
235+
}
236+
237+
/**
238+
* Process notifications from entitlements
239+
*/
240+
async function processNotifications() {
241+
try {
242+
const notifications = await EntitlementsManager.getNotifications();
243+
244+
if (!notifications || !Array.isArray(notifications)) {
245+
return;
246+
}
247+
248+
// Clean up stale notification IDs if we have at least 1 notification from server
249+
if (notifications.length > 0) {
250+
cleanupStaleNotifications(notifications);
251+
}
252+
253+
// Filter and show new notifications
254+
const notificationsToShow = notifications.filter(shouldShowNotification);
255+
256+
if (notificationsToShow.length > 0) {
257+
console.log(`Showing ${notificationsToShow.length} new notification(s)`);
258+
notificationsToShow.forEach(displayNotification);
259+
}
260+
} catch (error) {
261+
console.error('Error processing notifications:', error);
262+
}
263+
}
264+
265+
/**
266+
* Initialize the UserNotifications service
267+
*/
268+
function init() {
269+
EntitlementsManager = KernalModeTrust.EntitlementsManager;
270+
LoginService = KernalModeTrust.loginService;
271+
272+
if (!EntitlementsManager || !LoginService) {
273+
throw new Error("UserNotifications requires EntitlementsManager and LoginService in KernalModeTrust");
274+
}
275+
276+
// Listen for entitlements changes
277+
EntitlementsManager.on(EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, processNotifications);
278+
279+
console.log('UserNotifications service initialized');
280+
}
281+
282+
// Test-only exports for integration testing
283+
if (Phoenix.isTestWindow) {
284+
window._test_user_notifications_exports = {
285+
getShownNotifications,
286+
markNotificationAsShown,
287+
shouldShowNotification,
288+
acknowledgeNotificationToServer,
289+
processNotifications,
290+
cleanupStaleNotifications,
291+
currentlyShownNotifications,
292+
setFetchFn: function (fn) {
293+
fetchFn = fn;
294+
}
295+
};
296+
}
297+
298+
exports.init = init;
299+
// no public exports to prevent extension tampering
300+
});

0 commit comments

Comments
 (0)