From c655d583b3a82576fe7c57ace37a8f160dd976cf Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 12:00:57 -0700 Subject: [PATCH 1/9] more prefixing of methods --- package.json | 4 +- src/core/modelRepo/ModelStore.ts | 8 +- src/core/modelStores/SingletonModelStore.ts | 4 +- src/core/models/Model.ts | 4 +- src/onesignal/SessionNamespace.ts | 16 ++-- src/shared/helpers/EventProducer.ts | 28 ++----- src/shared/helpers/OutcomesHelper.ts | 81 ++++++++++--------- src/sw/serviceWorker/ServiceWorker.test.ts | 17 ++-- src/sw/serviceWorker/ServiceWorker.ts | 25 ++---- src/sw/webhooks/OSWebhookSender.ts | 37 --------- .../OSWebhookNotificationEventSender.ts | 60 -------------- .../notifications/webhookNotificationEvent.ts | 52 ++++++++++++ src/sw/webhooks/sender.ts | 35 ++++++++ 13 files changed, 170 insertions(+), 201 deletions(-) delete mode 100644 src/sw/webhooks/OSWebhookSender.ts delete mode 100644 src/sw/webhooks/notifications/OSWebhookNotificationEventSender.ts create mode 100644 src/sw/webhooks/notifications/webhookNotificationEvent.ts create mode 100644 src/sw/webhooks/sender.ts diff --git a/package.json b/package.json index 2eb20dacf..76c84737a 100644 --- a/package.json +++ b/package.json @@ -84,12 +84,12 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "47.565 kB", + "limit": "47.44 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "13.624 kB", + "limit": "13.58 kB", "gzip": true }, { diff --git a/src/core/modelRepo/ModelStore.ts b/src/core/modelRepo/ModelStore.ts index b7544d115..91892da0a 100644 --- a/src/core/modelRepo/ModelStore.ts +++ b/src/core/modelRepo/ModelStore.ts @@ -96,7 +96,7 @@ export abstract class ModelStore< _onChanged(args: ModelChangedArgs, tag: string): void { this._persist(); - this._changeSubscription.fire((handler) => + this._changeSubscription._fire((handler) => handler._onModelUpdated(args, tag), ); } @@ -116,7 +116,7 @@ export abstract class ModelStore< for (const item of this._models) { // no longer listen for changes to this model item._unsubscribe(this); - this._changeSubscription.fire((handler) => + this._changeSubscription._fire((handler) => handler._onModelRemoved(item, tag), ); db.delete(this._modelName, item._modelId); @@ -136,7 +136,7 @@ export abstract class ModelStore< model._subscribe(this); this._persist(); - this._changeSubscription.fire((handler) => + this._changeSubscription._fire((handler) => handler._onModelAdded(model, tag), ); } @@ -151,7 +151,7 @@ export abstract class ModelStore< await db.delete(this._modelName, model._modelId); this._persist(); - this._changeSubscription.fire((handler) => + this._changeSubscription._fire((handler) => handler._onModelRemoved(model, tag), ); } diff --git a/src/core/modelStores/SingletonModelStore.ts b/src/core/modelStores/SingletonModelStore.ts index ff85d1dd2..b0d169c8c 100644 --- a/src/core/modelStores/SingletonModelStore.ts +++ b/src/core/modelStores/SingletonModelStore.ts @@ -39,7 +39,7 @@ export class SingletonModelStore const existingModel = this.model; existingModel._initializeFromModel(existingModel._modelId, model); this.store._persist(); - this.changeSubscription.fire((handler) => + this.changeSubscription._fire((handler) => handler._onModelReplaced(existingModel, tag), ); } @@ -65,7 +65,7 @@ export class SingletonModelStore } _onModelUpdated(args: ModelChangedArgs, tag: ModelChangeTagValue): void { - this.changeSubscription.fire((handler) => + this.changeSubscription._fire((handler) => handler._onModelUpdated(args, tag), ); } diff --git a/src/core/models/Model.ts b/src/core/models/Model.ts index b1cea41d9..c4ac846d3 100644 --- a/src/core/models/Model.ts +++ b/src/core/models/Model.ts @@ -182,7 +182,9 @@ export class Model oldValue, newValue, }; - this._changeNotifier.fire((handler) => handler._onChanged(changeArgs, tag)); + this._changeNotifier._fire((handler) => + handler._onChanged(changeArgs, tag), + ); } /** diff --git a/src/onesignal/SessionNamespace.ts b/src/onesignal/SessionNamespace.ts index 90648761c..d7a21e5f1 100644 --- a/src/onesignal/SessionNamespace.ts +++ b/src/onesignal/SessionNamespace.ts @@ -27,13 +27,13 @@ export class SessionNamespace { return; } - if (!(await outcomesHelper.beforeOutcomeSend())) { + if (!(await outcomesHelper._beforeOutcomeSend())) { return; } - const outcomeAttribution = await outcomesHelper.getAttribution(); + const outcomeAttribution = await outcomesHelper._getAttribution(); - await outcomesHelper.send({ + await outcomesHelper._send({ type: outcomeAttribution.type, notificationIds: outcomeAttribution.notificationIds, weight: outcomeWeight, @@ -54,10 +54,10 @@ export class SessionNamespace { true, ); - if (!(await outcomesHelper.beforeOutcomeSend())) { + if (!(await outcomesHelper._beforeOutcomeSend())) { return; } - const outcomeAttribution = await outcomesHelper.getAttribution(); + const outcomeAttribution = await outcomesHelper._getAttribution(); if (outcomeAttribution.type === OutcomeAttributionType.NotSupported) { Log._warn( @@ -70,12 +70,12 @@ export class SessionNamespace { const { notificationIds } = outcomeAttribution; // only new notifs that ought to be attributed const newNotifsToAttributeWithOutcome = - await outcomesHelper.getNotifsToAttributeWithUniqueOutcome( + await outcomesHelper._getNotifsToAttributeWithUniqueOutcome( notificationIds, ); if ( - !outcomesHelper.shouldSendUnique( + !outcomesHelper._shouldSendUnique( outcomeAttribution, newNotifsToAttributeWithOutcome, ) @@ -84,7 +84,7 @@ export class SessionNamespace { return; } - await outcomesHelper.send({ + await outcomesHelper._send({ type: outcomeAttribution.type, notificationIds: newNotifsToAttributeWithOutcome, }); diff --git a/src/shared/helpers/EventProducer.ts b/src/shared/helpers/EventProducer.ts index 9663e9f82..372f38efe 100644 --- a/src/shared/helpers/EventProducer.ts +++ b/src/shared/helpers/EventProducer.ts @@ -1,40 +1,26 @@ import type { IEventNotifier } from 'src/core/types/models'; export class EventProducer implements IEventNotifier { - private subscribers: THandler[] = []; + private _subscribers: THandler[] = []; get _hasSubscribers(): boolean { - return this.subscribers.length > 0; + return this._subscribers.length > 0; } _subscribe(handler: THandler): void { - this.subscribers.push(handler); + this._subscribers.push(handler); } _unsubscribe(handler: THandler): void { - const index = this.subscribers.indexOf(handler); + const index = this._subscribers.indexOf(handler); if (index !== -1) { - this.subscribers.splice(index, 1); + this._subscribers.splice(index, 1); } } - subscribeAll(from: EventProducer): void { - for (const handler of from.subscribers) { - this._subscribe(handler); - } - } - - fire(callback: (handler: THandler) => void): void { - for (const handler of this.subscribers) { + _fire(callback: (handler: THandler) => void): void { + for (const handler of this._subscribers) { callback(handler); } } - - async suspendingFire( - callback: (handler: THandler) => Promise, - ): Promise { - for (const handler of this.subscribers) { - await callback(handler); - } - } } diff --git a/src/shared/helpers/OutcomesHelper.ts b/src/shared/helpers/OutcomesHelper.ts index 73b761644..d3e3688d3 100644 --- a/src/shared/helpers/OutcomesHelper.ts +++ b/src/shared/helpers/OutcomesHelper.ts @@ -19,10 +19,10 @@ const SEND_OUTCOME = 'sendOutcome'; const SEND_UNIQUE_OUTCOME = 'sendUniqueOutcome'; export default class OutcomesHelper { - private outcomeName: string; - private config: OutcomesConfig; - private appId: string; - private isUnique: boolean; + private _outcomeName: string; + private _config: OutcomesConfig; + private _appId: string; + private _isUnique: boolean; /** * @param {string} appId @@ -36,10 +36,10 @@ export default class OutcomesHelper { outcomeName: string, isUnique: boolean, ) { - this.outcomeName = outcomeName; - this.config = config; - this.appId = appId; - this.isUnique = isUnique; + this._outcomeName = outcomeName; + this._config = config; + this._appId = appId; + this._isUnique = isUnique; } /** * Returns `OutcomeAttribution` object which includes @@ -50,8 +50,8 @@ export default class OutcomesHelper { * does not check if they have been previously attributed (used in both sendOutcome & sendUniqueOutcome) * @returns Promise */ - async getAttribution(): Promise { - return await getConfigAttribution(this.config); + async _getAttribution(): Promise { + return await getConfigAttribution(this._config); } /** @@ -59,18 +59,18 @@ export default class OutcomesHelper { * @param {boolean} isUnique * @returns Promise */ - async beforeOutcomeSend(): Promise { - const outcomeMethodString = this.isUnique + async _beforeOutcomeSend(): Promise { + const outcomeMethodString = this._isUnique ? SEND_UNIQUE_OUTCOME : SEND_OUTCOME; - logMethodCall(outcomeMethodString, this.outcomeName); + logMethodCall(outcomeMethodString, this._outcomeName); - if (!this.config) { + if (!this._config) { Log._debug('Outcomes feature not supported by main application yet.'); return false; } - if (!this.outcomeName) { + if (!this._outcomeName) { Log._error('Outcome name is required'); return false; } @@ -91,10 +91,10 @@ export default class OutcomesHelper { * @param {string} outcomeName * @returns Promise */ - async getAttributedNotifsByUniqueOutcomeName(): Promise { + async _getAttributedNotifsByUniqueOutcomeName(): Promise { const sentOutcomes = await db.getAll('SentUniqueOutcome'); return sentOutcomes - .filter((o) => o.outcomeName === this.outcomeName) + .filter((o) => o.outcomeName === this._outcomeName) .reduce((acc: string[], curr: SentUniqueOutcome) => { const notificationIds = curr.notificationIds || []; return [...acc, ...notificationIds]; @@ -106,16 +106,19 @@ export default class OutcomesHelper { * @param {string} outcomeName * @param {string[]} notificationIds */ - async getNotifsToAttributeWithUniqueOutcome(notificationIds: string[]) { + async _getNotifsToAttributeWithUniqueOutcome(notificationIds: string[]) { const previouslyAttributedArr: string[] = - await this.getAttributedNotifsByUniqueOutcomeName(); + await this._getAttributedNotifsByUniqueOutcomeName(); return notificationIds.filter( (id) => previouslyAttributedArr.indexOf(id) === -1, ); } - shouldSendUnique(outcomeAttribution: OutcomeAttribution, notifArr: string[]) { + _shouldSendUnique( + outcomeAttribution: OutcomeAttribution, + notifArr: string[], + ) { // we should only send if type is unattributed OR there are notifs to attribute if (outcomeAttribution.type === OutcomeAttributionType.Unattributed) { return true; @@ -123,8 +126,8 @@ export default class OutcomesHelper { return notifArr.length > 0; } - async saveSentUniqueOutcome(newNotificationIds: string[]): Promise { - const outcomeName = this.outcomeName; + async _saveSentUniqueOutcome(newNotificationIds: string[]): Promise { + const outcomeName = this._outcomeName; const existingSentOutcome = await db.get('SentUniqueOutcome', outcomeName); const currentSession = await getCurrentSession(); @@ -141,8 +144,8 @@ export default class OutcomesHelper { }); } - async wasSentDuringSession() { - const sentOutcome = await db.get('SentUniqueOutcome', this.outcomeName); + async _wasSentDuringSession() { + const sentOutcome = await db.get('SentUniqueOutcome', this._outcomeName); if (!sentOutcome) { return false; @@ -160,45 +163,45 @@ export default class OutcomesHelper { ); } - async send(outcomeProps: OutcomeProps): Promise { + async _send(outcomeProps: OutcomeProps): Promise { const { type, notificationIds, weight } = outcomeProps; switch (type) { case OutcomeAttributionType.Direct: - if (this.isUnique) { - await this.saveSentUniqueOutcome(notificationIds); + if (this._isUnique) { + await this._saveSentUniqueOutcome(notificationIds); } await OneSignal._context._updateManager._sendOutcomeDirect( - this.appId, + this._appId, notificationIds, - this.outcomeName, + this._outcomeName, weight, ); return; case OutcomeAttributionType.Indirect: - if (this.isUnique) { - await this.saveSentUniqueOutcome(notificationIds); + if (this._isUnique) { + await this._saveSentUniqueOutcome(notificationIds); } await OneSignal._context._updateManager._sendOutcomeInfluenced( - this.appId, + this._appId, notificationIds, - this.outcomeName, + this._outcomeName, weight, ); return; case OutcomeAttributionType.Unattributed: - if (this.isUnique) { - if (await this.wasSentDuringSession()) { + if (this._isUnique) { + if (await this._wasSentDuringSession()) { Log._warn( `(Unattributed) unique outcome was already sent during this session`, ); return; } - await this.saveSentUniqueOutcome([]); + await this._saveSentUniqueOutcome([]); } await OneSignal._context._updateManager._sendOutcomeUnattributed( - this.appId, - this.outcomeName, + this._appId, + this._outcomeName, weight, ); return; @@ -209,8 +212,6 @@ export default class OutcomesHelper { return; } } - - // statics } /** diff --git a/src/sw/serviceWorker/ServiceWorker.test.ts b/src/sw/serviceWorker/ServiceWorker.test.ts index c64b094c0..2bbdec666 100644 --- a/src/sw/serviceWorker/ServiceWorker.test.ts +++ b/src/sw/serviceWorker/ServiceWorker.test.ts @@ -31,6 +31,11 @@ import type { UpsertOrDeactivateSessionPayload, } from 'src/shared/session/types'; import { NotificationType } from 'src/shared/subscriptions/constants'; +import { + notificationClick, + notificationDismissed, + notificationWillDisplay, +} from '../webhooks/notifications/webhookNotificationEvent'; import { OneSignalServiceWorker } from './ServiceWorker'; declare const self: ServiceWorkerGlobalScope; @@ -178,9 +183,7 @@ describe('ServiceWorker', () => { notificationId, title: payload.title, }; - expect( - OneSignalServiceWorker._webhookNotificationEventSender.willDisplay, - ).toHaveBeenCalledWith( + expect(notificationWillDisplay).toHaveBeenCalledWith( expect.objectContaining(notificationInfo), pushSubscriptionId, ); @@ -227,9 +230,7 @@ describe('ServiceWorker', () => { }); await dispatchEvent(event); - expect( - OneSignalServiceWorker._webhookNotificationEventSender.dismiss, - ).toHaveBeenCalledWith( + expect(notificationDismissed).toHaveBeenCalledWith( { notificationId, }, @@ -279,9 +280,7 @@ describe('ServiceWorker', () => { ); // should emit clicked event - expect( - OneSignalServiceWorker._webhookNotificationEventSender.click, - ).toHaveBeenCalledWith( + expect(notificationClick).toHaveBeenCalledWith( { notification: { launchURL, diff --git a/src/sw/serviceWorker/ServiceWorker.ts b/src/sw/serviceWorker/ServiceWorker.ts index d49754369..7895fd5ce 100755 --- a/src/sw/serviceWorker/ServiceWorker.ts +++ b/src/sw/serviceWorker/ServiceWorker.ts @@ -57,7 +57,11 @@ import { toNativeNotificationAction, toOSNotification, } from '../helpers/notifications'; -import { OSWebhookNotificationEventSender } from '../webhooks/notifications/OSWebhookNotificationEventSender'; +import { + notificationClick, + notificationDismissed, + notificationWillDisplay, +} from '../webhooks/notifications/webhookNotificationEvent'; import { getPushSubscriptionIdByToken } from './helpers'; import type { OSMinifiedNotificationPayload, @@ -75,10 +79,6 @@ const MAX_CONFIRMED_DELIVERY_DELAY = 25; * allows notification permissions, and is a pre-requisite to subscribing for push notifications. */ export class OneSignalServiceWorker { - static get _webhookNotificationEventSender() { - return new OSWebhookNotificationEventSender(); - } - static async _getPushSubscriptionId(): Promise { const pushSubscription = await self.registration.pushManager.getSubscription(); @@ -312,10 +312,7 @@ export class OneSignalServiceWorker { const pushSubscriptionId = await OneSignalServiceWorker._getPushSubscriptionId(); - OneSignalServiceWorker._webhookNotificationEventSender.willDisplay( - notif, - pushSubscriptionId, - ); + notificationWillDisplay(notif, pushSubscriptionId); return OneSignalServiceWorker._displayNotification(notif) .then(() => @@ -735,10 +732,7 @@ export class OneSignalServiceWorker { const pushSubscriptionId = await OneSignalServiceWorker._getPushSubscriptionId(); - OneSignalServiceWorker._webhookNotificationEventSender.dismiss( - notification, - pushSubscriptionId, - ); + notificationDismissed(notification, pushSubscriptionId); } /** @@ -984,10 +978,7 @@ export class OneSignalServiceWorker { ); } - await OneSignalServiceWorker._webhookNotificationEventSender.click( - notificationClickEvent, - pushSubscriptionId, - ); + await notificationClick(notificationClickEvent, pushSubscriptionId); if (onesignalRestPromise) await onesignalRestPromise; } diff --git a/src/sw/webhooks/OSWebhookSender.ts b/src/sw/webhooks/OSWebhookSender.ts deleted file mode 100644 index 32f6a5192..000000000 --- a/src/sw/webhooks/OSWebhookSender.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getOptionsValue } from 'src/shared/database/client'; -import type { OptionKey } from 'src/shared/database/types'; -import Log from 'src/shared/libraries/Log'; -import type { IOSWebhookEventPayload } from '../serviceWorker/types'; - -export class OSWebhookSender { - async send(payload: IOSWebhookEventPayload): Promise { - const webhookTargetUrl = await getOptionsValue( - `webhooks.${payload.event}` as OptionKey, - ); - if (!webhookTargetUrl) return; - - const isServerCorsEnabled = await getOptionsValue('webhooks.cors'); - - const fetchOptions: RequestInit = { - method: 'post', - mode: 'no-cors', - body: JSON.stringify(payload), - }; - - if (isServerCorsEnabled) { - fetchOptions.mode = 'cors'; - fetchOptions.headers = { - 'X-OneSignal-Event': payload.event, - 'Content-Type': 'application/json', - }; - } - Log._debug( - `Executing ${payload.event} webhook ${ - isServerCorsEnabled ? 'with' : 'without' - } CORS POST ${webhookTargetUrl}`, - payload, - ); - await fetch(webhookTargetUrl, fetchOptions); - return; - } -} diff --git a/src/sw/webhooks/notifications/OSWebhookNotificationEventSender.ts b/src/sw/webhooks/notifications/OSWebhookNotificationEventSender.ts deleted file mode 100644 index e71adda8f..000000000 --- a/src/sw/webhooks/notifications/OSWebhookNotificationEventSender.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { - IOSNotification, - NotificationClickEvent, -} from 'src/shared/notifications/types'; -import { OSWebhookSender } from './../OSWebhookSender'; - -export class OSWebhookNotificationEventSender { - private readonly sender: OSWebhookSender; - - constructor(sender: OSWebhookSender = new OSWebhookSender()) { - this.sender = sender; - } - - async click( - event: NotificationClickEvent, - subscriptionId: string | undefined, - ): Promise { - const notification = event.notification; - return await this.sender.send({ - event: 'notification.clicked', - notificationId: notification.notificationId, - heading: notification.title, - content: notification.body, - additionalData: notification.additionalData, - actionId: event.result.actionId, - url: event.result.url, - subscriptionId, - }); - } - - async willDisplay( - notification: IOSNotification, - subscriptionId: string | undefined, - ): Promise { - return await this.sender.send({ - event: 'notification.willDisplay', - notificationId: notification.notificationId, - heading: notification.title, - content: notification.body, - additionalData: notification.additionalData, - url: notification.launchURL, - subscriptionId, - }); - } - - async dismiss( - notification: IOSNotification, - subscriptionId: string | undefined, - ): Promise { - return await this.sender.send({ - event: 'notification.dismissed', - notificationId: notification.notificationId, - heading: notification.title, - content: notification.body, - additionalData: notification.additionalData, - url: notification.launchURL, - subscriptionId, - }); - } -} diff --git a/src/sw/webhooks/notifications/webhookNotificationEvent.ts b/src/sw/webhooks/notifications/webhookNotificationEvent.ts new file mode 100644 index 000000000..470d0a7b1 --- /dev/null +++ b/src/sw/webhooks/notifications/webhookNotificationEvent.ts @@ -0,0 +1,52 @@ +import type { + IOSNotification, + NotificationClickEvent, +} from 'src/shared/notifications/types'; +import { send } from '../sender'; + +export async function notificationClick( + event: NotificationClickEvent, + subscriptionId: string | undefined, +): Promise { + const notification = event.notification; + return await send({ + event: 'notification.clicked', + notificationId: notification.notificationId, + heading: notification.title, + content: notification.body, + additionalData: notification.additionalData, + actionId: event.result.actionId, + url: event.result.url, + subscriptionId, + }); +} + +export async function notificationWillDisplay( + notification: IOSNotification, + subscriptionId: string | undefined, +): Promise { + return await send({ + event: 'notification.willDisplay', + notificationId: notification.notificationId, + heading: notification.title, + content: notification.body, + additionalData: notification.additionalData, + url: notification.launchURL, + subscriptionId, + }); +} + +export async function notificationDismissed( + notification: IOSNotification, + subscriptionId: string | undefined, +): Promise { + return await send({ + event: 'notification.dismissed', + notificationId: notification.notificationId, + heading: notification.title, + content: notification.body, + additionalData: notification.additionalData, + url: notification.launchURL, + subscriptionId, + }); +} diff --git a/src/sw/webhooks/sender.ts b/src/sw/webhooks/sender.ts new file mode 100644 index 000000000..b192a5209 --- /dev/null +++ b/src/sw/webhooks/sender.ts @@ -0,0 +1,35 @@ +import { getOptionsValue } from 'src/shared/database/client'; +import type { OptionKey } from 'src/shared/database/types'; +import Log from 'src/shared/libraries/Log'; +import type { IOSWebhookEventPayload } from '../serviceWorker/types'; + +export async function send(payload: IOSWebhookEventPayload): Promise { + const webhookTargetUrl = await getOptionsValue( + `webhooks.${payload.event}` as OptionKey, + ); + if (!webhookTargetUrl) return; + + const isServerCorsEnabled = await getOptionsValue('webhooks.cors'); + + const fetchOptions: RequestInit = { + method: 'post', + mode: 'no-cors', + body: JSON.stringify(payload), + }; + + if (isServerCorsEnabled) { + fetchOptions.mode = 'cors'; + fetchOptions.headers = { + 'X-OneSignal-Event': payload.event, + 'Content-Type': 'application/json', + }; + } + Log._debug( + `Executing ${payload.event} webhook ${ + isServerCorsEnabled ? 'with' : 'without' + } CORS POST ${webhookTargetUrl}`, + payload, + ); + await fetch(webhookTargetUrl, fetchOptions); + return; +} From 3476ce201eb2d67b1ef65303933224572ee58b3d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 12:46:01 -0700 Subject: [PATCH 2/9] break up sw static class methods --- package.json | 2 +- src/entries/worker.ts | 7 +- src/global.d.ts | 1 - src/sw/serviceWorker/ServiceWorker.test.ts | 12 +- src/sw/serviceWorker/ServiceWorker.ts | 1909 ++++++++++---------- 5 files changed, 937 insertions(+), 994 deletions(-) diff --git a/package.json b/package.json index 76c84737a..fc10f91a6 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "13.58 kB", + "limit": "13.442 kB", "gzip": true }, { diff --git a/src/entries/worker.ts b/src/entries/worker.ts index c4b0472ce..1a8ff5f17 100644 --- a/src/entries/worker.ts +++ b/src/entries/worker.ts @@ -1,8 +1,7 @@ /** * New clients will only be including this entry file, which will result in a reduced service worker size. */ -import { OneSignalServiceWorker } from '../sw/serviceWorker/ServiceWorker'; +import { run } from '../sw/serviceWorker/ServiceWorker'; -// Expose this class to the global scope -declare const self: ServiceWorkerGlobalScope; -self.OneSignalWorker = OneSignalServiceWorker; +// The run() is already called in ServiceWorker.ts, but importing it ensures it's not tree-shaken +void run; diff --git a/src/global.d.ts b/src/global.d.ts index eed573a69..33396789f 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -41,7 +41,6 @@ declare global { interface WorkerGlobalScope { OneSignal: _OneSignal; - OneSignalWorker: typeof import('./sw/serviceWorker/ServiceWorker').OneSignalServiceWorker; _workerMessenger: import('./sw/serviceWorker/WorkerMessengerSW').WorkerMessengerSW; shouldLog: boolean; } diff --git a/src/sw/serviceWorker/ServiceWorker.test.ts b/src/sw/serviceWorker/ServiceWorker.test.ts index 2bbdec666..e2e08c5d6 100644 --- a/src/sw/serviceWorker/ServiceWorker.test.ts +++ b/src/sw/serviceWorker/ServiceWorker.test.ts @@ -31,12 +31,20 @@ import type { UpsertOrDeactivateSessionPayload, } from 'src/shared/session/types'; import { NotificationType } from 'src/shared/subscriptions/constants'; +import { getAppId } from './ServiceWorker'; + +// Mock webhook notification events +vi.mock('../webhooks/notifications/webhookNotificationEvent', () => ({ + notificationClick: vi.fn(), + notificationDismissed: vi.fn(), + notificationWillDisplay: vi.fn(), +})); + import { notificationClick, notificationDismissed, notificationWillDisplay, } from '../webhooks/notifications/webhookNotificationEvent'; -import { OneSignalServiceWorker } from './ServiceWorker'; declare const self: ServiceWorkerGlobalScope; @@ -114,7 +122,7 @@ describe('ServiceWorker', () => { // @ts-expect-error - search is readonly but we need to set it for testing self.location.search = '?appId=some-app-id'; - const appId = await OneSignalServiceWorker._getAppId(); + const appId = await getAppId(); expect(appId).toBe('some-app-id'); }); }); diff --git a/src/sw/serviceWorker/ServiceWorker.ts b/src/sw/serviceWorker/ServiceWorker.ts index 7895fd5ce..96437da8d 100755 --- a/src/sw/serviceWorker/ServiceWorker.ts +++ b/src/sw/serviceWorker/ServiceWorker.ts @@ -72,1107 +72,1044 @@ import type { declare const self: ServiceWorkerGlobalScope & OSServiceWorkerFields; const MAX_CONFIRMED_DELIVERY_DELAY = 25; +const workerMessenger = new WorkerMessengerSW(undefined); + +async function getPushSubscriptionId(): Promise { + const pushSubscription = + await self.registration.pushManager.getSubscription(); + const pushToken = pushSubscription?.endpoint; + if (!pushToken) return undefined; + return getPushSubscriptionIdByToken(pushToken); +} /** - * The main service worker script fetching and displaying notifications to users in the background even when the client - * site is not running. The worker is registered via the navigator.serviceWorker.register() call after the user first - * allows notification permissions, and is a pre-requisite to subscribing for push notifications. + * Service worker entry point. */ -export class OneSignalServiceWorker { - static async _getPushSubscriptionId(): Promise { - const pushSubscription = - await self.registration.pushManager.getSubscription(); - const pushToken = pushSubscription?.endpoint; - if (!pushToken) return undefined; - return getPushSubscriptionIdByToken(pushToken); - } +export function run() { + self.addEventListener('activate', onServiceWorkerActivated); + self.addEventListener('push', onPushReceived); + self.addEventListener('notificationclose', (event: NotificationEvent) => + event.waitUntil(onNotificationClosed(event)), + ); + self.addEventListener('notificationclick', (event: NotificationEvent) => + event.waitUntil(onNotificationClicked(event)), + ); + self.addEventListener('pushsubscriptionchange', (event: Event) => { + (event as FetchEvent).waitUntil( + onPushSubscriptionChange(event as unknown as SubscriptionChangeEvent), + ); + }); + + self.addEventListener('message', (event: ExtendableMessageEvent) => { + const data: WorkerMessengerMessage = event.data; + const payload = data?.payload; + + switch (data?.command) { + case WorkerMessengerCommand.SessionUpsert: + Log._debug('[Service Worker] Received SessionUpsert', payload); + debounceRefreshSession( + event, + payload as UpsertOrDeactivateSessionPayload, + ); + break; + case WorkerMessengerCommand.SessionDeactivate: + Log._debug('[Service Worker] Received SessionDeactivate', payload); + debounceRefreshSession( + event, + payload as UpsertOrDeactivateSessionPayload, + ); + break; + default: + return; + } + }); + /* + According to + https://w3c.github.io/ServiceWorker/#run-service-worker-algorithm: + + "user agents are encouraged to show a warning that the event listeners + must be added on the very first evaluation of the worker script." + + We have to register our event handler statically (not within an + asynchronous method) so that the browser can optimize not waking up the + service worker for events that aren't known for sure to be listened for. + + Also see: https://github.com/w3c/ServiceWorker/issues/1156 + */ + Log._debug('Setting up message listeners.'); + + // delay for setting up test mocks like global.ServiceWorkerGlobalScope + setTimeout(() => { + // self.addEventListener('message') is statically added inside the listen() method + workerMessenger._listen(); + + // Install messaging event handlers for page <-> service worker communication + setupMessageListeners(); + }, 0); +} - /** - * Allows message passing between this service worker and pages on the same domain. - * This allows events like notification dismissed, clicked, and displayed to be - * fired on the clients. It also allows the clients to communicate with the - * service worker to close all active notifications. - */ - static get _workerMessenger(): WorkerMessengerSW { - if (!self._workerMessenger) { - self._workerMessenger = new WorkerMessengerSW(undefined); +export async function getAppId(): Promise { + if (self.location.search) { + const match = self.location.search.match(/appId=([0-9a-z-]+)&?/i); + // Successful regex matches are at position 1 + if (match && match.length > 1) { + const appId = match[1]; + return appId; } - return self._workerMessenger; } + const { appId } = await getDBAppConfig(); + return appId; +} - /** - * Service worker entry point. - */ - static _run() { - self.addEventListener( - 'activate', - OneSignalServiceWorker._onServiceWorkerActivated, - ); - self.addEventListener('push', OneSignalServiceWorker._onPushReceived); - self.addEventListener('notificationclose', (event: NotificationEvent) => - event.waitUntil(OneSignalServiceWorker._onNotificationClosed(event)), - ); - self.addEventListener('notificationclick', (event: NotificationEvent) => - event.waitUntil(OneSignalServiceWorker._onNotificationClicked(event)), - ); - self.addEventListener('pushsubscriptionchange', (event: Event) => { - (event as FetchEvent).waitUntil( - OneSignalServiceWorker._onPushSubscriptionChange( - event as unknown as SubscriptionChangeEvent, - ), +function setupMessageListeners() { + workerMessenger._on(WorkerMessengerCommand.WorkerVersion, () => { + Log._debug('[Service Worker] Received worker version message.'); + workerMessenger._broadcast(WorkerMessengerCommand.WorkerVersion, VERSION); + }); + workerMessenger._on( + WorkerMessengerCommand.Subscribe, + async (appConfigBundle: AppConfig) => { + const appConfig = appConfigBundle; + Log._debug('[Service Worker] Received subscribe message.'); + const context = new ContextSW(appConfig); + const rawSubscription = await context._subscriptionManager._subscribe( + SubscriptionStrategyKind.ResubscribeExisting, ); - }); + const subscription = + await context._subscriptionManager._registerSubscription( + rawSubscription, + ); + workerMessenger._broadcast( + WorkerMessengerCommand.Subscribe, + subscription._serialize(), + ); + }, + ); + workerMessenger._on( + WorkerMessengerCommand.SubscribeNew, + async (appConfigBundle: AppConfig) => { + const appConfig = appConfigBundle; + Log._debug('[Service Worker] Received subscribe new message.'); + const context = new ContextSW(appConfig); + const rawSubscription = await context._subscriptionManager._subscribe( + SubscriptionStrategyKind.SubscribeNew, + ); + const subscription = + await context._subscriptionManager._registerSubscription( + rawSubscription, + ); - self.addEventListener('message', (event: ExtendableMessageEvent) => { - const data: WorkerMessengerMessage = event.data; - const payload = data?.payload; + workerMessenger._broadcast( + WorkerMessengerCommand.SubscribeNew, + subscription._serialize(), + ); + }, + ); + + workerMessenger._on( + WorkerMessengerCommand.AreYouVisibleResponse, + async (payload: PageVisibilityResponse) => { + Log._debug( + '[Service Worker] Received response for AreYouVisible', + payload, + ); - switch (data?.command) { - case WorkerMessengerCommand.SessionUpsert: - Log._debug('[Service Worker] Received SessionUpsert', payload); - OneSignalServiceWorker._debounceRefreshSession( - event, - payload as UpsertOrDeactivateSessionPayload, - ); - break; - case WorkerMessengerCommand.SessionDeactivate: - Log._debug('[Service Worker] Received SessionDeactivate', payload); - OneSignalServiceWorker._debounceRefreshSession( - event, - payload as UpsertOrDeactivateSessionPayload, - ); - break; - default: - return; + const timestamp = payload.timestamp; + if (self.clientsStatus?.timestamp !== timestamp) { + return; } - }); - /* - According to - https://w3c.github.io/ServiceWorker/#run-service-worker-algorithm: - - "user agents are encouraged to show a warning that the event listeners - must be added on the very first evaluation of the worker script." - We have to register our event handler statically (not within an - asynchronous method) so that the browser can optimize not waking up the - service worker for events that aren't known for sure to be listened for. - - Also see: https://github.com/w3c/ServiceWorker/issues/1156 - */ - Log._debug('Setting up message listeners.'); - - // delay for setting up test mocks like global.ServiceWorkerGlobalScope - setTimeout(() => { - // self.addEventListener('message') is statically added inside the listen() method - OneSignalServiceWorker._workerMessenger._listen(); - - // Install messaging event handlers for page <-> service worker communication - OneSignalServiceWorker._setupMessageListeners(); - }, 0); - } - - static async _getAppId(): Promise { - if (self.location.search) { - const match = self.location.search.match(/appId=([0-9a-z-]+)&?/i); - // Successful regex matches are at position 1 - if (match && match.length > 1) { - const appId = match[1]; - return appId; + self.clientsStatus.receivedResponsesCount++; + if (payload.focused) { + self.clientsStatus.hasAnyActiveSessions = true; } - } - const { appId } = await getDBAppConfig(); - return appId; - } + }, + ); + workerMessenger._on( + WorkerMessengerCommand.SetLogging, + async (payload: { shouldLog: boolean }) => { + if (payload.shouldLog) { + self.shouldLog = true; + } else { + self.shouldLog = undefined; + } + }, + ); +} - static _setupMessageListeners() { - OneSignalServiceWorker._workerMessenger._on( - WorkerMessengerCommand.WorkerVersion, - () => { - Log._debug('[Service Worker] Received worker version message.'); - OneSignalServiceWorker._workerMessenger._broadcast( - WorkerMessengerCommand.WorkerVersion, - VERSION, - ); - }, - ); - OneSignalServiceWorker._workerMessenger._on( - WorkerMessengerCommand.Subscribe, - async (appConfigBundle: AppConfig) => { - const appConfig = appConfigBundle; - Log._debug('[Service Worker] Received subscribe message.'); - const context = new ContextSW(appConfig); - const rawSubscription = await context._subscriptionManager._subscribe( - SubscriptionStrategyKind.ResubscribeExisting, - ); - const subscription = - await context._subscriptionManager._registerSubscription( - rawSubscription, +/** + * Occurs when a push message is received. + * This method handles the receipt of a push signal on all web browsers except Safari, which uses the OS to handle + * notifications. + */ +function onPushReceived(event: PushEvent): void { + Log._debug( + `Called onPushReceived(${JSON.stringify(event, null, 4)}):`, + event, + ); + + event.waitUntil( + parseOrFetchNotifications(event) + .then(async (rawNotificationsArray: OSMinifiedNotificationPayload[]) => { + //Display push notifications in the order we received them + const notificationEventPromiseFns = []; + const notificationReceivedPromises: Promise[] = []; + const appId = await getAppId(); + + for (const rawNotification of rawNotificationsArray) { + Log._debug('Raw Notification from OneSignal:', rawNotification); + const notification = toOSNotification(rawNotification); + + notificationReceivedPromises.push( + putNotificationReceivedForOutcomes(appId, notification), ); - OneSignalServiceWorker._workerMessenger._broadcast( - WorkerMessengerCommand.Subscribe, - subscription._serialize(), - ); - }, - ); - OneSignalServiceWorker._workerMessenger._on( - WorkerMessengerCommand.SubscribeNew, - async (appConfigBundle: AppConfig) => { - const appConfig = appConfigBundle; - Log._debug('[Service Worker] Received subscribe new message.'); - const context = new ContextSW(appConfig); - const rawSubscription = await context._subscriptionManager._subscribe( - SubscriptionStrategyKind.SubscribeNew, - ); - const subscription = - await context._subscriptionManager._registerSubscription( - rawSubscription, + // TODO: decide what to do with all the notif received promises + // Probably should have it's own error handling but not blocking the rest of the execution? + + // Never nest the following line in a callback from the point of entering from retrieveNotifications + notificationEventPromiseFns.push( + (async (notif: IOSNotification) => { + const event: NotificationForegroundWillDisplayEventSerializable = + { + notification: notif, + }; + await workerMessenger + ._broadcast( + WorkerMessengerCommand.NotificationWillDisplay, + event, + ) + .catch((e) => Log._error(e)); + const pushSubscriptionId = await getPushSubscriptionId(); + + notificationWillDisplay(notif, pushSubscriptionId); + + return displayNotification(notif) + .then(() => sendConfirmedDelivery(notif)) + .catch((e) => Log._error(e)); + }).bind(null, notification), ); + } - OneSignalServiceWorker._workerMessenger._broadcast( - WorkerMessengerCommand.SubscribeNew, - subscription._serialize(), - ); - }, - ); + // @ts-expect-error - TODO: improve type + return notificationEventPromiseFns.reduce((p, fn) => { + // @ts-expect-error - TODO: improve type + return (p = p.then(fn)); + }, Promise.resolve()); + }) + .catch((e) => { + Log._debug('Failed to display a notification:', e); + }), + ); +} - OneSignalServiceWorker._workerMessenger._on( - WorkerMessengerCommand.AreYouVisibleResponse, - async (payload: PageVisibilityResponse) => { - Log._debug( - '[Service Worker] Received response for AreYouVisible', - payload, - ); +/** + * Confirmed Delivery isn't supported on Safari since they are very strict about the amount + * of time you have to finish the onpush event. Spending to much time in the onpush event + * will cause the push endpoint to become revoked!, causing the device to stop receiving pushes! + * + * iPadOS 16.4 it was observed to be only about 10 secounds. + * macOS 13.3 didn't seem to have this restriction when testing up to a 25 secound delay, however + * to be safe we are disabling it for all Safari browsers. + */ +function browserSupportsConfirmedDelivery(): boolean { + return getBrowserName() !== Browser.Safari; +} - const timestamp = payload.timestamp; - if (self.clientsStatus?.timestamp !== timestamp) { - return; - } +/** + * Makes a PUT call to log the delivery of the notification + * @param notification A JSON object containing notification details. + * @returns {Promise} + */ +async function sendConfirmedDelivery( + notification: IOSNotification, +): Promise { + if (!notification) return; + + if (!browserSupportsConfirmedDelivery()) return null; + + if (!notification.confirmDelivery) return; + + const appId = await getAppId(); + const pushSubscriptionId = await getPushSubscriptionId(); + + // app and notification ids are required, decided to exclude deviceId from required params + // In rare case we don't have it we can still report as confirmed to backend to increment count + const hasRequiredParams = !!(appId && notification.notificationId); + if (!hasRequiredParams) return; + + // JSON.stringify() does not include undefined values + // Our response will not contain those fields here which have undefined values + const postData = { + player_id: pushSubscriptionId, + app_id: appId, + device_type: getDeviceType(), + }; + + Log._debug( + `Called sendConfirmedDelivery(${JSON.stringify(notification, null, 4)})`, + ); + + await delay(Math.floor(Math.random() * MAX_CONFIRMED_DELIVERY_DELAY * 1_000)); + await OneSignalApiBase.put( + `notifications/${notification.notificationId}/report_received`, + postData, + ); +} - self.clientsStatus.receivedResponsesCount++; - if (payload.focused) { - self.clientsStatus.hasAnyActiveSessions = true; - } - }, +/** + * Gets an array of window clients + * @returns {Promise} + */ +async function getWindowClients(): Promise> { + return await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); +} + +async function updateSessionBasedOnHasActive( + event: ExtendableMessageEvent, + hasAnyActiveSessions: boolean, + options: UpsertOrDeactivateSessionPayload, +) { + if (hasAnyActiveSessions) { + await upsertSession( + options.appId, + options.onesignalId, + options.subscriptionId, + options.sessionThreshold, + options.enableSessionDuration, + options.sessionOrigin, + options.outcomesConfig, ); - OneSignalServiceWorker._workerMessenger._on( - WorkerMessengerCommand.SetLogging, - async (payload: { shouldLog: boolean }) => { - if (payload.shouldLog) { - self.shouldLog = true; - } else { - self.shouldLog = undefined; - } - }, + } else { + const cancelableFinalize = await deactivateSession( + options.appId, + options.onesignalId, + options.subscriptionId, + options.sessionThreshold, + options.enableSessionDuration, + options.outcomesConfig, ); + if (cancelableFinalize) { + self.cancel = cancelableFinalize.cancel; + event.waitUntil(cancelableFinalize.promise); + } } +} +async function refreshSession( + event: ExtendableMessageEvent, + options: UpsertOrDeactivateSessionPayload, +): Promise { + Log._debug('[Service Worker] refreshSession'); /** - * Occurs when a push message is received. - * This method handles the receipt of a push signal on all web browsers except Safari, which uses the OS to handle - * notifications. + * getWindowClients -> check for the first focused + * unfortunately, not enough for safari, it always returns false for focused state of a client + * have to workaround it with messaging to the client. */ - static _onPushReceived(event: PushEvent): void { - Log._debug( - `Called onPushReceived(${JSON.stringify(event, null, 4)}):`, + const windowClients = await getWindowClients(); + + if (options.isSafari) { + await checkIfAnyClientsFocusedAndUpdateSession( event, + windowClients, + options, ); - - event.waitUntil( - OneSignalServiceWorker._parseOrFetchNotifications(event) - .then( - async (rawNotificationsArray: OSMinifiedNotificationPayload[]) => { - //Display push notifications in the order we received them - const notificationEventPromiseFns = []; - const notificationReceivedPromises: Promise[] = []; - const appId = await OneSignalServiceWorker._getAppId(); - - for (const rawNotification of rawNotificationsArray) { - Log._debug('Raw Notification from OneSignal:', rawNotification); - const notification = toOSNotification(rawNotification); - - notificationReceivedPromises.push( - putNotificationReceivedForOutcomes(appId, notification), - ); - // TODO: decide what to do with all the notif received promises - // Probably should have it's own error handling but not blocking the rest of the execution? - - // Never nest the following line in a callback from the point of entering from retrieveNotifications - notificationEventPromiseFns.push( - (async (notif: IOSNotification) => { - const event: NotificationForegroundWillDisplayEventSerializable = - { - notification: notif, - }; - await OneSignalServiceWorker._workerMessenger - ._broadcast( - WorkerMessengerCommand.NotificationWillDisplay, - event, - ) - .catch((e) => Log._error(e)); - const pushSubscriptionId = - await OneSignalServiceWorker._getPushSubscriptionId(); - - notificationWillDisplay(notif, pushSubscriptionId); - - return OneSignalServiceWorker._displayNotification(notif) - .then(() => - OneSignalServiceWorker._sendConfirmedDelivery(notif), - ) - .catch((e) => Log._error(e)); - }).bind(null, notification), - ); - } - - // @ts-expect-error - TODO: improve type - return notificationEventPromiseFns.reduce((p, fn) => { - // @ts-expect-error - TODO: improve type - return (p = p.then(fn)); - }, Promise.resolve()); - }, - ) - .catch((e) => { - Log._debug('Failed to display a notification:', e); - }), + } else { + const hasAnyActiveSessions: boolean = windowClients.some( + (w) => (w as WindowClient).focused, ); + Log._debug('[Service Worker] hasAnyActiveSessions', hasAnyActiveSessions); + await updateSessionBasedOnHasActive(event, hasAnyActiveSessions, options); } +} - /** - * Makes a PUT call to log the delivery of the notification - * @param notification A JSON object containing notification details. - * @returns {Promise} - */ - static async _sendConfirmedDelivery( - notification: IOSNotification, - ): Promise { - if (!notification) return; - - if (!OneSignalServiceWorker._browserSupportsConfirmedDelivery()) - return null; - - if (!notification.confirmDelivery) return; - - const appId = await OneSignalServiceWorker._getAppId(); - const pushSubscriptionId = await this._getPushSubscriptionId(); - - // app and notification ids are required, decided to exclude deviceId from required params - // In rare case we don't have it we can still report as confirmed to backend to increment count - const hasRequiredParams = !!(appId && notification.notificationId); - if (!hasRequiredParams) return; - - // JSON.stringify() does not include undefined values - // Our response will not contain those fields here which have undefined values - const postData = { - player_id: pushSubscriptionId, - app_id: appId, - device_type: getDeviceType(), - }; - - Log._debug( - `Called sendConfirmedDelivery(${JSON.stringify(notification, null, 4)})`, +async function checkIfAnyClientsFocusedAndUpdateSession( + event: ExtendableMessageEvent, + windowClients: ReadonlyArray, + sessionInfo: UpsertOrDeactivateSessionPayload, +): Promise { + const timestamp = new Date().getTime(); + self.clientsStatus = { + timestamp, + sentRequestsCount: 0, + receivedResponsesCount: 0, + hasAnyActiveSessions: false, + }; + const payload: PageVisibilityRequest = { timestamp }; + windowClients.forEach((c) => { + // keeping track of number of sent requests mostly for debugging purposes + self.clientsStatus!.sentRequestsCount++; + c.postMessage({ command: WorkerMessengerCommand.AreYouVisible, payload }); + }); + const updateOnHasActive = async () => { + Log._debug('updateSessionBasedOnHasActive', self.clientsStatus); + await updateSessionBasedOnHasActive( + event, + self.clientsStatus!.hasAnyActiveSessions, + sessionInfo, ); + self.clientsStatus = undefined; + }; + const getClientStatusesCancelable = cancelableTimeout(updateOnHasActive, 0.5); + self.cancel = getClientStatusesCancelable.cancel; + event.waitUntil(getClientStatusesCancelable.promise); +} - await delay( - Math.floor(Math.random() * MAX_CONFIRMED_DELIVERY_DELAY * 1_000), - ); - await OneSignalApiBase.put( - `notifications/${notification.notificationId}/report_received`, - postData, - ); - } +function debounceRefreshSession( + event: ExtendableMessageEvent, + options: UpsertOrDeactivateSessionPayload, +) { + Log._debug('[Service Worker] debounceRefreshSession', options); - /** - * Confirmed Delivery isn't supported on Safari since they are very strict about the amount - * of time you have to finish the onpush event. Spending to much time in the onpush event - * will cause the push endpoint to become revoked!, causing the device to stop receiving pushes! - * - * iPadOS 16.4 it was observed to be only about 10 secounds. - * macOS 13.3 didn't seem to have this restriction when testing up to a 25 secound delay, however - * to be safe we are disabling it for all Safari browsers. - */ - static _browserSupportsConfirmedDelivery(): boolean { - return getBrowserName() !== Browser.Safari; + if (self.cancel) { + self.cancel(); + self.cancel = undefined; } - /** - * Gets an array of window clients - * @returns {Promise} - */ - static async _getWindowClients(): Promise> { - return await self.clients.matchAll({ - type: 'window', - includeUncontrolled: true, - }); - } + const executeRefreshSession = async () => { + await refreshSession(event, options); + }; - static async _updateSessionBasedOnHasActive( - event: ExtendableMessageEvent, - hasAnyActiveSessions: boolean, - options: UpsertOrDeactivateSessionPayload, - ) { - if (hasAnyActiveSessions) { - await upsertSession( - options.appId, - options.onesignalId, - options.subscriptionId, - options.sessionThreshold, - options.enableSessionDuration, - options.sessionOrigin, - options.outcomesConfig, - ); - } else { - const cancelableFinalize = await deactivateSession( - options.appId, - options.onesignalId, - options.subscriptionId, - options.sessionThreshold, - options.enableSessionDuration, - options.outcomesConfig, - ); - if (cancelableFinalize) { - self.cancel = cancelableFinalize.cancel; - event.waitUntil(cancelableFinalize.promise); - } - } - } - - static async _refreshSession( - event: ExtendableMessageEvent, - options: UpsertOrDeactivateSessionPayload, - ): Promise { - Log._debug('[Service Worker] refreshSession'); - /** - * getWindowClients -> check for the first focused - * unfortunately, not enough for safari, it always returns false for focused state of a client - * have to workaround it with messaging to the client. - */ - const windowClients = await this._getWindowClients(); + const cancelableRefreshSession = cancelableTimeout(executeRefreshSession, 1); + self.cancel = cancelableRefreshSession.cancel; + event.waitUntil(cancelableRefreshSession.promise); +} - if (options.isSafari) { - await OneSignalServiceWorker._checkIfAnyClientsFocusedAndUpdateSession( - event, - windowClients, - options, - ); - } else { - const hasAnyActiveSessions: boolean = windowClients.some( - (w) => (w as WindowClient).focused, - ); - Log._debug('[Service Worker] hasAnyActiveSessions', hasAnyActiveSessions); - await OneSignalServiceWorker._updateSessionBasedOnHasActive( - event, - hasAnyActiveSessions, - options, - ); +/** + * Given an image URL, returns a proxied HTTPS image using the https://images.weserv.nl service. + * For a null image, returns null so that no icon is displayed. + * If the image protocol is HTTPS, or origin contains localhost or starts with 192.168.*.*, we do not proxy the image. + * @param imageUrl An HTTP or HTTPS image URL. + */ +function ensureImageResourceHttps(imageUrl?: string) { + if (imageUrl) { + try { + const parsedImageUrl = new URL(imageUrl); + if ( + parsedImageUrl.hostname === 'localhost' || + parsedImageUrl.hostname.indexOf('192.168') !== -1 || + parsedImageUrl.hostname === '127.0.0.1' || + parsedImageUrl.protocol === 'https:' + ) { + return imageUrl; + } + if ( + parsedImageUrl.hostname === 'i0.wp.com' || + parsedImageUrl.hostname === 'i1.wp.com' || + parsedImageUrl.hostname === 'i2.wp.com' || + parsedImageUrl.hostname === 'i3.wp.com' + ) { + /* Their site already uses Jetpack, just make sure Jetpack is HTTPS */ + return `https://${parsedImageUrl.hostname}${parsedImageUrl.pathname}`; + } + /* HTTPS origin hosts can be used by prefixing the hostname with ssl: */ + const replacedImageUrl = parsedImageUrl.host + parsedImageUrl.pathname; + return `https://i0.wp.com/${replacedImageUrl}`; + } catch (e) { + Log._error('ensureImageResourceHttps: ', e); } } + return undefined; +} - static async _checkIfAnyClientsFocusedAndUpdateSession( - event: ExtendableMessageEvent, - windowClients: ReadonlyArray, - sessionInfo: UpsertOrDeactivateSessionPayload, - ): Promise { - const timestamp = new Date().getTime(); - self.clientsStatus = { - timestamp, - sentRequestsCount: 0, - receivedResponsesCount: 0, - hasAnyActiveSessions: false, - }; - const payload: PageVisibilityRequest = { timestamp }; - windowClients.forEach((c) => { - // keeping track of number of sent requests mostly for debugging purposes - self.clientsStatus!.sentRequestsCount++; - c.postMessage({ command: WorkerMessengerCommand.AreYouVisible, payload }); - }); - const updateOnHasActive = async () => { - Log._debug('updateSessionBasedOnHasActive', self.clientsStatus); - await OneSignalServiceWorker._updateSessionBasedOnHasActive( - event, - self.clientsStatus!.hasAnyActiveSessions, - sessionInfo, - ); - self.clientsStatus = undefined; - }; - const getClientStatusesCancelable = cancelableTimeout( - updateOnHasActive, - 0.5, - ); - self.cancel = getClientStatusesCancelable.cancel; - event.waitUntil(getClientStatusesCancelable.promise); - } - - static _debounceRefreshSession( - event: ExtendableMessageEvent, - options: UpsertOrDeactivateSessionPayload, - ) { - Log._debug('[Service Worker] debounceRefreshSession', options); - - if (self.cancel) { - self.cancel(); - self.cancel = undefined; +/** + * Given a structured notification object, HTTPS-ifies the notification icons and action button icons, if they exist. + */ +function ensureNotificationResourcesHttps( + notification: IMutableOSNotification, +) { + if (notification) { + if (notification.icon) { + notification.icon = ensureImageResourceHttps(notification.icon); } - - const executeRefreshSession = async () => { - await OneSignalServiceWorker._refreshSession(event, options); - }; - - const cancelableRefreshSession = cancelableTimeout( - executeRefreshSession, - 1, - ); - self.cancel = cancelableRefreshSession.cancel; - event.waitUntil(cancelableRefreshSession.promise); - } - - /** - * Given an image URL, returns a proxied HTTPS image using the https://images.weserv.nl service. - * For a null image, returns null so that no icon is displayed. - * If the image protocol is HTTPS, or origin contains localhost or starts with 192.168.*.*, we do not proxy the image. - * @param imageUrl An HTTP or HTTPS image URL. - */ - static _ensureImageResourceHttps(imageUrl?: string) { - if (imageUrl) { - try { - const parsedImageUrl = new URL(imageUrl); - if ( - parsedImageUrl.hostname === 'localhost' || - parsedImageUrl.hostname.indexOf('192.168') !== -1 || - parsedImageUrl.hostname === '127.0.0.1' || - parsedImageUrl.protocol === 'https:' - ) { - return imageUrl; - } - if ( - parsedImageUrl.hostname === 'i0.wp.com' || - parsedImageUrl.hostname === 'i1.wp.com' || - parsedImageUrl.hostname === 'i2.wp.com' || - parsedImageUrl.hostname === 'i3.wp.com' - ) { - /* Their site already uses Jetpack, just make sure Jetpack is HTTPS */ - return `https://${parsedImageUrl.hostname}${parsedImageUrl.pathname}`; - } - /* HTTPS origin hosts can be used by prefixing the hostname with ssl: */ - const replacedImageUrl = parsedImageUrl.host + parsedImageUrl.pathname; - return `https://i0.wp.com/${replacedImageUrl}`; - } catch (e) { - Log._error('ensureImageResourceHttps: ', e); - } + if (notification.image) { + notification.image = ensureImageResourceHttps(notification.image); } - return undefined; - } - - /** - * Given a structured notification object, HTTPS-ifies the notification icons and action button icons, if they exist. - */ - static _ensureNotificationResourcesHttps( - notification: IMutableOSNotification, - ) { - if (notification) { - if (notification.icon) { - notification.icon = OneSignalServiceWorker._ensureImageResourceHttps( - notification.icon, - ); - } - if (notification.image) { - notification.image = OneSignalServiceWorker._ensureImageResourceHttps( - notification.image, - ); - } - if (notification.actionButtons && notification.actionButtons.length > 0) { - for (const button of notification.actionButtons) { - if (button.icon) { - button.icon = OneSignalServiceWorker._ensureImageResourceHttps( - button.icon, - ); - } + if (notification.actionButtons && notification.actionButtons.length > 0) { + for (const button of notification.actionButtons) { + if (button.icon) { + button.icon = ensureImageResourceHttps(button.icon); } } } } +} - /** - * Actually displays a visible notification to the user. - * Any event needing to display a notification calls this so that all the display options can be centralized here. - * @param notification A structured notification object. - */ - static async _displayNotification(notification: IMutableOSNotification) { - Log._debug( - `Called displayNotification(${JSON.stringify(notification, null, 4)}):`, - notification, - ); +// Workaround: For Chromium browsers displaying an extra notification, even +// when background rules are followed. +// For reference, the notification body is "This site has been updated in the background". +// https://issues.chromium.org/issues/378103918 +function requiresMacOS15ChromiumAfterDisplayWorkaround(): boolean { + const userAgentData = (navigator as any).userAgentData; + const isMacOS = userAgentData?.platform === 'macOS'; + const isChromium = !!userAgentData?.brands?.some( + (item: { brand: string }) => item.brand === 'Chromium', + ); + return isMacOS && isChromium; +} - // Use the default title if one isn't provided - const defaultTitle = await OneSignalServiceWorker._getTitle(); - // Use the default icon if one isn't provided - const defaultIcon = await getOptionsValue('defaultIcon'); - // Get option of whether we should leave notification displaying indefinitely - const persistNotification = await getOptionsValue( - 'persistNotification', - ); +/** + * Actually displays a visible notification to the user. + * Any event needing to display a notification calls this so that all the display options can be centralized here. + * @param notification A structured notification object. + */ +async function displayNotification(notification: IMutableOSNotification) { + Log._debug( + `Called displayNotification(${JSON.stringify(notification, null, 4)}):`, + notification, + ); + + // Use the default title if one isn't provided + const defaultTitle = await getTitle(); + // Use the default icon if one isn't provided + const defaultIcon = await getOptionsValue('defaultIcon'); + // Get option of whether we should leave notification displaying indefinitely + const persistNotification = await getOptionsValue( + 'persistNotification', + ); + + // Get app ID for tag value + const appId = await getAppId(); + + notification.title = notification.title ? notification.title : defaultTitle; + notification.icon = notification.icon + ? notification.icon + : defaultIcon + ? defaultIcon + : undefined; + + ensureNotificationResourcesHttps(notification); + + const notificationOptions: NotificationOptions = { + body: notification.body, + icon: notification.icon, + /* + On Chrome 56, a large image can be displayed: + https://bugs.chromium.org/p/chromium/issues/detail?id=614456 + */ + // @ts-expect-error - image is not standard? + image: notification.image, + /* + On Chrome 44+, use this property to store extra information which + you can read back when the notification gets invoked from a + notification click or dismissed event. We serialize the + notification in the 'data' field and read it back in other events. + See: + https://developers.google.com/web/updates/2015/05/notifying-you-of-changes-to-notifications?hl=en + */ + data: notification, + /* + On Chrome 48+, action buttons show below the message body of the + notification. Clicking either button takes the user to a link. See: + https://developers.google.com/web/updates/2016/01/notification-actions + */ + actions: toNativeNotificationAction(notification.actionButtons), + /* + Tags are any string value that groups notifications together. Two + or notifications sharing a tag replace each other. + */ + tag: notification.topic || appId, + /* + On Chrome 47+ (desktop), notifications will be dismissed after 20 + seconds unless requireInteraction is set to true. See: + https://developers.google.com/web/updates/2015/10/notification-requireInteractiom + */ + requireInteraction: persistNotification !== false, + /* + On Chrome 50+, by default notifications replacing + identically-tagged notifications no longer vibrate/signal the user + that a new notification has come in. This flag allows subsequent + notifications to re-alert the user. See: + https://developers.google.com/web/updates/2016/03/notifications + */ + renotify: true, + /* + On Chrome 53+, returns the URL of the image used to represent the + notification when there is not enough space to display the + notification itself. + + The URL of an image to represent the notification when there is not + enough space to display the notification itself such as, for + example, the Android Notification Bar. On Android devices, the + badge should accommodate devices up to 4x resolution, about 96 by + 96 px, and the image will be automatically masked. + */ + badge: notification.badgeIcon, + }; - // Get app ID for tag value - const appId = await OneSignalServiceWorker._getAppId(); + await self.registration.showNotification( + notification.title, + notificationOptions, + ); - notification.title = notification.title ? notification.title : defaultTitle; - notification.icon = notification.icon - ? notification.icon - : defaultIcon - ? defaultIcon - : undefined; + if (requiresMacOS15ChromiumAfterDisplayWorkaround()) { + await delay(1_000); + } +} - OneSignalServiceWorker._ensureNotificationResourcesHttps(notification); +/** + * Returns false if the given URL matches a few special URLs designed to skip opening a URL when clicking a + * notification. Otherwise returns true and the link will be opened. + * @param url + */ +function shouldOpenNotificationUrl(url: string) { + return ( + url !== 'javascript:void(0);' && + url !== 'do_not_open' && + !containsMatch(url, '_osp=do_not_open') + ); +} - const notificationOptions: NotificationOptions = { - body: notification.body, - icon: notification.icon, - /* - On Chrome 56, a large image can be displayed: - https://bugs.chromium.org/p/chromium/issues/detail?id=614456 - */ - // @ts-expect-error - image is not standard? - image: notification.image, - /* - On Chrome 44+, use this property to store extra information which - you can read back when the notification gets invoked from a - notification click or dismissed event. We serialize the - notification in the 'data' field and read it back in other events. - See: - https://developers.google.com/web/updates/2015/05/notifying-you-of-changes-to-notifications?hl=en - */ - data: notification, - /* - On Chrome 48+, action buttons show below the message body of the - notification. Clicking either button takes the user to a link. See: - https://developers.google.com/web/updates/2016/01/notification-actions - */ - actions: toNativeNotificationAction(notification.actionButtons), - /* - Tags are any string value that groups notifications together. Two - or notifications sharing a tag replace each other. - */ - tag: notification.topic || appId, - /* - On Chrome 47+ (desktop), notifications will be dismissed after 20 - seconds unless requireInteraction is set to true. See: - https://developers.google.com/web/updates/2015/10/notification-requireInteractiom - */ - requireInteraction: persistNotification !== false, - /* - On Chrome 50+, by default notifications replacing - identically-tagged notifications no longer vibrate/signal the user - that a new notification has come in. This flag allows subsequent - notifications to re-alert the user. See: - https://developers.google.com/web/updates/2016/03/notifications - */ - renotify: true, - /* - On Chrome 53+, returns the URL of the image used to represent the - notification when there is not enough space to display the - notification itself. - - The URL of an image to represent the notification when there is not - enough space to display the notification itself such as, for - example, the Android Notification Bar. On Android devices, the - badge should accommodate devices up to 4x resolution, about 96 by - 96 px, and the image will be automatically masked. - */ - badge: notification.badgeIcon, - }; +/** + * Occurs when a notification is dismissed by the user (clicking the 'X') or all notifications are cleared. + * Supported on: Chrome 50+ only + */ +async function onNotificationClosed(event: NotificationEvent) { + Log._debug( + `Called onNotificationClosed(${JSON.stringify(event, null, 4)}):`, + event, + ); + const notification = event.notification.data as IOSNotification; + + workerMessenger + ._broadcast(WorkerMessengerCommand.NotificationDismissed, notification) + .catch((e) => Log._error(e)); + const pushSubscriptionId = await getPushSubscriptionId(); + + notificationDismissed(notification, pushSubscriptionId); +} - await self.registration.showNotification( - notification.title, - notificationOptions, +/** + * After clicking a notification, determines the URL to open based on whether an action button was clicked or the + * notification body was clicked. + */ +async function getNotificationUrlToOpen( + notification: IOSNotification, + actionId?: string, +): Promise { + // If the user clicked an action button, use the URL provided by the action button. + // Unless the action button URL is null + if (actionId) { + const clickedButton = notification?.actionButtons?.find( + (button) => button.actionId === actionId, ); - - if (this._requiresMacOS15ChromiumAfterDisplayWorkaround()) { - await delay(1_000); + if (clickedButton?.launchURL && clickedButton.launchURL !== '') { + return clickedButton.launchURL; } } - // Workaround: For Chromium browsers displaying an extra notification, even - // when background rules are followed. - // For reference, the notification body is "This site has been updated in the background". - // https://issues.chromium.org/issues/378103918 - static _requiresMacOS15ChromiumAfterDisplayWorkaround(): boolean { - const userAgentData = (navigator as any).userAgentData; - const isMacOS = userAgentData?.platform === 'macOS'; - const isChromium = !!userAgentData?.brands?.some( - (item: { brand: string }) => item.brand === 'Chromium', - ); - return isMacOS && isChromium; + if (notification.launchURL && notification.launchURL !== '') { + return notification.launchURL; } - /** - * Returns false if the given URL matches a few special URLs designed to skip opening a URL when clicking a - * notification. Otherwise returns true and the link will be opened. - * @param url - */ - static _shouldOpenNotificationUrl(url: string) { - return ( - url !== 'javascript:void(0);' && - url !== 'do_not_open' && - !containsMatch(url, '_osp=do_not_open') - ); + const { defaultNotificationUrl: dbDefaultNotificationUrl } = + await getAppState(); + if (dbDefaultNotificationUrl) { + return dbDefaultNotificationUrl; } - /** - * Occurs when a notification is dismissed by the user (clicking the 'X') or all notifications are cleared. - * Supported on: Chrome 50+ only - */ - static async _onNotificationClosed(event: NotificationEvent) { - Log._debug( - `Called onNotificationClosed(${JSON.stringify(event, null, 4)}):`, - event, - ); - const notification = event.notification.data as IOSNotification; + return location.origin; +} - OneSignalServiceWorker._workerMessenger - ._broadcast(WorkerMessengerCommand.NotificationDismissed, notification) - .catch((e) => Log._error(e)); - const pushSubscriptionId = - await OneSignalServiceWorker._getPushSubscriptionId(); +/** + * Occurs when the notification's body or action buttons are clicked. Does not occur if the notification is + * dismissed by clicking the 'X' icon. See the notification close event for the dismissal event. + */ +async function onNotificationClicked(event: NotificationEvent) { + Log._debug( + `Called onNotificationClicked(${JSON.stringify(event, null, 4)}):`, + event, + ); + + // Close the notification first here, before we do anything that might fail + event.notification.close(); + + const osNotification = event.notification.data as IOSNotification; + + let notificationClickHandlerMatch = 'exact'; + let notificationClickHandlerAction = 'navigate'; + + const matchPreference = await getOptionsValue( + 'notificationClickHandlerMatch', + ); + if (matchPreference) notificationClickHandlerMatch = matchPreference; + + const actionPreference = await getOptionsValue( + 'notificationClickHandlerAction', + ); + if (actionPreference) notificationClickHandlerAction = actionPreference; + + const launchUrl = await getNotificationUrlToOpen( + osNotification, + event.action, + ); + const notificationOpensLink: boolean = shouldOpenNotificationUrl(launchUrl); + const appId = await getAppId(); + const deviceType = getDeviceType(); + + const notificationClickEvent: NotificationClickEventInternal = { + notification: osNotification, + result: { + actionId: event.action, + url: launchUrl, + }, + timestamp: new Date().getTime(), + }; + + Log._info('NotificationClicked', notificationClickEvent); + const saveNotificationClickedPromise = (async (notificationClickEvent) => { + try { + const existingSession = await getCurrentSession(); + if (existingSession && existingSession.status === SessionStatus.Active) { + return; + } - notificationDismissed(notification, pushSubscriptionId); - } + await putNotificationClickedForOutcomes(appId, notificationClickEvent); - /** - * After clicking a notification, determines the URL to open based on whether an action button was clicked or the - * notification body was clicked. - */ - static async _getNotificationUrlToOpen( - notification: IOSNotification, - actionId?: string, - ): Promise { - // If the user clicked an action button, use the URL provided by the action button. - // Unless the action button URL is null - if (actionId) { - const clickedButton = notification?.actionButtons?.find( - (button) => button.actionId === actionId, - ); - if (clickedButton?.launchURL && clickedButton.launchURL !== '') { - return clickedButton.launchURL; + // upgrade existing session to be directly attributed to the notif + // if it results in re-focusing the site + if (existingSession) { + existingSession.notificationId = + notificationClickEvent.notification.notificationId; + await db.put('Sessions', existingSession); } + } catch (e) { + Log._error('Failed to save clicked notification.', e); } - - if (notification.launchURL && notification.launchURL !== '') { - return notification.launchURL; + })(notificationClickEvent); + + // Start making REST API requests BEFORE self.clients.openWindow is called. + // It will cause the service worker to stop on Chrome for Android when site is added to the home screen. + const pushSubscriptionId = await getPushSubscriptionId(); + const convertedAPIRequests = sendConvertedAPIRequests( + appId, + pushSubscriptionId, + notificationClickEvent, + deviceType, + ); + + /* + Check if we can focus on an existing tab instead of opening a new url. + If an existing tab with exactly the same URL already exists, then this existing tab is focused instead of + an identical new tab being created. With a special setting, any existing tab matching the origin will + be focused instead of an identical new tab being created. + */ + const activeClients = await getWindowClients(); + let doNotOpenLink = false; + for (const client of activeClients) { + const clientUrl = client.url; + let clientOrigin = ''; + try { + clientOrigin = new URL(clientUrl).origin; + } catch (e) { + Log._error(`Failed to get the HTTP site's actual origin:`, e); } - - const { defaultNotificationUrl: dbDefaultNotificationUrl } = - await getAppState(); - if (dbDefaultNotificationUrl) { - return dbDefaultNotificationUrl; + let launchOrigin = null; + try { + // Check if the launchUrl is valid; it can be null + launchOrigin = new URL(launchUrl).origin; + } catch (e) { + Log._error(`Failed parse launchUrl:`, e); } - return location.origin; - } - - /** - * Occurs when the notification's body or action buttons are clicked. Does not occur if the notification is - * dismissed by clicking the 'X' icon. See the notification close event for the dismissal event. - */ - static async _onNotificationClicked(event: NotificationEvent) { - Log._debug( - `Called onNotificationClicked(${JSON.stringify(event, null, 4)}):`, - event, - ); - - // Close the notification first here, before we do anything that might fail - event.notification.close(); - - const osNotification = event.notification.data as IOSNotification; - - let notificationClickHandlerMatch = 'exact'; - let notificationClickHandlerAction = 'navigate'; - - const matchPreference = await getOptionsValue( - 'notificationClickHandlerMatch', - ); - if (matchPreference) notificationClickHandlerMatch = matchPreference; - - const actionPreference = await getOptionsValue( - 'notificationClickHandlerAction', - ); - if (actionPreference) notificationClickHandlerAction = actionPreference; - - const launchUrl = await OneSignalServiceWorker._getNotificationUrlToOpen( - osNotification, - event.action, - ); - const notificationOpensLink: boolean = - OneSignalServiceWorker._shouldOpenNotificationUrl(launchUrl); - const appId = await OneSignalServiceWorker._getAppId(); - const deviceType = getDeviceType(); - - const notificationClickEvent: NotificationClickEventInternal = { - notification: osNotification, - result: { - actionId: event.action, - url: launchUrl, - }, - timestamp: new Date().getTime(), - }; - - Log._info('NotificationClicked', notificationClickEvent); - const saveNotificationClickedPromise = (async (notificationClickEvent) => { - try { - const existingSession = await getCurrentSession(); - if ( - existingSession && - existingSession.status === SessionStatus.Active - ) { - return; - } - - await putNotificationClickedForOutcomes(appId, notificationClickEvent); - - // upgrade existing session to be directly attributed to the notif - // if it results in re-focusing the site - if (existingSession) { - existingSession.notificationId = - notificationClickEvent.notification.notificationId; - await db.put('Sessions', existingSession); - } - } catch (e) { - Log._error('Failed to save clicked notification.', e); - } - })(notificationClickEvent); - - // Start making REST API requests BEFORE self.clients.openWindow is called. - // It will cause the service worker to stop on Chrome for Android when site is added to the home screen. - const pushSubscriptionId = await this._getPushSubscriptionId(); - const convertedAPIRequests = - OneSignalServiceWorker._sendConvertedAPIRequests( - appId, - pushSubscriptionId, - notificationClickEvent, - deviceType, - ); - - /* - Check if we can focus on an existing tab instead of opening a new url. - If an existing tab with exactly the same URL already exists, then this existing tab is focused instead of - an identical new tab being created. With a special setting, any existing tab matching the origin will - be focused instead of an identical new tab being created. - */ - const activeClients = await OneSignalServiceWorker._getWindowClients(); - let doNotOpenLink = false; - for (const client of activeClients) { - const clientUrl = client.url; - let clientOrigin = ''; - try { - clientOrigin = new URL(clientUrl).origin; - } catch (e) { - Log._error(`Failed to get the HTTP site's actual origin:`, e); - } - let launchOrigin = null; - try { - // Check if the launchUrl is valid; it can be null - launchOrigin = new URL(launchUrl).origin; - } catch (e) { - Log._error(`Failed parse launchUrl:`, e); - } - + if ( + (notificationClickHandlerMatch === 'exact' && clientUrl === launchUrl) || + (notificationClickHandlerMatch === 'origin' && + clientOrigin === launchOrigin) + ) { if ( - (notificationClickHandlerMatch === 'exact' && - clientUrl === launchUrl) || - (notificationClickHandlerMatch === 'origin' && + client.url === launchUrl || + (notificationClickHandlerAction === 'focus' && clientOrigin === launchOrigin) ) { - if ( - client.url === launchUrl || - (notificationClickHandlerAction === 'focus' && - clientOrigin === launchOrigin) - ) { - OneSignalServiceWorker._workerMessenger._unicast( - WorkerMessengerCommand.NotificationClicked, - notificationClickEvent, - client, - ); + workerMessenger._unicast( + WorkerMessengerCommand.NotificationClicked, + notificationClickEvent, + client, + ); + try { + if (client instanceof WindowClient) await client.focus(); + } catch (e) { + Log._error('Failed to focus:', client, e); + } + } else { + /* + We must focus first; once the client navigates away, it may not be on a domain the same domain, and + the client ID may change, making it unable to focus. + + client.navigate() is available on Chrome 49+ and Firefox 50+. + */ + if (client instanceof WindowClient && client.navigate) { try { + Log._debug( + 'Client is standard HTTPS site. Attempting to focus() client.', + ); if (client instanceof WindowClient) await client.focus(); } catch (e) { Log._error('Failed to focus:', client, e); } - } else { - /* - We must focus first; once the client navigates away, it may not be on a domain the same domain, and - the client ID may change, making it unable to focus. - - client.navigate() is available on Chrome 49+ and Firefox 50+. - */ - if (client instanceof WindowClient && client.navigate) { - try { - Log._debug( - 'Client is standard HTTPS site. Attempting to focus() client.', - ); - if (client instanceof WindowClient) await client.focus(); - } catch (e) { - Log._error('Failed to focus:', client, e); - } - try { - if (notificationOpensLink) { - Log._debug(`Redirecting HTTPS site to (${launchUrl}).`); - await client.navigate(launchUrl); - } else { - Log._debug('Not navigating because link is special.'); - } - } catch (e) { - Log._error('Failed to navigate:', client, launchUrl, e); + try { + if (notificationOpensLink) { + Log._debug(`Redirecting HTTPS site to (${launchUrl}).`); + await client.navigate(launchUrl); + } else { + Log._debug('Not navigating because link is special.'); } - } else { - // If client.navigate() isn't available, we have no other option but to open a new tab to the URL. - await OneSignalServiceWorker._openUrl(launchUrl); + } catch (e) { + Log._error('Failed to navigate:', client, launchUrl, e); } + } else { + // If client.navigate() isn't available, we have no other option but to open a new tab to the URL. + await openUrl(launchUrl); } - doNotOpenLink = true; - break; } + doNotOpenLink = true; + break; } - - if (notificationOpensLink && !doNotOpenLink) { - await OneSignalServiceWorker._openUrl(launchUrl); - } - if (saveNotificationClickedPromise) { - await saveNotificationClickedPromise; - } - - return await convertedAPIRequests; } - /** - * Makes network calls for the notification open event to; - * 1. OneSignal.com to increase the notification open count. - * 2. A website developer defined webhook URL, if set. - */ - static async _sendConvertedAPIRequests( - appId: string | undefined | null, - pushSubscriptionId: string | undefined, - notificationClickEvent: NotificationClickEventInternal, - deviceType: DeliveryPlatformKindValue, - ): Promise { - const notificationData = notificationClickEvent.notification; - - if (!notificationData.notificationId) { - console.error( - 'No notification id, skipping networks calls to report open!', - ); - return; - } + if (notificationOpensLink && !doNotOpenLink) { + await openUrl(launchUrl); + } + if (saveNotificationClickedPromise) { + await saveNotificationClickedPromise; + } - let onesignalRestPromise: Promise | undefined; - - if (appId) { - onesignalRestPromise = OneSignalApiBase.put( - `notifications/${notificationData.notificationId}`, - { - app_id: appId, - player_id: pushSubscriptionId, - opened: true, - device_type: deviceType, - }, - ); - } else { - console.error( - 'No app Id, skipping OneSignal API call for notification open!', - ); - } + return await convertedAPIRequests; +} - await notificationClick(notificationClickEvent, pushSubscriptionId); - if (onesignalRestPromise) await onesignalRestPromise; +/** + * Makes network calls for the notification open event to; + * 1. OneSignal.com to increase the notification open count. + * 2. A website developer defined webhook URL, if set. + */ +async function sendConvertedAPIRequests( + appId: string | undefined | null, + pushSubscriptionId: string | undefined, + notificationClickEvent: NotificationClickEventInternal, + deviceType: DeliveryPlatformKindValue, +): Promise { + const notificationData = notificationClickEvent.notification; + + if (!notificationData.notificationId) { + console.error( + 'No notification id, skipping networks calls to report open!', + ); + return; } - /** - * Attempts to open the given url in a new browser tab. Called when a notification is clicked. - * @param url May not be well-formed. - */ - static async _openUrl(url: string): Promise { - Log._debug('Opening notification URL:', url); - try { - return await self.clients.openWindow(url); - } catch (e) { - Log._warn(`Failed to open the URL '${url}':`, e); - return null; - } - } + let onesignalRestPromise: Promise | undefined; - /** - * Fires when the ServiceWorker can control pages. - * @param event - */ - static _onServiceWorkerActivated(event: ExtendableEvent) { - Log._info(`OneSignal Service Worker activated (version ${VERSION})`); - event.waitUntil(self.clients.claim()); + if (appId) { + onesignalRestPromise = OneSignalApiBase.put( + `notifications/${notificationData.notificationId}`, + { + app_id: appId, + player_id: pushSubscriptionId, + opened: true, + device_type: deviceType, + }, + ); + } else { + console.error( + 'No app Id, skipping OneSignal API call for notification open!', + ); } - static async _onPushSubscriptionChange(event: SubscriptionChangeEvent) { - Log._debug( - `Called onPushSubscriptionChange(${JSON.stringify(event, null, 4)}):`, - event, - ); + await notificationClick(notificationClickEvent, pushSubscriptionId); + if (onesignalRestPromise) await onesignalRestPromise; +} - const appId = await OneSignalServiceWorker._getAppId(); - if (!appId) { - // Without an app ID, we can't make any calls - return; - } - const appConfig = await getServerAppConfig( - { appId }, - downloadSWServerAppConfig, - ); - if (!appConfig) { - // Without a valid app config (e.g. deleted app), we can't make any calls - return; - } - const context = new ContextSW(appConfig); - - // Get our current device ID - let deviceIdExists: boolean; - { - let deviceId: string | null | undefined = (await getSubscription()) - .deviceId; - - deviceIdExists = !!deviceId; - if (!deviceIdExists && event.oldSubscription) { - // We don't have the device ID stored, but we can look it up from our old subscription - deviceId = await getUserIdFromSubscriptionIdentifier( - appId, - getDeviceType(), - event.oldSubscription.endpoint, - ); +/** + * Attempts to open the given url in a new browser tab. Called when a notification is clicked. + * @param url May not be well-formed. + */ +async function openUrl(url: string): Promise { + Log._debug('Opening notification URL:', url); + try { + return await self.clients.openWindow(url); + } catch (e) { + Log._warn(`Failed to open the URL '${url}':`, e); + return null; + } +} - // Store the device ID, so it can be looked up when subscribing - const subscription = await getSubscription(); - subscription.deviceId = deviceId; - await setSubscription(subscription); - } - deviceIdExists = !!deviceId; - } +/** + * Fires when the ServiceWorker can control pages. + * @param event + */ +function onServiceWorkerActivated(event: ExtendableEvent) { + Log._info(`OneSignal Service Worker activated (version ${VERSION})`); + event.waitUntil(self.clients.claim()); +} - // Get our new push subscription - let rawPushSubscription: RawPushSubscription | undefined; +async function onPushSubscriptionChange(event: SubscriptionChangeEvent) { + Log._debug( + `Called onPushSubscriptionChange(${JSON.stringify(event, null, 4)}):`, + event, + ); - // Set it initially by the provided new push subscription - const providedNewSubscription = event.newSubscription; - if (providedNewSubscription) { - rawPushSubscription = RawPushSubscription._setFromW3cSubscription( - providedNewSubscription, + const appId = await getAppId(); + if (!appId) { + // Without an app ID, we can't make any calls + return; + } + const appConfig = await getServerAppConfig( + { appId }, + downloadSWServerAppConfig, + ); + if (!appConfig) { + // Without a valid app config (e.g. deleted app), we can't make any calls + return; + } + const context = new ContextSW(appConfig); + + // Get our current device ID + let deviceIdExists: boolean; + { + let deviceId: string | null | undefined = (await getSubscription()) + .deviceId; + + deviceIdExists = !!deviceId; + if (!deviceIdExists && event.oldSubscription) { + // We don't have the device ID stored, but we can look it up from our old subscription + deviceId = await getUserIdFromSubscriptionIdentifier( + appId, + getDeviceType(), + event.oldSubscription.endpoint, ); - } else { - // Otherwise set our push registration by resubscribing - try { - rawPushSubscription = await context._subscriptionManager._subscribe( - SubscriptionStrategyKind.SubscribeNew, - ); - } catch (e) { - // Let rawPushSubscription be null - } - } - const hasNewSubscription = !!rawPushSubscription; - if (!deviceIdExists && !hasNewSubscription) { - await db.delete('Ids', 'userId'); - await db.delete('Ids', 'registrationId'); - } else { - /* - Determine subscription state we should set new record to. - - If the permission is revoked, we should set the subscription state to permission revoked. - */ - let subscriptionState: null | NotificationTypeValue = null; - const pushPermission = Notification.permission; + // Store the device ID, so it can be looked up when subscribing + const subscription = await getSubscription(); + subscription.deviceId = deviceId; + await setSubscription(subscription); + } + deviceIdExists = !!deviceId; + } - if (pushPermission !== 'granted') { - subscriptionState = NotificationType.PermissionRevoked; - } else if (!rawPushSubscription) { - /* - If it's not a permission revoked issue, the subscription expired or was revoked by the - push server. - */ - subscriptionState = NotificationType.PushSubscriptionRevoked; - } + // Get our new push subscription + let rawPushSubscription: RawPushSubscription | undefined; - // rawPushSubscription may be null if no push subscription was retrieved - await context._subscriptionManager._registerSubscription( - rawPushSubscription, - subscriptionState, + // Set it initially by the provided new push subscription + const providedNewSubscription = event.newSubscription; + if (providedNewSubscription) { + rawPushSubscription = RawPushSubscription._setFromW3cSubscription( + providedNewSubscription, + ); + } else { + // Otherwise set our push registration by resubscribing + try { + rawPushSubscription = await context._subscriptionManager._subscribe( + SubscriptionStrategyKind.SubscribeNew, ); + } catch (e) { + // Let rawPushSubscription be null } } + const hasNewSubscription = !!rawPushSubscription; - /** - * Returns a promise that is fulfilled with either the default title from the database (first priority) or the page title from the database (alternate result). - */ - static _getTitle(): Promise { - return new Promise((resolve) => { - Promise.all([ - getOptionsValue('defaultTitle'), - getOptionsValue('pageTitle'), - ]).then(([defaultTitle, pageTitle]) => { - if (defaultTitle !== null) { - resolve(defaultTitle); - } else if (pageTitle != null) { - resolve(pageTitle); - } else { - resolve(''); - } - }); - }); - } + if (!deviceIdExists && !hasNewSubscription) { + await db.delete('Ids', 'userId'); + await db.delete('Ids', 'registrationId'); + } else { + /* + Determine subscription state we should set new record to. - /** - * Returns an array of raw notification objects, read from the event.data.payload property - * @param event - * @returns An array of notifications. The new web push protocol will only ever contain one notification, however - * an array is returned for backwards compatibility with the rest of the service worker plumbing. - */ - static _parseOrFetchNotifications( - event: PushEvent, - ): Promise { - if (!event || !event.data) { - return Promise.reject('Missing event.data on push payload!'); - } + If the permission is revoked, we should set the subscription state to permission revoked. + */ + let subscriptionState: null | NotificationTypeValue = null; + const pushPermission = Notification.permission; - const isValidPayload = OneSignalServiceWorker._isValidPushPayload( - event.data, - ); - if (isValidPayload) { - Log._debug('Received a valid encrypted push payload.'); - const payload: OSMinifiedNotificationPayload = event.data.json(); - return Promise.resolve([payload]); + if (pushPermission !== 'granted') { + subscriptionState = NotificationType.PermissionRevoked; + } else if (!rawPushSubscription) { + /* + If it's not a permission revoked issue, the subscription expired or was revoked by the + push server. + */ + subscriptionState = NotificationType.PushSubscriptionRevoked; } - /* - We received a push message payload from another service provider or a malformed - payload. The last received notification will be displayed. - */ - return Promise.reject( - `Unexpected push message payload received: ${event.data}`, + // rawPushSubscription may be null if no push subscription was retrieved + await context._subscriptionManager._registerSubscription( + rawPushSubscription, + subscriptionState, ); } +} - /** - * Returns true if the raw data payload is a OneSignal push message in the format of the new web push protocol. - * Otherwise returns false. - * @param rawData The raw PushMessageData from the push event's event.data, not already parsed to JSON. - */ - static _isValidPushPayload(rawData: PushMessageData) { - try { - const payload = rawData.json(); - if (isValidPayload(payload)) { - return true; +/** + * Returns a promise that is fulfilled with either the default title from the database (first priority) or the page title from the database (alternate result). + */ +function getTitle(): Promise { + return new Promise((resolve) => { + Promise.all([ + getOptionsValue('defaultTitle'), + getOptionsValue('pageTitle'), + ]).then(([defaultTitle, pageTitle]) => { + if (defaultTitle !== null) { + resolve(defaultTitle); + } else if (pageTitle != null) { + resolve(pageTitle); } else { - Log._debug( - 'isValidPushPayload: Valid JSON but missing notification UUID:', - payload, - ); - return false; + resolve(''); } - } catch (e) { - Log._debug('isValidPushPayload: Parsing to JSON failed with:', e); + }); + }); +} + +/** + * Returns true if the raw data payload is a OneSignal push message in the format of the new web push protocol. + * Otherwise returns false. + * @param rawData The raw PushMessageData from the push event's event.data, not already parsed to JSON. + */ +function isValidPushPayload(rawData: PushMessageData) { + try { + const payload = rawData.json(); + if (isValidPayload(payload)) { + return true; + } else { + Log._debug( + 'isValidPushPayload: Valid JSON but missing notification UUID:', + payload, + ); return false; } + } catch (e) { + Log._debug('isValidPushPayload: Parsing to JSON failed with:', e); + return false; } } -OneSignalServiceWorker._run(); +/** + * Returns an array of raw notification objects, read from the event.data.payload property + * @param event + * @returns An array of notifications. The new web push protocol will only ever contain one notification, however + * an array is returned for backwards compatibility with the rest of the service worker plumbing. + */ +function parseOrFetchNotifications( + event: PushEvent, +): Promise { + if (!event || !event.data) { + return Promise.reject('Missing event.data on push payload!'); + } + + const isValidPayload = isValidPushPayload(event.data); + if (isValidPayload) { + Log._debug('Received a valid encrypted push payload.'); + const payload: OSMinifiedNotificationPayload = event.data.json(); + return Promise.resolve([payload]); + } + + /* + We received a push message payload from another service provider or a malformed + payload. The last received notification will be displayed. + */ + return Promise.reject( + `Unexpected push message payload received: ${event.data}`, + ); +} + +run(); From 7641df6928d49497ef26449f2a53390e3685c566 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 14:28:00 -0700 Subject: [PATCH 3/9] break up tag utils --- package.json | 2 +- .../slidedownManager/SlidedownManager.ts | 9 +- src/page/managers/tagManager/TagManager.ts | 12 +- src/page/slidedown/TaggingContainer.ts | 4 +- src/shared/utils/TagUtils.ts | 105 ------------------ .../utils/{TagUtils.test.ts => tags.test.ts} | 2 +- src/shared/utils/tags.ts | 104 +++++++++++++++++ 7 files changed, 122 insertions(+), 116 deletions(-) delete mode 100644 src/shared/utils/TagUtils.ts rename src/shared/utils/{TagUtils.test.ts => tags.test.ts} (98%) create mode 100644 src/shared/utils/tags.ts diff --git a/package.json b/package.json index fc10f91a6..61417a486 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "47.44 kB", + "limit": "47.33 kB", "gzip": true }, { diff --git a/src/page/managers/slidedownManager/SlidedownManager.ts b/src/page/managers/slidedownManager/SlidedownManager.ts index 19e8b8511..970f7a11b 100644 --- a/src/page/managers/slidedownManager/SlidedownManager.ts +++ b/src/page/managers/slidedownManager/SlidedownManager.ts @@ -18,6 +18,10 @@ import { } from 'src/shared/prompts/constants'; import { isSlidedownPushDependent } from 'src/shared/prompts/helpers'; import type { DelayedPromptTypeValue } from 'src/shared/prompts/types'; +import { + convertTagsApiToBooleans, + markAllTagsAsSpecified, +} from 'src/shared/utils/tags'; import { logMethodCall } from 'src/shared/utils/utils'; import { CoreModuleDirector } from '../../../core/CoreModuleDirector'; import { @@ -26,7 +30,6 @@ import { } from '../../../shared/helpers/DismissHelper'; import Log from '../../../shared/libraries/Log'; import type { PushSubscriptionState } from '../../../shared/models/PushSubscriptionState'; -import TagUtils from '../../../shared/utils/TagUtils'; import { DismissPrompt } from '../../models/Dismiss'; import ChannelCaptureContainer from '../../slidedown/ChannelCaptureContainer'; import ConfirmationToast from '../../slidedown/ConfirmationToast'; @@ -307,12 +310,12 @@ export class SlidedownManager { this._context._tagManager._storeRemotePlayerTags( existingTags as TagsObjectForApi, ); - tagsForComponent = TagUtils.convertTagsApiToBooleans( + tagsForComponent = convertTagsApiToBooleans( existingTags as TagsObjectForApi, ); } else { // first subscription or no existing tags - TagUtils.markAllTagsAsSpecified(categories, true); + markAllTagsAsSpecified(categories, true); } taggingContainer._mount(categories, tagsForComponent); diff --git a/src/page/managers/tagManager/TagManager.ts b/src/page/managers/tagManager/TagManager.ts index 8be427384..9d6cc8e22 100644 --- a/src/page/managers/tagManager/TagManager.ts +++ b/src/page/managers/tagManager/TagManager.ts @@ -3,8 +3,12 @@ import type { TagsObjectWithBoolean, } from 'src/page/tags/types'; import type { ContextInterface } from 'src/shared/context/types'; +import { + convertTagsBooleansToApi, + getObjectDifference, + isTagObjectEmpty, +} from 'src/shared/utils/tags'; import Log from '../../../shared/libraries/Log'; -import TagUtils from '../../../shared/utils/TagUtils'; import type { ITagManager } from './types'; /** @@ -26,15 +30,15 @@ export default class TagManager implements ITagManager { public async _sendTags(): Promise { Log._info('Category Slidedown Local Tags:', this._tagsFromTaggingContainer); - const localTagsConvertedToApi = TagUtils.convertTagsBooleansToApi( + const localTagsConvertedToApi = convertTagsBooleansToApi( this._tagsFromTaggingContainer, ); - const finalTagsObject = TagUtils.getObjectDifference( + const finalTagsObject = getObjectDifference( localTagsConvertedToApi, this._remoteTags, ); - const shouldSendUpdate = !TagUtils.isTagObjectEmpty(finalTagsObject); + const shouldSendUpdate = !isTagObjectEmpty(finalTagsObject); if (shouldSendUpdate) { await OneSignal.User.addTags(finalTagsObject); return finalTagsObject; diff --git a/src/page/slidedown/TaggingContainer.ts b/src/page/slidedown/TaggingContainer.ts index 711d5f382..c3642cf15 100644 --- a/src/page/slidedown/TaggingContainer.ts +++ b/src/page/slidedown/TaggingContainer.ts @@ -6,6 +6,7 @@ import { removeCssClass, removeDomElement, } from 'src/shared/helpers/dom'; +import { getCheckedTagCategories } from 'src/shared/utils/tags'; import { COLORS, SLIDEDOWN_CSS_CLASSES, @@ -14,7 +15,6 @@ import { TAGGING_CONTAINER_CSS_IDS, TAGGING_CONTAINER_STRINGS, } from '../../shared/slidedown/constants'; -import TagUtils from '../../shared/utils/TagUtils'; import type { TagsObjectWithBoolean } from '../tags/types'; import { getLoadingIndicatorWithColor } from './LoadingIndicator'; @@ -79,7 +79,7 @@ export default class TaggingContainer { remoteTagCategories: TagCategory[], existingPlayerTags?: TagsObjectWithBoolean, ): Element { - const checkedTagCategories = TagUtils.getCheckedTagCategories( + const checkedTagCategories = getCheckedTagCategories( remoteTagCategories, existingPlayerTags, ); diff --git a/src/shared/utils/TagUtils.ts b/src/shared/utils/TagUtils.ts deleted file mode 100644 index 336d51d05..000000000 --- a/src/shared/utils/TagUtils.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - TagCategory, - TagsObjectForApi, - TagsObjectWithBoolean, -} from 'src/page/tags/types'; - -export default class TagUtils { - static convertTagsApiToBooleans( - tags: TagsObjectForApi, - ): TagsObjectWithBoolean { - const convertedTags: TagsObjectWithBoolean = {}; - Object.keys(tags).forEach((key) => { - convertedTags[key] = tags[key] === '1' ? true : false; - }); - return convertedTags; - } - - static convertTagsBooleansToApi( - tags: TagsObjectWithBoolean, - ): TagsObjectForApi { - const convertedTags: TagsObjectForApi = {}; - Object.keys(tags).forEach((key) => { - convertedTags[key] = tags[key] === true ? '1' : '0'; - }); - return convertedTags; - } - - /** - * Used in determining what Tag/Category preferences changed in order - * to only update what is necessary - * @param {TagsObject} localTags - tags from taggingContainer with values of type "number" - * @param {TagsObject} remoteTags - remote player tags with values of type "number" - * @returns array of keys of corresponding different values (finds difference) - */ - static getObjectDifference( - localTags: TagsObjectForApi, - remoteTags: TagsObjectForApi, - ): TagsObjectForApi { - const finalTags: TagsObjectForApi = {}; - - // Going off local tags since it's our categories. Trying to find only changed tags and returning those - // as a final object. - Object.keys(localTags).forEach((key) => { - // only if user's tag value did not change, skip it - if (remoteTags[key] === localTags[key]) { - return; - } - - finalTags[key] = localTags[key]; - }); - return finalTags; - } - - static markAllTagsAsSpecified( - categoryArray: TagCategory[], - checked: boolean, - ): void { - categoryArray.forEach((category) => { - category.checked = checked; - }); - } - - static isTagObjectEmpty( - tags: TagsObjectForApi | TagsObjectWithBoolean, - ): boolean { - return Object.keys(tags).length === 0; - } - /** - * Uses configured categories and remote player tags to calculate which boxes should be checked - * @param {TagCategory[]} categories - * @param {TagsObjectWithBoolean} existingPlayerTags? - */ - static getCheckedTagCategories( - categories: TagCategory[], - existingPlayerTags?: TagsObjectWithBoolean, - ): TagCategory[] { - if (!existingPlayerTags) { - return categories; - } - - const isExistingPlayerTagsEmpty = - TagUtils.isTagObjectEmpty(existingPlayerTags); - if (isExistingPlayerTagsEmpty) { - const categoriesCopy = structuredClone(categories); - TagUtils.markAllTagsAsSpecified(categoriesCopy, true); - return categoriesCopy; - } - - const categoriesCopy = structuredClone(categories); - return categoriesCopy.map((category) => { - const existingTagValue: boolean = existingPlayerTags[category.tag]; - category.checked = TagUtils.getCheckedStatusForTagValue(existingTagValue); - return category; - }); - } - - static getCheckedStatusForTagValue(tagValue: boolean | undefined): boolean { - // If user does not have tag assigned to them, consider it selected - if (tagValue === undefined) { - return true; - } - - return tagValue; - } -} diff --git a/src/shared/utils/TagUtils.test.ts b/src/shared/utils/tags.test.ts similarity index 98% rename from src/shared/utils/TagUtils.test.ts rename to src/shared/utils/tags.test.ts index c232452be..3bb3fec4e 100644 --- a/src/shared/utils/TagUtils.test.ts +++ b/src/shared/utils/tags.test.ts @@ -3,7 +3,7 @@ import type { TagsObjectForApi, TagsObjectWithBoolean, } from 'src/page/tags/types'; -import TagUtils from './TagUtils'; +import * as TagUtils from './tags'; describe('TagUtils', () => { test('should convert api tags to boolean format', () => { diff --git a/src/shared/utils/tags.ts b/src/shared/utils/tags.ts new file mode 100644 index 000000000..0c1a1849d --- /dev/null +++ b/src/shared/utils/tags.ts @@ -0,0 +1,104 @@ +import type { + TagCategory, + TagsObjectForApi, + TagsObjectWithBoolean, +} from 'src/page/tags/types'; + +export function convertTagsApiToBooleans( + tags: TagsObjectForApi, +): TagsObjectWithBoolean { + const convertedTags: TagsObjectWithBoolean = {}; + Object.keys(tags).forEach((key) => { + convertedTags[key] = tags[key] === '1' ? true : false; + }); + return convertedTags; +} + +export function convertTagsBooleansToApi( + tags: TagsObjectWithBoolean, +): TagsObjectForApi { + const convertedTags: TagsObjectForApi = {}; + Object.keys(tags).forEach((key) => { + convertedTags[key] = tags[key] === true ? '1' : '0'; + }); + return convertedTags; +} + +/** + * Used in determining what Tag/Category preferences changed in order + * to only update what is necessary + * @param {TagsObject} localTags - tags from taggingContainer with values of type "number" + * @param {TagsObject} remoteTags - remote player tags with values of type "number" + * @returns array of keys of corresponding different values (finds difference) + */ +export function getObjectDifference( + localTags: TagsObjectForApi, + remoteTags: TagsObjectForApi, +): TagsObjectForApi { + const finalTags: TagsObjectForApi = {}; + + // Going off local tags since it's our categories. Trying to find only changed tags and returning those + // as a final object. + Object.keys(localTags).forEach((key) => { + // only if user's tag value did not change, skip it + if (remoteTags[key] === localTags[key]) { + return; + } + + finalTags[key] = localTags[key]; + }); + return finalTags; +} + +export function markAllTagsAsSpecified( + categoryArray: TagCategory[], + checked: boolean, +): void { + categoryArray.forEach((category) => { + category.checked = checked; + }); +} + +export function isTagObjectEmpty( + tags: TagsObjectForApi | TagsObjectWithBoolean, +): boolean { + return Object.keys(tags).length === 0; +} +/** + * Uses configured categories and remote player tags to calculate which boxes should be checked + * @param {TagCategory[]} categories + * @param {TagsObjectWithBoolean} existingPlayerTags? + */ +export function getCheckedTagCategories( + categories: TagCategory[], + existingPlayerTags?: TagsObjectWithBoolean, +): TagCategory[] { + if (!existingPlayerTags) { + return categories; + } + + const isExistingPlayerTagsEmpty = isTagObjectEmpty(existingPlayerTags); + if (isExistingPlayerTagsEmpty) { + const categoriesCopy = structuredClone(categories); + markAllTagsAsSpecified(categoriesCopy, true); + return categoriesCopy; + } + + const categoriesCopy = structuredClone(categories); + return categoriesCopy.map((category) => { + const existingTagValue: boolean = existingPlayerTags[category.tag]; + category.checked = getCheckedStatusForTagValue(existingTagValue); + return category; + }); +} + +export function getCheckedStatusForTagValue( + tagValue: boolean | undefined, +): boolean { + // If user does not have tag assigned to them, consider it selected + if (tagValue === undefined) { + return true; + } + + return tagValue; +} From cf34e45ff0e12fb1e6917f7207d27b497aca0ed4 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 14:32:48 -0700 Subject: [PATCH 4/9] more renames --- __test__/support/helpers/setup.ts | 2 +- package.json | 2 +- src/onesignal/OneSignal.ts | 2 +- src/page/bell/Bell.ts | 2 +- src/page/managers/PromptsManager.ts | 2 +- .../services/DynamicResourceLoader.test.ts | 10 ++++----- src/page/services/DynamicResourceLoader.ts | 22 +++++++++---------- src/shared/libraries/workerMessenger/page.ts | 16 +++++++------- src/shared/managers/CustomLinkManager.ts | 2 +- src/shared/managers/ServiceWorkerManager.ts | 4 ++-- .../managers/sessionManager/SessionManager.ts | 6 ++--- 11 files changed, 35 insertions(+), 35 deletions(-) diff --git a/__test__/support/helpers/setup.ts b/__test__/support/helpers/setup.ts index 818ff5c34..81f615616 100644 --- a/__test__/support/helpers/setup.ts +++ b/__test__/support/helpers/setup.ts @@ -138,7 +138,7 @@ export const setupSubscriptionModel = async ( export const setupLoadStylesheet = async () => { vi.spyOn( OneSignal._context._dynamicResourceLoader, - 'loadSdkStylesheet', + '_loadSdkStylesheet', ).mockResolvedValue(ResourceLoadState.Loaded); }; diff --git a/package.json b/package.json index 61417a486..ba25ae95f 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "47.33 kB", + "limit": "47.26 kB", "gzip": true }, { diff --git a/src/onesignal/OneSignal.ts b/src/onesignal/OneSignal.ts index 6d19650a5..20c91c419 100644 --- a/src/onesignal/OneSignal.ts +++ b/src/onesignal/OneSignal.ts @@ -168,7 +168,7 @@ export default class OneSignal { private static async _delayedInit(): Promise { OneSignal._pendingInit = false; // Ignore Promise as doesn't return until the service worker becomes active. - OneSignal._context._workerMessenger.listen(); + OneSignal._context._workerMessenger._listen(); async function __init() { if (OneSignal._initAlreadyCalled) return; diff --git a/src/page/bell/Bell.ts b/src/page/bell/Bell.ts index 80df16ebb..6985d2484 100755 --- a/src/page/bell/Bell.ts +++ b/src/page/bell/Bell.ts @@ -413,7 +413,7 @@ export default class Bell { if (!this._options.enable) return; const sdkStylesLoadResult = - await OneSignal._context._dynamicResourceLoader.loadSdkStylesheet(); + await OneSignal._context._dynamicResourceLoader._loadSdkStylesheet(); if (sdkStylesLoadResult !== ResourceLoadState.Loaded) { Log._debug('Not showing notify button because styles failed to load.'); return; diff --git a/src/page/managers/PromptsManager.ts b/src/page/managers/PromptsManager.ts index 19928c383..d1110c25a 100644 --- a/src/page/managers/PromptsManager.ts +++ b/src/page/managers/PromptsManager.ts @@ -202,7 +202,7 @@ export class PromptsManager { } const sdkStylesLoadResult = - await this.context._dynamicResourceLoader.loadSdkStylesheet(); + await this.context._dynamicResourceLoader._loadSdkStylesheet(); if (sdkStylesLoadResult !== ResourceLoadState.Loaded) { Log._debug( 'Not showing slidedown permission message because styles failed to load.', diff --git a/src/page/services/DynamicResourceLoader.test.ts b/src/page/services/DynamicResourceLoader.test.ts index 698a7b966..d0e39a337 100644 --- a/src/page/services/DynamicResourceLoader.test.ts +++ b/src/page/services/DynamicResourceLoader.test.ts @@ -129,7 +129,7 @@ describe('DynamicResourceLoader', () => { ); const loader = new DynamicResourceLoader(); - const state = await loader.loadSdkStylesheet(); + const state = await loader._loadSdkStylesheet(); expect(state).toBe(ResourceLoadState.Loaded); const stylesheets = document.head.querySelectorAll( @@ -137,7 +137,7 @@ describe('DynamicResourceLoader', () => { ); const url = `${cssURL}?v=${__VERSION__}`; expect(stylesheets[0].getAttribute('href')).toBe(url); - expect(loader.getCache()).toEqual({ + expect(loader._getCache()).toEqual({ [url]: expect.any(Promise), }); }); @@ -153,14 +153,14 @@ describe('DynamicResourceLoader', () => { ); const loader = new DynamicResourceLoader(); - const state = await loader.loadIfNew( + const state = await loader._loadIfNew( ResourceType.Script, new URL(scriptURL), ); expect(state).toBe(ResourceLoadState.Loaded); // should not load the same script again - const state2 = await loader.loadIfNew( + const state2 = await loader._loadIfNew( ResourceType.Script, new URL(scriptURL), ); @@ -179,7 +179,7 @@ describe('DynamicResourceLoader', () => { ); const loader = new DynamicResourceLoader(); - const state = await loader.loadIfNew( + const state = await loader._loadIfNew( ResourceType.Script, new URL(scriptURL), ); diff --git a/src/page/services/DynamicResourceLoader.ts b/src/page/services/DynamicResourceLoader.ts index e214285ff..c1eaaad09 100644 --- a/src/page/services/DynamicResourceLoader.ts +++ b/src/page/services/DynamicResourceLoader.ts @@ -68,21 +68,21 @@ const getOneSignalResourceUrlPath = () => { }; export class DynamicResourceLoader { - private cache: DynamicResourceLoaderCache; + private _cache: DynamicResourceLoaderCache; constructor() { - this.cache = {}; + this._cache = {}; } - getCache(): DynamicResourceLoaderCache { + _getCache(): DynamicResourceLoaderCache { // Cache is private; return a cloned copy just for testing - return { ...this.cache }; + return { ...this._cache }; } - async loadSdkStylesheet(): Promise { + async _loadSdkStylesheet(): Promise { const pathForEnv = getOneSignalResourceUrlPath(); const cssFileForEnv = getOneSignalCssFileName(); - return this.loadIfNew( + return this._loadIfNew( ResourceType.Stylesheet, new URL(`${pathForEnv}/${cssFileForEnv}?v=${VERSION}`), ); @@ -92,24 +92,24 @@ export class DynamicResourceLoader { * Attempts to load a resource by adding it to the document's . * Caches any previous load attempt's result and does not retry loading a previous resource. */ - async loadIfNew( + async _loadIfNew( type: ResourceTypeValue, url: URL, ): Promise { // Load for first time - if (!this.cache[url.toString()]) { - this.cache[url.toString()] = DynamicResourceLoader.load(type, url); + if (!this._cache[url.toString()]) { + this._cache[url.toString()] = DynamicResourceLoader._load(type, url); } // Resource is loading; multiple calls can be made to this while the same resource is loading // Waiting on the Promise is what we want here - return this.cache[url.toString()]; + return this._cache[url.toString()]; } /** * Attempts to load a resource by adding it to the document's . * Each call creates a new DOM element and fetch attempt. */ - static async load( + static async _load( type: ResourceTypeValue, url: URL, ): Promise { diff --git a/src/shared/libraries/workerMessenger/page.ts b/src/shared/libraries/workerMessenger/page.ts index 7dac8bb0c..fb646c96a 100644 --- a/src/shared/libraries/workerMessenger/page.ts +++ b/src/shared/libraries/workerMessenger/page.ts @@ -15,18 +15,18 @@ export class WorkerMessengerPage extends WorkerMessengerBase { * synchronously add self.addEventListener('message') if we are running in the * service worker. */ - public async listen() { + public async _listen() { if (!supportsServiceWorkers()) return; - await this.listenForPage(); + await this._listenForPage(); } /** * Listens for messages for the service worker. */ - private async listenForPage() { + private async _listenForPage() { navigator.serviceWorker.addEventListener( 'message', - this.onPageMessageReceivedFromServiceWorker.bind(this), + this._onPageMessageReceivedFromServiceWorker.bind(this), ); Log._debug( `(${location.origin}) [Worker Messenger] Page is now listening for messages.`, @@ -40,7 +40,7 @@ export class WorkerMessengerPage extends WorkerMessengerBase { message topic. If no one is listening to the message, it is discarded; otherwise, the listener callback is executed. */ - onPageMessageReceivedFromServiceWorker(event: MessageEvent) { + _onPageMessageReceivedFromServiceWorker(event: MessageEvent) { const data: WorkerMessengerMessage = event.data; /* If this message doesn't contain our expected fields, discard the message */ @@ -74,17 +74,17 @@ export class WorkerMessengerPage extends WorkerMessengerBase { /** * Sends a postMessage() to OneSignal's Serviceworker */ - async unicast( + async _unicast( command: WorkerMessengerCommandValue, payload?: WorkerMessengerPayload, ) { Log._debug( `[Worker Messenger] [Page -> SW] Unicasting '${command.toString()}' to service worker.`, ); - this.directPostMessageToSW(command, payload); + this._directPostMessageToSW(command, payload); } - public async directPostMessageToSW( + public async _directPostMessageToSW( command: WorkerMessengerCommandValue, payload?: WorkerMessengerPayload, ): Promise { diff --git a/src/shared/managers/CustomLinkManager.ts b/src/shared/managers/CustomLinkManager.ts index 49d870ffb..7e08b449e 100644 --- a/src/shared/managers/CustomLinkManager.ts +++ b/src/shared/managers/CustomLinkManager.ts @@ -124,7 +124,7 @@ export class CustomLinkManager { private async _loadSdkStyles(): Promise { const sdkStylesLoadResult = - await OneSignal._context._dynamicResourceLoader.loadSdkStylesheet(); + await OneSignal._context._dynamicResourceLoader._loadSdkStylesheet(); if (sdkStylesLoadResult !== ResourceLoadState.Loaded) { Log._debug( 'Not initializing custom link button because styles failed to load.', diff --git a/src/shared/managers/ServiceWorkerManager.ts b/src/shared/managers/ServiceWorkerManager.ts index 25d84b364..dcb2b1a99 100644 --- a/src/shared/managers/ServiceWorkerManager.ts +++ b/src/shared/managers/ServiceWorkerManager.ts @@ -133,7 +133,7 @@ export class ServiceWorkerManager { resolve(workerVersion); }, ); - await this._context._workerMessenger.unicast( + await this._context._workerMessenger._unicast( WorkerMessengerCommand.WorkerVersion, ); }); @@ -336,7 +336,7 @@ export class ServiceWorkerManager { timestamp: incomingPayload.timestamp, focused: document.hasFocus(), }; - await workerMessenger.directPostMessageToSW( + await workerMessenger._directPostMessageToSW( WorkerMessengerCommand.AreYouVisibleResponse, payload, ); diff --git a/src/shared/managers/sessionManager/SessionManager.ts b/src/shared/managers/sessionManager/SessionManager.ts index 0b67db6a2..a7bb27a10 100644 --- a/src/shared/managers/sessionManager/SessionManager.ts +++ b/src/shared/managers/sessionManager/SessionManager.ts @@ -47,7 +47,7 @@ export class SessionManager implements ISessionManager { }; if (supportsServiceWorkers()) { Log._debug('Notify SW to upsert session'); - await this._context._workerMessenger.unicast( + await this._context._workerMessenger._unicast( WorkerMessengerCommand.SessionUpsert, payload, ); @@ -75,7 +75,7 @@ export class SessionManager implements ISessionManager { }; if (supportsServiceWorkers()) { Log._debug('Notify SW to deactivate session'); - await this._context._workerMessenger.unicast( + await this._context._workerMessenger._unicast( WorkerMessengerCommand.SessionDeactivate, payload, ); @@ -203,7 +203,7 @@ export class SessionManager implements ISessionManager { }; Log._debug('Notify SW to deactivate session (beforeunload)'); - this._context._workerMessenger.directPostMessageToSW( + this._context._workerMessenger._directPostMessageToSW( WorkerMessengerCommand.SessionDeactivate, payload, ); From 71785c1616f2bcf0ec052a2870c8d257f739ac04 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 14:42:17 -0700 Subject: [PATCH 5/9] breakup limit store --- package.json | 2 +- src/page/bell/Button.ts | 16 ++++++---- src/shared/helpers/init.ts | 4 +-- src/shared/listeners.ts | 4 +-- src/shared/services/LimitStore.ts | 49 ------------------------------ src/shared/services/limitStore2.ts | 47 ++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 60 deletions(-) delete mode 100755 src/shared/services/LimitStore.ts create mode 100755 src/shared/services/limitStore2.ts diff --git a/package.json b/package.json index ba25ae95f..743ddfa58 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "47.26 kB", + "limit": "47.2 kB", "gzip": true }, { diff --git a/src/page/bell/Button.ts b/src/page/bell/Button.ts index 975bfce2c..830ee3f88 100755 --- a/src/page/bell/Button.ts +++ b/src/page/bell/Button.ts @@ -1,6 +1,10 @@ import { addDomElement, removeDomElement } from 'src/shared/helpers/dom'; import { registerForPushNotifications } from 'src/shared/helpers/init'; -import LimitStore from 'src/shared/services/LimitStore'; +import { + limitGetLast, + limitIsEmpty, + limitStorePut, +} from 'src/shared/services/limitStore2'; import OneSignalEvent from 'src/shared/services/OneSignalEvent'; import AnimatedElement from './AnimatedElement'; import type Bell from './Bell'; @@ -66,16 +70,16 @@ export default class Button extends AnimatedElement { _onHovering() { if ( - LimitStore.isEmpty(this._events.mouse) || - LimitStore.getLast(this._events.mouse) === 'out' + limitIsEmpty(this._events.mouse) || + limitGetLast(this._events.mouse) === 'out' ) { OneSignalEvent._trigger(BellEvent._Hovering); } - LimitStore.put(this._events.mouse, 'over'); + limitStorePut(this._events.mouse, 'over'); } _onHovered() { - LimitStore.put(this._events.mouse, 'out'); + limitStorePut(this._events.mouse, 'out'); OneSignalEvent._trigger(BellEvent._Hovered); } @@ -107,7 +111,7 @@ export default class Button extends AnimatedElement { return; } - const optedOut = LimitStore.getLast('subscription.optedOut'); + const optedOut = limitGetLast('subscription.optedOut'); if (this._bell._unsubscribed && !optedOut) { // The user is actually subscribed, register him for notifications registerForPushNotifications(); diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index ab8903eaa..45bee05d9 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -7,7 +7,7 @@ import type { OptionKey } from '../database/types'; import Log from '../libraries/Log'; import { CustomLinkManager } from '../managers/CustomLinkManager'; import { SubscriptionStrategyKind } from '../models/SubscriptionStrategyKind'; -import LimitStore from '../services/LimitStore'; +import { limitStorePut } from '../services/limitStore2'; import OneSignalEvent from '../services/OneSignalEvent'; import { IS_SERVICE_WORKER } from '../utils/EnvVariables'; import { once } from '../utils/utils'; @@ -138,7 +138,7 @@ async function storeInitialValues() { await OneSignal._context._permissionManager._getPermissionStatus(); const isOptedOut = await OneSignal._context._subscriptionManager._isOptedOut(); - LimitStore.put('subscription.optedOut', isOptedOut); + limitStorePut('subscription.optedOut', isOptedOut); await db.put('Options', { key: 'isPushEnabled', value: isPushEnabled, diff --git a/src/shared/listeners.ts b/src/shared/listeners.ts index d5c0973b7..f2cc68d86 100644 --- a/src/shared/listeners.ts +++ b/src/shared/listeners.ts @@ -13,7 +13,7 @@ import type { NotificationClickEventInternal, } from './notifications/types'; import { isCategorySlidedownConfigured } from './prompts/helpers'; -import LimitStore from './services/LimitStore'; +import { limitStorePut } from './services/limitStore2'; import OneSignalEvent from './services/OneSignalEvent'; import { logMethodCall } from './utils/utils'; @@ -189,7 +189,7 @@ function onSubscriptionChanged_updateCustomLink() { } export async function onInternalSubscriptionSet(optedOut: boolean) { - LimitStore.put('subscription.optedOut', optedOut); + limitStorePut('subscription.optedOut', optedOut); } async function onSubscriptionChanged_showWelcomeNotification( diff --git a/src/shared/services/LimitStore.ts b/src/shared/services/LimitStore.ts deleted file mode 100755 index 8544476e0..000000000 --- a/src/shared/services/LimitStore.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - LimitStore.put('colorado', 'rocky'); - ["rocky"] - LimitStore.put('colorado', 'mountain'); - ["rocky", "mountain"] - LimitStore.put('colorado', 'national'); - ["mountain", "national"] - LimitStore.put('colorado', 'park'); - ["national", "park"] - */ -export default class LimitStore { - static store: Record> = {}; - static LIMIT = 2; - - static put(key: string, value: T) { - if (LimitStore.store[key] === undefined) { - LimitStore.store[key] = [null, null]; - } - LimitStore.store[key].push(value); - if (LimitStore.store[key].length == LimitStore.LIMIT + 1) { - LimitStore.store[key].shift(); - } - return LimitStore.store[key]; - } - - static get(key: string): T[] { - if (LimitStore.store[key] === undefined) { - LimitStore.store[key] = [null, null]; - } - return LimitStore.store[key] as T[]; - } - - static getFirst(key: string) { - return LimitStore.get(key)[0]; - } - - static getLast(key: string) { - return LimitStore.get(key)[1]; - } - - static remove(key: string) { - delete LimitStore.store[key]; - } - - static isEmpty(key: string) { - const values = LimitStore.get(key); - return values[0] === null && values[1] === null; - } -} diff --git a/src/shared/services/limitStore2.ts b/src/shared/services/limitStore2.ts new file mode 100755 index 000000000..9a036b770 --- /dev/null +++ b/src/shared/services/limitStore2.ts @@ -0,0 +1,47 @@ +/* + LimitStore.put('colorado', 'rocky'); + ["rocky"] + LimitStore.put('colorado', 'mountain'); + ["rocky", "mountain"] + LimitStore.put('colorado', 'national'); + ["mountain", "national"] + LimitStore.put('colorado', 'park'); + ["national", "park"] + */ +const LIMIT = 2; +const store: Record> = {}; + +export function limitStorePut(key: string, value: T) { + if (store[key] === undefined) { + store[key] = [null, null]; + } + store[key].push(value); + if (store[key].length == LIMIT + 1) { + store[key].shift(); + } + return store[key]; +} + +export function limitStoreGet(key: string): T[] { + if (store[key] === undefined) { + store[key] = [null, null]; + } + return store[key] as T[]; +} + +export function limitGetFirst(key: string) { + return limitStoreGet(key)[0]; +} + +export function limitGetLast(key: string) { + return limitStoreGet(key)[1]; +} + +export function limitRemove(key: string) { + delete store[key]; +} + +export function limitIsEmpty(key: string) { + const values = limitStoreGet(key); + return values[0] === null && values[1] === null; +} From 8620ceef8ba5432231c1f3f9731d60a0b3790f78 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 14:42:32 -0700 Subject: [PATCH 6/9] rename limit store --- src/shared/helpers/init.ts | 2 +- src/shared/listeners.ts | 2 +- src/shared/services/{limitStore2.ts => limitStore.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/shared/services/{limitStore2.ts => limitStore.ts} (100%) diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index 45bee05d9..bfcbed329 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -7,7 +7,7 @@ import type { OptionKey } from '../database/types'; import Log from '../libraries/Log'; import { CustomLinkManager } from '../managers/CustomLinkManager'; import { SubscriptionStrategyKind } from '../models/SubscriptionStrategyKind'; -import { limitStorePut } from '../services/limitStore2'; +import { limitStorePut } from '../services/limitStore'; import OneSignalEvent from '../services/OneSignalEvent'; import { IS_SERVICE_WORKER } from '../utils/EnvVariables'; import { once } from '../utils/utils'; diff --git a/src/shared/listeners.ts b/src/shared/listeners.ts index f2cc68d86..2a838711a 100644 --- a/src/shared/listeners.ts +++ b/src/shared/listeners.ts @@ -13,7 +13,7 @@ import type { NotificationClickEventInternal, } from './notifications/types'; import { isCategorySlidedownConfigured } from './prompts/helpers'; -import { limitStorePut } from './services/limitStore2'; +import { limitStorePut } from './services/limitStore'; import OneSignalEvent from './services/OneSignalEvent'; import { logMethodCall } from './utils/utils'; diff --git a/src/shared/services/limitStore2.ts b/src/shared/services/limitStore.ts similarity index 100% rename from src/shared/services/limitStore2.ts rename to src/shared/services/limitStore.ts From e9bdc4bf8fa88aea47990aa4d19d653a91921481 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 14:44:10 -0700 Subject: [PATCH 7/9] more method renames --- __test__/unit/models/path.test.ts | 36 ++++++++--------- package.json | 2 +- src/page/bell/Button.ts | 2 +- src/page/managers/LoginManager.ts | 9 +++-- src/shared/helpers/service-worker.ts | 2 +- src/shared/managers/ServiceWorkerManager.ts | 6 +-- .../managers/sessionManager/SessionManager.ts | 10 ++--- src/shared/managers/subscription/page.ts | 2 +- src/shared/models/Path.ts | 40 +++++-------------- 9 files changed, 44 insertions(+), 65 deletions(-) diff --git a/__test__/unit/models/path.test.ts b/__test__/unit/models/path.test.ts index adce95c20..b84c0e36b 100644 --- a/__test__/unit/models/path.test.ts +++ b/__test__/unit/models/path.test.ts @@ -3,40 +3,42 @@ import Path from '../../../src/shared/models/Path'; describe('Path tests', () => { test(`should return correct components for a simple web path`, () => { const path = new Path('/web-folder/assets/service-worker.js'); - expect(path.getFileName()).toBe('service-worker.js'); - expect(path.getFullPath()).toBe('/web-folder/assets/service-worker.js'); + expect(path._getFileName()).toBe('service-worker.js'); + expect(path._getFullPath()).toBe('/web-folder/assets/service-worker.js'); }); test(`should return correct components for a file-based path`, () => { const path = new Path('file:///c:/web-folder/assets/service-worker.js'); - expect(path.getFileName()).toBe('service-worker.js'); - expect(path.getFullPath()).toBe( + expect(path._getFileName()).toBe('service-worker.js'); + expect(path._getFullPath()).toBe( 'file:///c:/web-folder/assets/service-worker.js', ); }); test(`should return case-sensitive correct components for a file-based path`, () => { const path = new Path('/WeB-FoLdEr/AsSeTs/SeRvIcE-WoRkEr.js'); - expect(path.getFileName()).toBe('SeRvIcE-WoRkEr.js'); - expect(path.getFullPath()).toBe('/WeB-FoLdEr/AsSeTs/SeRvIcE-WoRkEr.js'); + expect(path._getFileName()).toBe('SeRvIcE-WoRkEr.js'); + expect(path._getFullPath()).toBe('/WeB-FoLdEr/AsSeTs/SeRvIcE-WoRkEr.js'); }); test(`should return correct components for a double-extension path`, () => { const path = new Path('/web-folder/assets/service-worker.js.php'); - expect(path.getFileName()).toBe('service-worker.js.php'); - expect(path.getFullPath()).toBe('/web-folder/assets/service-worker.js.php'); + expect(path._getFileName()).toBe('service-worker.js.php'); + expect(path._getFullPath()).toBe( + '/web-folder/assets/service-worker.js.php', + ); }); test(`should return correct components for a root-relative path`, () => { const path = new Path('/service-worker.js'); - expect(path.getFileName()).toBe('service-worker.js'); - expect(path.getFullPath()).toBe('/service-worker.js'); + expect(path._getFileName()).toBe('service-worker.js'); + expect(path._getFullPath()).toBe('/service-worker.js'); }); test(`should return correct components for an absolute web path`, () => { const path = new Path('https://site.com/web-folder/service-worker.js'); - expect(path.getFileName()).toBe('service-worker.js'); - expect(path.getFullPath()).toBe( + expect(path._getFileName()).toBe('service-worker.js'); + expect(path._getFullPath()).toBe( 'https://site.com/web-folder/service-worker.js', ); }); @@ -45,21 +47,15 @@ describe('Path tests', () => { const path = new Path( 'https://site.com/web-folder/service-worker.js?appId=12345', ); - expect(path.getFullPath()).toBe( - 'https://site.com/web-folder/service-worker.js?appId=12345', - ); - }); - test(`should include query string in path with query`, () => { - const path = new Path( + expect(path._getFullPath()).toBe( 'https://site.com/web-folder/service-worker.js?appId=12345', ); - expect(path.getFileNameWithQuery()).toBe('service-worker.js?appId=12345'); }); test(`should not include query string in path filename`, () => { const path = new Path( 'https://site.com/web-folder/service-worker.js?appId=12345', ); - expect(path.getFileName()).toBe('service-worker.js'); + expect(path._getFileName()).toBe('service-worker.js'); }); }); diff --git a/package.json b/package.json index 743ddfa58..f124f1299 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "47.2 kB", + "limit": "47.08 kB", "gzip": true }, { diff --git a/src/page/bell/Button.ts b/src/page/bell/Button.ts index 830ee3f88..a9bfeedde 100755 --- a/src/page/bell/Button.ts +++ b/src/page/bell/Button.ts @@ -4,7 +4,7 @@ import { limitGetLast, limitIsEmpty, limitStorePut, -} from 'src/shared/services/limitStore2'; +} from 'src/shared/services/limitStore'; import OneSignalEvent from 'src/shared/services/OneSignalEvent'; import AnimatedElement from './AnimatedElement'; import type Bell from './Bell'; diff --git a/src/page/managers/LoginManager.ts b/src/page/managers/LoginManager.ts index f0a385d5d..3fb62e912 100644 --- a/src/page/managers/LoginManager.ts +++ b/src/page/managers/LoginManager.ts @@ -10,11 +10,14 @@ import Log from '../../shared/libraries/Log'; export default class LoginManager { // Other internal classes should await on this if they access users - static switchingUsersPromise: Promise = Promise.resolve(); + static _switchingUsersPromise: Promise = Promise.resolve(); // public api static async login(externalId: string, token?: string): Promise { - await (this.switchingUsersPromise = LoginManager._login(externalId, token)); + await (this._switchingUsersPromise = LoginManager._login( + externalId, + token, + )); } private static async _login( @@ -77,7 +80,7 @@ export default class LoginManager { // public api static async logout(): Promise { - await (this.switchingUsersPromise = LoginManager._logout()); + await (this._switchingUsersPromise = LoginManager._logout()); } private static async _logout(): Promise { diff --git a/src/shared/helpers/service-worker.ts b/src/shared/helpers/service-worker.ts index 7009d06ff..5fae4e8bc 100755 --- a/src/shared/helpers/service-worker.ts +++ b/src/shared/helpers/service-worker.ts @@ -27,7 +27,7 @@ export function getServiceWorkerHref( sdkVersion: string, ): string { return appendServiceWorkerParams( - config.workerPath.getFullPath(), + config.workerPath._getFullPath(), appId, sdkVersion, ); diff --git a/src/shared/managers/ServiceWorkerManager.ts b/src/shared/managers/ServiceWorkerManager.ts index dcb2b1a99..2fe5fc83d 100644 --- a/src/shared/managers/ServiceWorkerManager.ts +++ b/src/shared/managers/ServiceWorkerManager.ts @@ -87,7 +87,7 @@ export class ServiceWorkerManager { } const workerScriptPath = new URL(serviceWorker.scriptURL).pathname; - const swFileName = new Path(workerScriptPath).getFileName(); + const swFileName = new Path(workerScriptPath)._getFileName(); // If the current service worker is Akamai's if (swFileName == 'akam-sw.js') { @@ -101,7 +101,7 @@ export class ServiceWorkerManager { "Found a ServiceWorker under Akamai's akam-sw.js?othersw=", importedSw, ); - return new Path(new URL(importedSw).pathname).getFileName(); + return new Path(new URL(importedSw).pathname)._getFileName(); } } return swFileName; @@ -115,7 +115,7 @@ export class ServiceWorkerManager { return ServiceWorkerActiveState.None; } const isValidOSWorker = - fileName == this._config.workerPath.getFileName() || + fileName == this._config.workerPath._getFileName() || fileName == 'OneSignalSDK.sw.js'; // For backwards compatibility with temporary v16 user model beta filename (remove after 5/5/24 deprecation) if (isValidOSWorker) { diff --git a/src/shared/managers/sessionManager/SessionManager.ts b/src/shared/managers/sessionManager/SessionManager.ts index a7bb27a10..154c7e589 100644 --- a/src/shared/managers/sessionManager/SessionManager.ts +++ b/src/shared/managers/sessionManager/SessionManager.ts @@ -112,7 +112,7 @@ export class SessionManager implements ISessionManager { } async _handleVisibilityChange(): Promise { - await LoginManager.switchingUsersPromise; + await LoginManager._switchingUsersPromise; if (!User._singletonInstance?.onesignalId) { return; @@ -180,7 +180,7 @@ export class SessionManager implements ISessionManager { } async _handleOnBeforeUnload(): Promise { - await LoginManager.switchingUsersPromise; + await LoginManager._switchingUsersPromise; if (!User._singletonInstance?.onesignalId) { return; @@ -213,7 +213,7 @@ export class SessionManager implements ISessionManager { } async _handleOnFocus(e: Event): Promise { - await LoginManager.switchingUsersPromise; + await LoginManager._switchingUsersPromise; Log._debug('handleOnFocus', e); if (!User._singletonInstance?.onesignalId) { @@ -243,7 +243,7 @@ export class SessionManager implements ISessionManager { } async _handleOnBlur(e: Event): Promise { - await LoginManager.switchingUsersPromise; + await LoginManager._switchingUsersPromise; Log._debug('handleOnBlur', e); if (!User._singletonInstance?.onesignalId) { @@ -273,7 +273,7 @@ export class SessionManager implements ISessionManager { } async _upsertSession(sessionOrigin: SessionOriginValue): Promise { - await LoginManager.switchingUsersPromise; + await LoginManager._switchingUsersPromise; if (User._singletonInstance?.onesignalId) { const { onesignalId, subscriptionId } = diff --git a/src/shared/managers/subscription/page.ts b/src/shared/managers/subscription/page.ts index 918e8e9a0..3124fdd3d 100644 --- a/src/shared/managers/subscription/page.ts +++ b/src/shared/managers/subscription/page.ts @@ -53,7 +53,7 @@ export const updatePushSubscriptionModelWithRawSubscription = async ( ) => { // incase a login op was called before user accepts the notifcations permissions, we need to wait for it to finish // otherwise there would be two login ops in the same bucket for LoginOperationExecutor which would error - await LoginManager.switchingUsersPromise; + await LoginManager._switchingUsersPromise; let pushModel = await OneSignal._coreDirector._getPushSubscriptionModel(); // for new users, we need to create a new push subscription model and also save its push id to IndexedDB diff --git a/src/shared/models/Path.ts b/src/shared/models/Path.ts index 0bbc29770..4c922cbaa 100644 --- a/src/shared/models/Path.ts +++ b/src/shared/models/Path.ts @@ -1,5 +1,7 @@ import { EmptyArgumentError } from '../errors/common'; +const QUERY_STRING = '?'; + /** * Represents a normalized path. * @@ -7,44 +9,22 @@ import { EmptyArgumentError } from '../errors/common'; * Paths without file names will never contain trailing slashes, except for empty paths. */ export default class Path { - private static QUERY_STRING = '?'; - - private readonly path: string; + private readonly _path: string; constructor(path: string) { if (!path) throw EmptyArgumentError('path'); - this.path = path.trim(); - } - - getQueryString(): string | null { - // If there are no ? characters, return null - // If there are multiple ?, return the substring starting after the first ? all the way to the end - const indexOfDelimiter = this.path.indexOf('?'); - if (indexOfDelimiter === -1) { - return null; - } - if (this.path.length > indexOfDelimiter) { - // Return the substring *after the first ? to the end - return this.path.substring(indexOfDelimiter + 1); - } else { - // The last character is ? - return null; - } - } - - getWithoutQueryString(): string { - return this.path.split(Path.QUERY_STRING)[0]; + this._path = path.trim(); } - getFileName(): string | undefined { - return this.getWithoutQueryString().split('\\').pop()?.split('/').pop(); + _getWithoutQueryString(): string { + return this._path.split(QUERY_STRING)[0]; } - getFileNameWithQuery(): string | undefined { - return this.path.split('\\').pop()?.split('/').pop(); + _getFileName(): string | undefined { + return this._getWithoutQueryString().split('\\').pop()?.split('/').pop(); } - getFullPath() { - return this.path; + _getFullPath() { + return this._path; } } From 8f8cfbd2c6f9f0bdcc76f5982625486add82238c Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 15 Oct 2025 15:03:01 -0700 Subject: [PATCH 8/9] breakup main helpers and remove unused functions --- .../nativePermissionChange.test.ts | 6 +- package.json | 2 +- src/core/CoreModuleDirector.ts | 4 +- src/core/controllers/CustomEventController.ts | 4 +- .../listeners/IdentityModelStoreListener.ts | 4 +- .../listeners/PropertiesModelStoreListener.ts | 4 +- .../SubscriptionModelStoreListener.ts | 8 +- src/onesignal/OneSignal.test.ts | 4 +- src/onesignal/OneSignal.ts | 4 +- src/onesignal/User.ts | 4 +- src/onesignal/UserDirector.ts | 4 +- src/page/bell/Bell.ts | 6 +- src/page/managers/LoginManager.ts | 4 +- src/page/slidedown/Slidedown.ts | 4 +- src/shared/helpers/MainHelper.ts | 242 ------------------ src/shared/helpers/init.ts | 4 +- src/shared/helpers/main.ts | 137 ++++++++++ src/shared/listeners.ts | 6 +- .../managers/sessionManager/SessionManager.ts | 4 +- 19 files changed, 175 insertions(+), 280 deletions(-) delete mode 100755 src/shared/helpers/MainHelper.ts create mode 100755 src/shared/helpers/main.ts diff --git a/__test__/unit/pushSubscription/nativePermissionChange.test.ts b/__test__/unit/pushSubscription/nativePermissionChange.test.ts index ec09c3220..3d6c5be90 100644 --- a/__test__/unit/pushSubscription/nativePermissionChange.test.ts +++ b/__test__/unit/pushSubscription/nativePermissionChange.test.ts @@ -11,10 +11,10 @@ import { MockServiceWorker } from '__test__/support/mocks/MockServiceWorker'; import { clearStore, db, getOptionsValue } from 'src/shared/database/client'; import { setAppState as setDBAppState } from 'src/shared/database/config'; import type { AppState } from 'src/shared/database/types'; +import { checkAndTriggerNotificationPermissionChanged } from 'src/shared/helpers/main'; import * as PermissionUtils from 'src/shared/helpers/permissions'; import Emitter from 'src/shared/libraries/Emitter'; import { checkAndTriggerSubscriptionChanged } from 'src/shared/listeners'; -import MainHelper from '../../../src/shared/helpers/MainHelper'; vi.mock('src/shared/libraries/Log'); const triggerNotificationSpy = vi.spyOn( @@ -50,7 +50,7 @@ describe('Notification Types are set correctly on subscription change', () => { }); await setDbPermission('granted'); - await MainHelper._checkAndTriggerNotificationPermissionChanged(); + await checkAndTriggerNotificationPermissionChanged(); expect(triggerNotificationSpy).not.toHaveBeenCalled(); }); @@ -74,7 +74,7 @@ describe('Notification Types are set correctly on subscription change', () => { permChangeStringListener, ); - await MainHelper._checkAndTriggerNotificationPermissionChanged(); + await checkAndTriggerNotificationPermissionChanged(); // should update the db const dbPermission = await getOptionsValue( diff --git a/package.json b/package.json index f124f1299..3a84ff757 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "47.08 kB", + "limit": "46.601 kB", "gzip": true }, { diff --git a/src/core/CoreModuleDirector.ts b/src/core/CoreModuleDirector.ts index d325fbbfe..fe2114005 100644 --- a/src/core/CoreModuleDirector.ts +++ b/src/core/CoreModuleDirector.ts @@ -8,7 +8,7 @@ import { } from 'src/shared/subscriptions/constants'; import type { SubscriptionChannelValue } from 'src/shared/subscriptions/types'; import { logMethodCall } from 'src/shared/utils/utils'; -import MainHelper from '../shared/helpers/MainHelper'; +import { getCurrentPushToken } from '../shared/helpers/main'; import { RawPushSubscription } from '../shared/models/RawPushSubscription'; import CoreModule from './CoreModule'; import { IdentityModel } from './models/IdentityModel'; @@ -115,7 +115,7 @@ export class CoreModuleDirector { SubscriptionModel | undefined > { logMethodCall('CoreModuleDirector.getPushSubscriptionModelByCurrentToken'); - const pushToken = await MainHelper._getCurrentPushToken(); + const pushToken = await getCurrentPushToken(); if (pushToken) { return this._getSubscriptionOfTypeWithToken( SubscriptionChannel.Push, diff --git a/src/core/controllers/CustomEventController.ts b/src/core/controllers/CustomEventController.ts index 5186c3c51..87ca92ed0 100644 --- a/src/core/controllers/CustomEventController.ts +++ b/src/core/controllers/CustomEventController.ts @@ -1,4 +1,4 @@ -import MainHelper from '../../shared/helpers/MainHelper'; +import { getAppId } from '../../shared/helpers/main'; import { IdentityModelStore } from '../modelStores/IdentityModelStore'; import { TrackCustomEventOperation } from '../operations/TrackCustomEventOperation'; import type { @@ -22,7 +22,7 @@ export class CustomEventController implements ICustomEventController { } _sendCustomEvent(event: ICustomEvent): void { - const appId = MainHelper._getAppId(); + const appId = getAppId(); const identityModel = this._identityModelStore.model; const op = new TrackCustomEventOperation({ diff --git a/src/core/listeners/IdentityModelStoreListener.ts b/src/core/listeners/IdentityModelStoreListener.ts index ad8c11101..db15aac66 100644 --- a/src/core/listeners/IdentityModelStoreListener.ts +++ b/src/core/listeners/IdentityModelStoreListener.ts @@ -1,4 +1,4 @@ -import MainHelper from 'src/shared/helpers/MainHelper'; +import { getAppId } from 'src/shared/helpers/main'; import { type IdentityModel } from '../models/IdentityModel'; import { type IdentityModelStore } from '../modelStores/IdentityModelStore'; import { DeleteAliasOperation } from '../operations/DeleteAliasOperation'; @@ -26,7 +26,7 @@ export class IdentityModelStoreListener extends SingletonModelStoreListener {}); diff --git a/src/onesignal/OneSignal.ts b/src/onesignal/OneSignal.ts index 20c91c419..57a5b0ff6 100644 --- a/src/onesignal/OneSignal.ts +++ b/src/onesignal/OneSignal.ts @@ -25,6 +25,7 @@ import { removeLegacySubscriptionOptions, setConsentRequired as setStorageConsentRequired, } from 'src/shared/helpers/localStorage'; +import { checkAndTriggerNotificationPermissionChanged } from 'src/shared/helpers/main'; import { _onSubscriptionChanged, checkAndTriggerSubscriptionChanged, @@ -38,7 +39,6 @@ import { CoreModuleDirector } from '../core/CoreModuleDirector'; import LoginManager from '../page/managers/LoginManager'; import Context from '../page/models/Context'; import type { OneSignalDeferredLoadedCallback } from '../page/models/OneSignalDeferredLoadedCallback'; -import MainHelper from '../shared/helpers/MainHelper'; import Emitter from '../shared/libraries/Emitter'; import Log from '../shared/libraries/Log'; import DebugNamespace from './DebugNamesapce'; @@ -188,7 +188,7 @@ export default class OneSignal { window.addEventListener('focus', () => { // Checks if permission changed every time a user focuses on the page, // since a user has to click out of and back on the page to check permissions - MainHelper._checkAndTriggerNotificationPermissionChanged(); + checkAndTriggerNotificationPermissionChanged(); }); await initSaveState(); diff --git a/src/onesignal/User.ts b/src/onesignal/User.ts index 1139614ff..516801fe0 100644 --- a/src/onesignal/User.ts +++ b/src/onesignal/User.ts @@ -9,7 +9,7 @@ import { ReservedArgumentError, WrongTypeArgumentError, } from 'src/shared/errors/common'; -import MainHelper from 'src/shared/helpers/MainHelper'; +import { getAppId } from 'src/shared/helpers/main'; import { isObject, isValidEmail } from 'src/shared/helpers/validators'; import Log from 'src/shared/libraries/Log'; import { IDManager } from 'src/shared/managers/IDManager'; @@ -283,7 +283,7 @@ function addSubscriptionToModels({ // Check if we need to enqueue a login operation for local IDs if (IDManager._isLocalId(onesignalId)) { - const appId = MainHelper._getAppId(); + const appId = getAppId(); if (!hasLoginOp(onesignalId)) { OneSignal._coreDirector._operationRepo._enqueue( diff --git a/src/onesignal/UserDirector.ts b/src/onesignal/UserDirector.ts index a44cc1548..8d0f98666 100644 --- a/src/onesignal/UserDirector.ts +++ b/src/onesignal/UserDirector.ts @@ -4,12 +4,12 @@ import { CreateSubscriptionOperation } from 'src/core/operations/CreateSubscript import { LoginUserOperation } from 'src/core/operations/LoginUserOperation'; import Log from 'src/shared/libraries/Log'; import { IDManager } from 'src/shared/managers/IDManager'; -import MainHelper from '../shared/helpers/MainHelper'; +import { getAppId } from '../shared/helpers/main'; export default class UserDirector { static async _createUserOnServer(): Promise { const identityModel = OneSignal._coreDirector._getIdentityModel(); - const appId = MainHelper._getAppId(); + const appId = getAppId(); const hasAnySubscription = OneSignal._coreDirector._subscriptionModelStore.list().length > 0; diff --git a/src/page/bell/Bell.ts b/src/page/bell/Bell.ts index 6985d2484..68d9dc09b 100755 --- a/src/page/bell/Bell.ts +++ b/src/page/bell/Bell.ts @@ -13,7 +13,7 @@ import type { BellText, } from 'src/shared/prompts/types'; import { wasPromptOfTypeDismissed } from '../../shared/helpers/DismissHelper'; -import MainHelper from '../../shared/helpers/MainHelper'; +import { getNotificationIcons } from '../../shared/helpers/main'; import Log from '../../shared/libraries/Log'; import OneSignalEvent from '../../shared/services/OneSignalEvent'; import { once } from '../../shared/utils/utils'; @@ -325,7 +325,7 @@ export default class Bell { this._badge._hide(); } if (this._dialog._notificationIcons === null) { - const icons = await MainHelper._getNotificationIcons(); + const icons = await getNotificationIcons(); this._dialog._notificationIcons = icons; } } @@ -501,7 +501,7 @@ export default class Bell { await (isPushEnabled ? this._launcher._inactivate() : nothing()) .then(() => { if (isPushEnabled && this._dialog._notificationIcons === null) { - return MainHelper._getNotificationIcons().then((icons) => { + return getNotificationIcons().then((icons) => { this._dialog._notificationIcons = icons; }); } else return nothing(); diff --git a/src/page/managers/LoginManager.ts b/src/page/managers/LoginManager.ts index 3fb62e912..fc08c8baa 100644 --- a/src/page/managers/LoginManager.ts +++ b/src/page/managers/LoginManager.ts @@ -3,7 +3,7 @@ import { LoginUserOperation } from 'src/core/operations/LoginUserOperation'; import { TransferSubscriptionOperation } from 'src/core/operations/TransferSubscriptionOperation'; import { ModelChangeTags } from 'src/core/types/models'; import { db } from 'src/shared/database/client'; -import MainHelper from 'src/shared/helpers/MainHelper'; +import { getAppId } from 'src/shared/helpers/main'; import { IDManager } from 'src/shared/managers/IDManager'; import UserDirector from '../../onesignal/UserDirector'; import Log from '../../shared/libraries/Log'; @@ -51,7 +51,7 @@ export default class LoginManager { ModelChangeTags.HYDRATE, ); const newIdentityOneSignalId = identityModel._onesignalId; - const appId = MainHelper._getAppId(); + const appId = getAppId(); const promises: Promise[] = [ OneSignal._coreDirector._getPushSubscriptionModel().then((pushOp) => { diff --git a/src/page/slidedown/Slidedown.ts b/src/page/slidedown/Slidedown.ts index 9deb57f2c..76b03a27e 100755 --- a/src/page/slidedown/Slidedown.ts +++ b/src/page/slidedown/Slidedown.ts @@ -8,7 +8,7 @@ import { removeDomElement, } from 'src/shared/helpers/dom'; import { getValueOrDefault } from 'src/shared/helpers/general'; -import MainHelper from 'src/shared/helpers/MainHelper'; +import { getNotificationIcons } from 'src/shared/helpers/main'; import type { NotificationIcons } from 'src/shared/notifications/types'; import { DelayedPromptType, @@ -94,7 +94,7 @@ export default class Slidedown { async _create(isInUpdateMode?: boolean): Promise { // TODO: dynamically change btns depending on if its first or repeat display of slidedown (subscribe vs update) if (this._notificationIcons === null) { - const icons = await MainHelper._getNotificationIcons(); + const icons = await getNotificationIcons(); this._notificationIcons = icons; diff --git a/src/shared/helpers/MainHelper.ts b/src/shared/helpers/MainHelper.ts deleted file mode 100755 index 219b2fb45..000000000 --- a/src/shared/helpers/MainHelper.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { db, getOptionsValue } from '../database/client'; -import { getDBAppConfig } from '../database/config'; -import { getSubscription } from '../database/subscription'; -import { getOneSignalApiUrl, useSafariLegacyPush } from '../environment/detect'; -import { AppIDMissingError, MalformedArgumentError } from '../errors/common'; -import Log from '../libraries/Log'; -import type { NotificationIcons } from '../notifications/types'; -import type { - AppUserConfigPromptOptions, - SlidedownOptions, -} from '../prompts/types'; -import { getPlatformNotificationIcon, logMethodCall } from '../utils/utils'; -import { getValueOrDefault } from './general'; -import { triggerNotificationPermissionChanged } from './permissions'; -import { isValidUrl } from './validators'; - -export default class MainHelper { - static async _showLocalNotification( - title: string, - message: string, - url: string, - icon?: string, - data?: Record, - buttons?: Array, - ): Promise { - logMethodCall( - 'MainHelper:showLocalNotification: ', - title, - message, - url, - icon, - data, - buttons, - ); - - const appConfig = await getDBAppConfig(); - - if (!appConfig.appId) throw AppIDMissingError; - if (!OneSignal.Notifications.permission) - throw new Error('User is not subscribed'); - if (!isValidUrl(url)) throw MalformedArgumentError('url'); - if (!isValidUrl(icon, { allowEmpty: true, requireHttps: true })) - throw MalformedArgumentError('icon'); - if (!icon) { - // get default icon - const icons = await MainHelper._getNotificationIcons(); - icon = getPlatformNotificationIcon(icons); - } - - const convertButtonsToNotificationActionType = (buttons: Array) => { - const convertedButtons = []; - - for (let i = 0; i < buttons.length; i++) { - const button = buttons[i]; - convertedButtons.push({ - action: button.id, - title: button.text, - icon: button.icon, - url: button.url, - }); - } - - return convertedButtons; - }; - const dataPayload = { - data, - launchURL: url, - buttons: buttons - ? convertButtonsToNotificationActionType(buttons) - : undefined, - }; - - OneSignal._context._serviceWorkerManager - ._getRegistration() - .then(async (registration?: ServiceWorkerRegistration | null) => { - if (!registration) { - Log._error('Service worker registration not available.'); - return; - } - - const options = { - body: message, - data: dataPayload, - icon: icon, - actions: buttons - ? convertButtonsToNotificationActionType(buttons) - : [], - }; - registration.showNotification(title, options); - }); - } - - static async _checkAndTriggerNotificationPermissionChanged() { - const previousPermission = await getOptionsValue( - 'notificationPermission', - ); - - const currentPermission = - await OneSignal._context._permissionManager._getPermissionStatus(); - - if (previousPermission !== currentPermission) { - await triggerNotificationPermissionChanged(); - await db.put('Options', { - key: 'notificationPermission', - value: currentPermission, - }); - } - } - - static async _getNotificationIcons() { - const appId = MainHelper._getAppId(); - if (!appId) { - throw AppIDMissingError; - } - const url = `${getOneSignalApiUrl().toString()}apps/${appId}/icon`; - const response = await fetch(url); - const data = await response.json(); - if (data.errors) { - Log._error(`API call ${url}`, 'failed with:', data.errors); - throw new Error('Failed to get notification icons.'); - } - return data as NotificationIcons; - } - - public static _getSlidedownOptions( - promptOptions: AppUserConfigPromptOptions, - ): SlidedownOptions { - return getValueOrDefault(promptOptions.slidedown, { prompts: [] }); - } - - static _getFullscreenPermissionMessageOptions( - promptOptions: AppUserConfigPromptOptions | undefined, - ): AppUserConfigPromptOptions | null { - if (!promptOptions) { - return null; - } - if (!promptOptions.fullscreen) { - return promptOptions; - } - - return { - autoAcceptTitle: promptOptions.fullscreen.autoAcceptTitle, - actionMessage: promptOptions.fullscreen.actionMessage, - exampleNotificationTitleDesktop: promptOptions.fullscreen.title, - exampleNotificationTitleMobile: promptOptions.fullscreen.title, - exampleNotificationMessageDesktop: promptOptions.fullscreen.message, - exampleNotificationMessageMobile: promptOptions.fullscreen.message, - exampleNotificationCaption: promptOptions.fullscreen.caption, - acceptButton: promptOptions.fullscreen.acceptButton, - cancelButton: promptOptions.fullscreen.cancelButton, - }; - } - - static _getPromptOptionsQueryString() { - const promptOptions = MainHelper._getFullscreenPermissionMessageOptions( - OneSignal.config?.userConfig.promptOptions, - ); - let promptOptionsStr = ''; - if (promptOptions) { - const hash = MainHelper._getPromptOptionsPostHash(); - for (const key of Object.keys(hash)) { - const value = hash[key as keyof typeof hash]; - promptOptionsStr += '&' + key + '=' + value; - } - } - return promptOptionsStr; - } - - static _getPromptOptionsPostHash() { - const promptOptions = MainHelper._getFullscreenPermissionMessageOptions( - OneSignal.config?.userConfig.promptOptions, - ); - const hash: Record = {}; - if (promptOptions) { - const legacyParams = { - exampleNotificationTitleDesktop: 'exampleNotificationTitle', - exampleNotificationMessageDesktop: 'exampleNotificationMessage', - exampleNotificationTitleMobile: 'exampleNotificationTitle', - exampleNotificationMessageMobile: 'exampleNotificationMessage', - }; - for (const legacyParamKey of Object.keys(legacyParams)) { - const legacyParamValue = - legacyParams[legacyParamKey as keyof typeof legacyParams]; - if (promptOptions[legacyParamKey as keyof AppUserConfigPromptOptions]) { - // @ts-expect-error - TODO: look into better typing for this - promptOptions[legacyParamValue] = promptOptions[legacyParamKey]; - } - } - const allowedPromptOptions = [ - 'autoAcceptTitle', - 'siteName', - 'autoAcceptTitle', - 'subscribeText', - 'showGraphic', - 'actionMessage', - 'exampleNotificationTitle', - 'exampleNotificationMessage', - 'exampleNotificationCaption', - 'acceptButton', - 'cancelButton', - 'timeout', - ]; - for (let i = 0; i < allowedPromptOptions.length; i++) { - const key = allowedPromptOptions[i]; - const value = promptOptions[key as keyof AppUserConfigPromptOptions]; - const encoded_value = encodeURIComponent(value as string); - if (value || value === false || value === '') { - hash[key as keyof typeof hash] = encoded_value; - } - } - } - return hash; - } - - static _getAppId(): string { - return OneSignal.config?.appId || ''; - } - - static async _getDeviceId(): Promise { - const subscription = await getSubscription(); - return subscription.deviceId || undefined; - } - - // TO DO: unit test - static async _getCurrentPushToken(): Promise { - if (useSafariLegacyPush()) { - const safariToken = window.safari?.pushNotification?.permission( - OneSignal.config?.safariWebId, - ).deviceToken; - return safariToken?.toLowerCase() || undefined; - } - - const registration = - await OneSignal._context._serviceWorkerManager._getRegistration(); - if (!registration) { - return undefined; - } - - const subscription = await registration.pushManager.getSubscription(); - return subscription?.endpoint; - } -} diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index bfcbed329..7f0cefa17 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -11,7 +11,7 @@ import { limitStorePut } from '../services/limitStore'; import OneSignalEvent from '../services/OneSignalEvent'; import { IS_SERVICE_WORKER } from '../utils/EnvVariables'; import { once } from '../utils/utils'; -import MainHelper from './MainHelper'; +import { getAppId } from './main'; import { incrementPageViewCount } from './pageview'; import { triggerNotificationPermissionChanged } from './permissions'; import { registerForPush } from './subscription'; @@ -363,7 +363,7 @@ export async function saveInitOptions() { } export async function initSaveState(overridingPageTitle?: string) { - const appId = MainHelper._getAppId(); + const appId = getAppId(); const config: AppConfig = OneSignal.config!; await db.put('Ids', { type: 'appId', id: appId }); const pageTitle: string = diff --git a/src/shared/helpers/main.ts b/src/shared/helpers/main.ts new file mode 100755 index 000000000..d90b5f17c --- /dev/null +++ b/src/shared/helpers/main.ts @@ -0,0 +1,137 @@ +import { db, getOptionsValue } from '../database/client'; +import { getDBAppConfig } from '../database/config'; +import { getOneSignalApiUrl, useSafariLegacyPush } from '../environment/detect'; +import { AppIDMissingError, MalformedArgumentError } from '../errors/common'; +import Log from '../libraries/Log'; +import type { NotificationIcons } from '../notifications/types'; +import { getPlatformNotificationIcon, logMethodCall } from '../utils/utils'; +import { triggerNotificationPermissionChanged } from './permissions'; +import { isValidUrl } from './validators'; + +export async function showLocalNotification( + title: string, + message: string, + url: string, + icon?: string, + data?: Record, + buttons?: Array, +): Promise { + logMethodCall( + 'MainHelper:showLocalNotification: ', + title, + message, + url, + icon, + data, + buttons, + ); + + const appConfig = await getDBAppConfig(); + + if (!appConfig.appId) throw AppIDMissingError; + if (!OneSignal.Notifications.permission) + throw new Error('User is not subscribed'); + if (!isValidUrl(url)) throw MalformedArgumentError('url'); + if (!isValidUrl(icon, { allowEmpty: true, requireHttps: true })) + throw MalformedArgumentError('icon'); + if (!icon) { + // get default icon + const icons = await getNotificationIcons(); + icon = getPlatformNotificationIcon(icons); + } + + const convertButtonsToNotificationActionType = (buttons: Array) => { + const convertedButtons = []; + + for (let i = 0; i < buttons.length; i++) { + const button = buttons[i]; + convertedButtons.push({ + action: button.id, + title: button.text, + icon: button.icon, + url: button.url, + }); + } + + return convertedButtons; + }; + const dataPayload = { + data, + launchURL: url, + buttons: buttons + ? convertButtonsToNotificationActionType(buttons) + : undefined, + }; + + OneSignal._context._serviceWorkerManager + ._getRegistration() + .then(async (registration?: ServiceWorkerRegistration | null) => { + if (!registration) { + Log._error('Service worker registration not available.'); + return; + } + + const options = { + body: message, + data: dataPayload, + icon: icon, + actions: buttons ? convertButtonsToNotificationActionType(buttons) : [], + }; + registration.showNotification(title, options); + }); +} + +export async function checkAndTriggerNotificationPermissionChanged() { + const previousPermission = await getOptionsValue( + 'notificationPermission', + ); + + const currentPermission = + await OneSignal._context._permissionManager._getPermissionStatus(); + + if (previousPermission !== currentPermission) { + await triggerNotificationPermissionChanged(); + await db.put('Options', { + key: 'notificationPermission', + value: currentPermission, + }); + } +} + +export async function getNotificationIcons() { + const appId = getAppId(); + if (!appId) { + throw AppIDMissingError; + } + const url = `${getOneSignalApiUrl().toString()}apps/${appId}/icon`; + const response = await fetch(url); + const data = await response.json(); + if (data.errors) { + Log._error(`API call ${url}`, 'failed with:', data.errors); + throw new Error('Failed to get notification icons.'); + } + return data as NotificationIcons; +} + +export function getAppId(): string { + return OneSignal.config?.appId || ''; +} + +// TO DO: unit test +export async function getCurrentPushToken(): Promise { + if (useSafariLegacyPush()) { + const safariToken = window.safari?.pushNotification?.permission( + OneSignal.config?.safariWebId, + ).deviceToken; + return safariToken?.toLowerCase() || undefined; + } + + const registration = + await OneSignal._context._serviceWorkerManager._getRegistration(); + if (!registration) { + return undefined; + } + + const subscription = await registration.pushManager.getSubscription(); + return subscription?.endpoint; +} diff --git a/src/shared/listeners.ts b/src/shared/listeners.ts index 2a838711a..7016bcccb 100644 --- a/src/shared/listeners.ts +++ b/src/shared/listeners.ts @@ -4,7 +4,7 @@ import type { UserChangeEvent } from 'src/page/models/UserChangeEvent'; import { db, getOptionsValue } from './database/client'; import { getAppState, setAppState } from './database/config'; import { decodeHtmlEntities } from './helpers/dom'; -import MainHelper from './helpers/MainHelper'; +import { getCurrentPushToken, showLocalNotification } from './helpers/main'; import Log from './libraries/Log'; import { CustomLinkManager } from './managers/CustomLinkManager'; import { UserState } from './models/UserState'; @@ -34,7 +34,7 @@ export async function checkAndTriggerSubscriptionChanged() { lastKnownPushToken, lastKnownOptedIn, } = appState; - const currentPushToken = await MainHelper._getCurrentPushToken(); + const currentPushToken = await getCurrentPushToken(); const pushModel = await OneSignal._coreDirector._getPushSubscriptionModel(); const pushSubscriptionId = pushModel?.id; @@ -245,7 +245,7 @@ async function onSubscriptionChanged_showWelcomeNotification( message = decodeHtmlEntities(message); Log._debug('Sending welcome notification.'); - MainHelper._showLocalNotification( + showLocalNotification( title, message, url, diff --git a/src/shared/managers/sessionManager/SessionManager.ts b/src/shared/managers/sessionManager/SessionManager.ts index 154c7e589..ba811a000 100644 --- a/src/shared/managers/sessionManager/SessionManager.ts +++ b/src/shared/managers/sessionManager/SessionManager.ts @@ -17,7 +17,7 @@ import { NotificationType } from 'src/shared/subscriptions/constants'; import { isCompleteSubscriptionObject } from '../../../core/utils/typePredicates'; import User from '../../../onesignal/User'; import LoginManager from '../../../page/managers/LoginManager'; -import MainHelper from '../../helpers/MainHelper'; +import { getAppId } from '../../helpers/main'; import Log from '../../libraries/Log'; import { WorkerMessengerCommand } from '../../libraries/workerMessenger/constants'; import type { ISessionManager } from './types'; @@ -397,7 +397,7 @@ export class SessionManager implements ISessionManager { }, }; - const appId = MainHelper._getAppId(); + const appId = getAppId(); enforceAppId(appId); enforceAlias(aliasPair); try { From 853472e52ba8f58fbd6776ddcd45bdd893416b42 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 23 Oct 2025 16:28:51 -0700 Subject: [PATCH 9/9] address feedback --- src/entries/worker.ts | 4 ++-- src/shared/services/limitStore.ts | 10 +++++----- src/sw/serviceWorker/ServiceWorker.test.ts | 4 +++- src/sw/serviceWorker/ServiceWorker.ts | 2 -- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/entries/worker.ts b/src/entries/worker.ts index 1a8ff5f17..140a162ea 100644 --- a/src/entries/worker.ts +++ b/src/entries/worker.ts @@ -3,5 +3,5 @@ */ import { run } from '../sw/serviceWorker/ServiceWorker'; -// The run() is already called in ServiceWorker.ts, but importing it ensures it's not tree-shaken -void run; +// Need to call run() to ensure the service worker is registered but also to ensure the service worker is not tree-shaken +run(); diff --git a/src/shared/services/limitStore.ts b/src/shared/services/limitStore.ts index 9a036b770..534a2c365 100755 --- a/src/shared/services/limitStore.ts +++ b/src/shared/services/limitStore.ts @@ -1,11 +1,11 @@ /* - LimitStore.put('colorado', 'rocky'); + limitStorePut('colorado', 'rocky'); ["rocky"] - LimitStore.put('colorado', 'mountain'); + limitStorePut('colorado', 'mountain'); ["rocky", "mountain"] - LimitStore.put('colorado', 'national'); + limitStorePut('colorado', 'national'); ["mountain", "national"] - LimitStore.put('colorado', 'park'); + limitStorePut('colorado', 'park'); ["national", "park"] */ const LIMIT = 2; @@ -16,7 +16,7 @@ export function limitStorePut(key: string, value: T) { store[key] = [null, null]; } store[key].push(value); - if (store[key].length == LIMIT + 1) { + if (store[key].length === LIMIT + 1) { store[key].shift(); } return store[key]; diff --git a/src/sw/serviceWorker/ServiceWorker.test.ts b/src/sw/serviceWorker/ServiceWorker.test.ts index e2e08c5d6..8fce87488 100644 --- a/src/sw/serviceWorker/ServiceWorker.test.ts +++ b/src/sw/serviceWorker/ServiceWorker.test.ts @@ -31,7 +31,7 @@ import type { UpsertOrDeactivateSessionPayload, } from 'src/shared/session/types'; import { NotificationType } from 'src/shared/subscriptions/constants'; -import { getAppId } from './ServiceWorker'; +import { getAppId, run } from './ServiceWorker'; // Mock webhook notification events vi.mock('../webhooks/notifications/webhookNotificationEvent', () => ({ @@ -53,6 +53,8 @@ const appId = APP_ID; const notificationId = 'test-notification-id'; const version = __VERSION__; +run(); + vi.useFakeTimers(); vi.setSystemTime('2025-01-01T00:08:00.000Z'); vi.spyOn(Log, '_debug').mockImplementation(() => {}); diff --git a/src/sw/serviceWorker/ServiceWorker.ts b/src/sw/serviceWorker/ServiceWorker.ts index 96437da8d..acaeda09a 100755 --- a/src/sw/serviceWorker/ServiceWorker.ts +++ b/src/sw/serviceWorker/ServiceWorker.ts @@ -1111,5 +1111,3 @@ function parseOrFetchNotifications( `Unexpected push message payload received: ${event.data}`, ); } - -run();