diff --git a/package.json b/package.json index cfae59429..e270acba5 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "51.8 kB", + "limit": "50.523 kB", "gzip": true }, { diff --git a/src/page/bell/ActiveAnimatedElement.ts b/src/page/bell/ActiveAnimatedElement.ts deleted file mode 100755 index dd962dc1e..000000000 --- a/src/page/bell/ActiveAnimatedElement.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { containsMatch } from 'src/shared/context/helpers'; -import { addCssClass, removeCssClass } from 'src/shared/helpers/dom'; -import Log from '../../shared/libraries/Log'; -import OneSignalEvent from '../../shared/services/OneSignalEvent'; -import { once } from '../../shared/utils/utils'; -import AnimatedElement from './AnimatedElement'; - -export default class ActiveAnimatedElement extends AnimatedElement { - public activeClass: string | undefined; - public inactiveClass: string | undefined; - public activeState: string | undefined; - public nestedContentSelector: string | undefined; - - /** - * Abstracts common DOM operations like hiding and showing transitionable elements into chainable promises. - * @param selector {string} The CSS selector of the element. - * @param showClass {string} The CSS class name to add to show the element. - * @param hideClass {string} The CSS class name to remove to hide the element. - * @param activeClass {string} The CSS class name to add to activate the element. - * @param inactiveClass {string} The CSS class name to remove to inactivate the element. - * @param state {string} The current state of the element, defaults to 'shown'. - * @param activeState {string} The current state of the element, defaults to 'active'. - * @param targetTransitionEvents {string} An array of properties (e.g. ['transform', 'opacity']) to look for on transitionend of show() and hide() to know the transition is complete. As long as one matches, the transition is considered complete. - * @param nestedContentSelector {string} The CSS selector targeting the nested element within the current element. This nested element will be used for content getters and setters. - */ - constructor( - selector: string, - showClass: string | undefined, - hideClass: string | undefined, - activeClass: string | undefined, - inactiveClass: string | undefined, - state = 'shown', - activeState = 'active', - targetTransitionEvents = ['opacity', 'transform'], - nestedContentSelector?: string, - ) { - super(selector, showClass, hideClass, state, targetTransitionEvents); - this.activeClass = activeClass; - this.inactiveClass = inactiveClass; - this.activeState = activeState; - this.nestedContentSelector = nestedContentSelector; - } - - /** - * Asynchronously activates an element by applying its {activeClass} CSS class. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. - */ - activate() { - if (!this.inactive || !this.shown) { - return Promise.resolve(this); - } else - return new Promise((resolve) => { - this.activeState = 'activating'; - OneSignalEvent.trigger(ActiveAnimatedElement.EVENTS.ACTIVATING, this); - const element = this.element; - if (!element) { - Log._error('Could not find active animated element'); - } else { - if (this.inactiveClass) removeCssClass(element, this.inactiveClass); - if (this.activeClass) addCssClass(element, this.activeClass); - } - - if (this.shown) { - if (this.targetTransitionEvents.length == 0) { - return resolve(this); - } else { - const timerId = setTimeout(() => { - Log._debug( - `Element did not completely activate (state: ${this.state}, activeState: ${this.activeState}).`, - ); - }, this.transitionCheckTimeout); - once( - this.element, - 'transitionend', - (event: Event, destroyListenerFn: () => void) => { - if ( - event.target === this.element && - containsMatch( - this.targetTransitionEvents, - (event as any).propertyName, - ) - ) { - clearTimeout(timerId); - // Uninstall the event listener for transitionend - destroyListenerFn(); - this.activeState = 'active'; - OneSignalEvent.trigger( - ActiveAnimatedElement.EVENTS.ACTIVE, - this, - ); - return resolve(this); - } - }, - true, - ); - } - } else { - Log._debug(`Ending activate() transition (alternative).`); - this.activeState = 'active'; - OneSignalEvent.trigger(ActiveAnimatedElement.EVENTS.ACTIVE, this); - return resolve(this); - } - }); - } - - /** - * Asynchronously activates an element by applying its {activeClass} CSS class. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. - */ - inactivate() { - if (!this.active) { - return Promise.resolve(this); - } else - return new Promise((resolve) => { - this.activeState = 'inactivating'; - OneSignalEvent.trigger(ActiveAnimatedElement.EVENTS.INACTIVATING, this); - const element = this.element; - if (!element) { - Log._error('Could not find active animated element'); - } else { - if (this.activeClass) removeCssClass(element, this.activeClass); - if (this.inactiveClass) addCssClass(element, this.inactiveClass); - } - - if (this.shown) { - if (this.targetTransitionEvents.length == 0) { - return resolve(this); - } else { - const timerId = setTimeout(() => { - Log._debug( - `Element did not completely inactivate (state: ${this.state}, activeState: ${this.activeState}).`, - ); - }, this.transitionCheckTimeout); - once( - this.element, - 'transitionend', - (event: Event, destroyListenerFn: () => void) => { - if ( - event.target === this.element && - containsMatch( - this.targetTransitionEvents, - (event as any).propertyName, - ) - ) { - clearTimeout(timerId); - // Uninstall the event listener for transitionend - destroyListenerFn(); - this.activeState = 'inactive'; - OneSignalEvent.trigger( - ActiveAnimatedElement.EVENTS.INACTIVE, - this, - ); - return resolve(this); - } - }, - true, - ); - } - } else { - this.activeState = 'inactive'; - OneSignalEvent.trigger(ActiveAnimatedElement.EVENTS.INACTIVE, this); - return resolve(this); - } - }); - } - - /** - * Asynchronously waits for an element to finish transitioning to being active. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. - */ - waitUntilActive() { - if (this.active) return Promise.resolve(this); - else - return new Promise((resolve) => { - OneSignal.emitter.once(ActiveAnimatedElement.EVENTS.ACTIVE, (event) => { - if (event === this) { - return resolve(this); - } - }); - }); - } - - /** - * Asynchronously waits for an element to finish transitioning to being inactive. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. - */ - waitUntilInactive() { - if (this.inactive) return Promise.resolve(this); - else - return new Promise((resolve) => { - OneSignal.emitter.once( - ActiveAnimatedElement.EVENTS.INACTIVE, - (event) => { - if (event === this) { - return resolve(this); - } - }, - ); - }); - } - - static get EVENTS() { - return { - ...AnimatedElement.EVENTS, - ...{ - ACTIVATING: 'activeAnimatedElementActivating', - ACTIVE: 'activeAnimatedElementActive', - INACTIVATING: 'activeAnimatedElementInactivating', - INACTIVE: 'activeAnimatedElementInactive', - }, - }; - } - - /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be transitioning to being activated. - */ - get activating() { - return this.activeState === 'activating'; - } - - /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be already active. - */ - get active() { - return this.activeState === 'active'; - } - - /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be transitioning to inactive. - */ - get inactivating() { - return this.activeState === 'inactivating'; - } - - /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be already inactive. - */ - get inactive() { - return this.activeState === 'inactive'; - } -} diff --git a/src/page/bell/AnimatedElement.ts b/src/page/bell/AnimatedElement.ts index 570190f25..55b9fc783 100755 --- a/src/page/bell/AnimatedElement.ts +++ b/src/page/bell/AnimatedElement.ts @@ -1,271 +1,163 @@ -import { containsMatch } from 'src/shared/context/helpers'; -import { addCssClass, removeCssClass } from 'src/shared/helpers/dom'; -import Log from '../../shared/libraries/Log'; -import OneSignalEvent from '../../shared/services/OneSignalEvent'; -import { once } from '../../shared/utils/utils'; - +/** + * Modern replacement for AnimatedElement using CSS transitions and Web Animations API + */ export default class AnimatedElement { - public selector: string; - public showClass: string | undefined; - public hideClass: string | undefined; - public state: string; - public targetTransitionEvents: string[]; - public nestedContentSelector: string | undefined; - public transitionCheckTimeout: number; - /** - * Abstracts common DOM operations like hiding and showing transitionable elements into chainable promises. - * @param selector {string} The CSS selector of the element. - * @param showClass {string} The CSS class name to add to show the element. - * @param hideClass {string} The CSS class name to remove to hide the element. - * @param state {string} The current state of the element, defaults to 'shown'. - * @param targetTransitionEvents {string} An array of properties (e.g. ['transform', 'opacity']) to look for on transitionend of show() and hide() to know the transition is complete. As long as one matches, the transition is considered complete. - * @param nestedContentSelector {string} The CSS selector targeting the nested element within the current element. This nested element will be used for content getters and setters. - */ + public _selector: string; + protected _visibleClass?: string; + protected _activeClass?: string; + protected _inactiveClass?: string; + protected _nestedContentSelector?: string; + constructor( selector: string, - showClass: string | undefined, - hideClass: string | undefined, - state = 'shown', - targetTransitionEvents = ['opacity', 'transform'], + visibleClass?: string, + activeClass?: string, + inactiveClass?: string, nestedContentSelector?: string, - transitionCheckTimeout = 500, ) { - this.selector = selector; - this.showClass = showClass; - this.hideClass = hideClass; - this.state = state; - this.targetTransitionEvents = targetTransitionEvents; - this.nestedContentSelector = nestedContentSelector; - this.transitionCheckTimeout = transitionCheckTimeout; + this._selector = selector; + this._visibleClass = visibleClass; + this._activeClass = activeClass; + this._inactiveClass = inactiveClass; + this._nestedContentSelector = nestedContentSelector; } /** - * Asynchronously shows an element by applying its {showClass} CSS class. - * - * Returns a promise that is resolved with this element when it has completed its transition. + * Show element using CSS classes and wait for animations to complete */ - show(): Promise { - if (!this.hidden) { - return Promise.resolve(this); - } else - return new Promise((resolve) => { - this.state = 'showing'; - OneSignalEvent.trigger(AnimatedElement.EVENTS.SHOWING, this); - const element = this.element; - if (!element) { - Log._error( - `(show) could not find animated element with selector ${this.selector}`, - ); - } else { - if (this.hideClass) removeCssClass(element, this.hideClass); - if (this.showClass) addCssClass(element, this.showClass); - } + async _show(): Promise { + const element = this._element; + if (!element || this._shown) { + return this; + } - if (this.targetTransitionEvents.length == 0) { - return resolve(this); - } else { - const timerId = setTimeout(() => { - Log._debug( - `Element did not completely show (state: ${this.state}).`, - ); - }, this.transitionCheckTimeout); - once( - this.element, - 'transitionend', - (event: Event, destroyListenerFn: () => void) => { - if ( - event.target === this.element && - containsMatch( - this.targetTransitionEvents, - (event as any).propertyName, - ) - ) { - clearTimeout(timerId); - // Uninstall the event listener for transitionend - destroyListenerFn(); - this.state = 'shown'; - OneSignalEvent.trigger(AnimatedElement.EVENTS.SHOWN, this); - return resolve(this); - } - }, - true, - ); - } - }); + if (this._visibleClass) { + element.classList.add(this._visibleClass); + } + + await this._waitForAnimations(); + return this; } /** - * Asynchronously hides an element by applying its {hideClass} CSS class. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. + * Hide element using CSS classes and wait for animations to complete */ - hide() { - if (!this.shown) { - return Promise.resolve(this); - } else - return new Promise((resolve) => { - this.state = 'hiding'; - OneSignalEvent.trigger(AnimatedElement.EVENTS.HIDING, this); - const element = this.element; - if (!element) { - Log._error( - `(hide) could not find animated element with selector ${this.selector}`, - ); - } else { - if (this.showClass) removeCssClass(element, this.showClass); - if (this.hideClass) addCssClass(element, this.hideClass); - } + async _hide(): Promise { + const element = this._element; + if (!element || !this._shown) { + return this; + } - if (this.targetTransitionEvents.length == 0) { - return resolve(this); - } else { - once( - this.element, - 'transitionend', - (event: Event, destroyListenerFn: () => void) => { - const timerId = setTimeout(() => { - Log._debug( - `Element did not completely hide (state: ${this.state}).`, - ); - }, this.transitionCheckTimeout); - if ( - event.target === this.element && - containsMatch( - this.targetTransitionEvents, - (event as any).propertyName, - ) - ) { - clearTimeout(timerId); - // Uninstall the event listener for transitionend - destroyListenerFn(); - this.state = 'hidden'; - OneSignalEvent.trigger(AnimatedElement.EVENTS.HIDDEN, this); - return resolve(this); - } - }, - true, - ); - } - }); - } + if (this._visibleClass) { + element.classList.remove(this._visibleClass); + } - /** - * Asynchronously waits for an element to finish transitioning to being shown. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. - */ - waitUntilShown() { - if (this.state === 'shown') return Promise.resolve(this); - else - return new Promise((resolve) => { - OneSignal.emitter.once(AnimatedElement.EVENTS.SHOWN, (event) => { - if (event === this) { - return resolve(this); - } - }); - }); + await this._waitForAnimations(); + return this; } /** - * Asynchronously waits for an element to finish transitioning to being hidden. - * @returns {Promise} Returns a promise that is resolved with this element when it has completed its transition. + * Activate element using CSS classes */ - waitUntilHidden() { - if (this.state === 'hidden') return Promise.resolve(this); - else - return new Promise((resolve) => { - OneSignal.emitter.once(AnimatedElement.EVENTS.HIDDEN, (event) => { - if (event === this) { - return resolve(this); - } - }); - }); - } + async _activate(): Promise { + const element = this._element; + if (!element || this._active) { + return this; + } - static get EVENTS() { - return { - SHOWING: 'animatedElementShowing', - SHOWN: 'animatedElementShown', - HIDING: 'animatedElementHiding', - HIDDEN: 'animatedElementHidden', - }; + if (this._inactiveClass) { + element.classList.remove(this._inactiveClass); + } + if (this._activeClass) { + element.classList.add(this._activeClass); + } + + await this._waitForAnimations(); + return this; } /** - * Returns the native element's innerHTML property. - * @returns {string} Returns the native element's innerHTML property. + * Inactivate element using CSS classes */ - get content() { - if (!this.element) { - return ''; + async _inactivate(): Promise { + const element = this._element; + if (!element || !this._active) { + return this; } - if (this.nestedContentSelector) { - const innerElement = this.element.querySelector( - this.nestedContentSelector, - ); - return innerElement ? innerElement.innerHTML : ''; - } else { - return this.element.innerHTML; + + if (this._activeClass) { + element.classList.remove(this._activeClass); } + if (this._inactiveClass) { + element.classList.add(this._inactiveClass); + } + + await this._waitForAnimations(); + return this; } /** - * Sets the native element's innerHTML property. - * @param value {string} The HTML to set to the element. + * Wait for all CSS animations/transitions to complete */ - set content(value) { - if (!this.element) { - return; - } - if (this.nestedContentSelector) { - const nestedContent = this.element.querySelector( - this.nestedContentSelector, - ); - if (nestedContent) { - nestedContent.textContent = value; - } - } else { - this.element.textContent = value; - } + protected async _waitForAnimations(): Promise { + const element = this._element; + if (!element) return; + + const animations = element.getAnimations(); + if (animations.length === 0) return; + + await Promise.allSettled(animations.map((animation) => animation.finished)); } /** - * Returns the native {Element} via document.querySelector(). - * @returns {Element | null} Returns the native {Element} via document.querySelector() or {null} if not found. + * Get or set element content */ - get element() { - return document.querySelector(this.selector); + get _content(): string { + const element = this._element; + if (!element) return ''; + + if (this._nestedContentSelector) { + const nestedElement = element.querySelector(this._nestedContentSelector); + return nestedElement?.textContent ?? ''; + } + + return element.textContent ?? ''; } - /* States an element can be in */ + set _content(value: string) { + const element = this._element; + if (!element) return; - /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be transitioning to being shown. - */ - get showing() { - return this.state === 'showing'; + if (this._nestedContentSelector) { + const nestedElement = element.querySelector(this._nestedContentSelector); + if (nestedElement) { + nestedElement.textContent = value; + } + } else { + element.textContent = value; + } } /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be already shown. + * Get the DOM element (lazy-loaded) */ - get shown() { - return this.state === 'shown'; + get _element(): HTMLElement | null { + return document.querySelector(this._selector); } /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be transitioning to hiding. + * State getters */ - get hiding() { - return this.state === 'hiding'; + get _shown(): boolean { + const element = this._element; + return element?.classList.contains(this._visibleClass ?? '') ?? false; } - /** - * Synchronously returns the last known state of the element. - * @returns {boolean} Returns true if the element was last known to be already hidden. - */ - get hidden() { - return this.state === 'hidden'; + get _active(): boolean { + const element = this._element; + if (this._inactiveClass) { + return !(element?.classList.contains(this._inactiveClass) ?? false); + } + return element?.classList.contains(this._activeClass ?? '') ?? false; } } diff --git a/src/page/bell/Badge.ts b/src/page/bell/Badge.ts index a129e38dd..e53232d12 100755 --- a/src/page/bell/Badge.ts +++ b/src/page/bell/Badge.ts @@ -1,40 +1,32 @@ -import ActiveAnimatedElement from './ActiveAnimatedElement'; import AnimatedElement from './AnimatedElement'; -export default class Badge extends ActiveAnimatedElement { +export default class Badge extends AnimatedElement { constructor() { super( '.onesignal-bell-launcher-badge', 'onesignal-bell-launcher-badge-opened', - undefined, 'onesignal-bell-launcher-badge-active', undefined, - 'hidden', ); } - increment(): void { - // If it IS a number (is not not a number) - if (!isNaN(this.content as any)) { - let badgeNumber = +this.content; // Coerce to int - badgeNumber += 1; - this.content = badgeNumber.toString(); + private _updateCount(delta: number): void { + const current = Number(this._content); + if (!isNaN(current)) { + const newCount = current + delta; + this._content = newCount > 0 ? newCount.toString() : ''; } } - show(): Promise { - const promise = super.show(); - OneSignal.notifyButton?.setCustomColorsIfSpecified(); - return promise; + _increment(): void { + this._updateCount(1); } - decrement() { - // If it IS a number (is not not a number) - if (!isNaN(this.content as any)) { - let badgeNumber = +this.content; // Coerce to int - badgeNumber -= 1; - if (badgeNumber > 0) this.content = badgeNumber.toString(); - else this.content = ''; - } + _show(): Promise { + return super._show(); + } + + _decrement(): void { + this._updateCount(-1); } } diff --git a/src/page/bell/Bell.ts b/src/page/bell/Bell.ts index a61780865..f01a38cbe 100755 --- a/src/page/bell/Bell.ts +++ b/src/page/bell/Bell.ts @@ -25,65 +25,34 @@ import Button from './Button'; import Dialog from './Dialog'; import Launcher from './Launcher'; import Message from './Message'; +import { Events, MESSAGE_TIMEOUT, MessageType } from './constants'; const logoSvg = ``; type BellState = 'uninitialized' | 'subscribed' | 'unsubscribed' | 'blocked'; +const DEFAULT_SIZE: BellSize = 'medium'; +const DEFAULT_POSITION: BellPosition = 'bottom-right'; +const DEFAULT_THEME = 'default'; + export default class Bell { - public options: AppUserConfigNotifyButton; - public state: BellState = Bell.STATES.UNINITIALIZED; + public _options: AppUserConfigNotifyButton; + public _state: BellState = 'uninitialized'; public _ignoreSubscriptionState = false; - public hovering = false; - public initialized = false; - public _launcher: Launcher | undefined; - public _button: any; - public _badge: any; - public _message: any; - public _dialog: any; - - private DEFAULT_SIZE: BellSize = 'medium'; - private DEFAULT_POSITION: BellPosition = 'bottom-right'; - private DEFAULT_THEME = 'default'; - - static get EVENTS() { - return { - STATE_CHANGED: 'notifyButtonStateChange', - LAUNCHER_CLICK: 'notifyButtonLauncherClick', - BELL_CLICK: 'notifyButtonButtonClick', - SUBSCRIBE_CLICK: 'notifyButtonSubscribeClick', - UNSUBSCRIBE_CLICK: 'notifyButtonUnsubscribeClick', - HOVERING: 'notifyButtonHovering', - HOVERED: 'notifyButtonHover', - }; - } - - static get STATES() { - return { - UNINITIALIZED: 'uninitialized' as BellState, - SUBSCRIBED: 'subscribed' as BellState, - UNSUBSCRIBED: 'unsubscribed' as BellState, - BLOCKED: 'blocked' as BellState, - }; - } - - static get TEXT_SUBS() { - return { - 'prompt.native.grant': { - default: 'Allow', - chrome: 'Allow', - firefox: 'Always Receive Notifications', - safari: 'Allow', - }, - }; - } + public _hovering = false; + public _initialized = false; + public _launcherEl: Launcher | undefined; + public _buttonEl: Button | undefined = undefined; + public _badgeEl: Badge | undefined = undefined; + public _messageEl: Message | undefined = undefined; + public _dialogEl: Dialog | undefined = undefined; constructor(config: Partial, launcher?: Launcher) { - this.options = { + this._options = { enable: config.enable || false, - size: config.size || this.DEFAULT_SIZE, - position: config.position || this.DEFAULT_POSITION, - theme: config.theme || this.DEFAULT_THEME, + size: config.size || DEFAULT_SIZE, + position: config.position || DEFAULT_POSITION, + theme: config.theme || DEFAULT_THEME, showLauncherAfter: config.showLauncherAfter || 10, showBadgeAfter: config.showBadgeAfter || 300, text: this.setDefaultTextOptions(config.text || {}), @@ -94,34 +63,36 @@ export default class Bell { }; if (launcher) { - this._launcher = launcher; + this._launcherEl = launcher; } - if (!this.options.enable) return; + if (!this._options.enable) return; - this.validateOptions(this.options); - this.state = Bell.STATES.UNINITIALIZED; + this.validateOptions(this._options); + this._state = 'uninitialized'; this._ignoreSubscriptionState = false; this.installEventHooks(); this.updateState(); } - showDialogProcedure() { - if (!this.dialog.shown) { - this.dialog.show().then(() => { + _showDialogProcedure() { + if (!this._dialog._shown) { + this._dialog._show().then(() => { once( document, 'click', (e: Event, destroyEventListener: () => void) => { - const wasDialogClicked = this.dialog.element.contains(e.target); + const wasDialogClicked = this._dialog._element!.contains( + e.target as Node, + ); if (wasDialogClicked) { return; } destroyEventListener(); - if (this.dialog.shown) { - this.dialog.hide().then(() => { - this.launcher.inactivateIfWasInactive(); + if (this._dialog._shown) { + this._dialog._hide().then(() => { + this._launcher._inactivateIfWasInactive(); }); } }, @@ -152,11 +123,11 @@ export default class Bell { ); if (!options.showLauncherAfter || options.showLauncherAfter < 0) throw new Error( - `Invalid delay duration of ${this.options.showLauncherAfter} for showing the notify button. Choose a value above 0.`, + `Invalid delay duration of ${this._options.showLauncherAfter} for showing the notify button. Choose a value above 0.`, ); if (!options.showBadgeAfter || options.showBadgeAfter < 0) throw new Error( - `Invalid delay duration of ${this.options.showBadgeAfter} for showing the notify button's badge. Choose a value above 0.`, + `Invalid delay duration of ${this._options.showBadgeAfter} for showing the notify button's badge. Choose a value above 0.`, ); } @@ -198,25 +169,25 @@ export default class Bell { private installEventHooks() { // Install event hooks - OneSignal.emitter.on(Bell.EVENTS.SUBSCRIBE_CLICK, () => { - this.dialog.subscribeButton.disabled = true; + OneSignal.emitter.on(Events.SubscribeClick, () => { + this._dialog._subscribeButton!.disabled = true; this._ignoreSubscriptionState = true; OneSignal.User.PushSubscription.optIn() .then(() => { - this.dialog.subscribeButton.disabled = false; - return this.dialog.hide(); + this._dialog._subscribeButton!.disabled = false; + return this._dialog._hide(); }) .then(() => { - return this.message.display( - Message.TYPES.MESSAGE, - this.options.text['message.action.resubscribed'], - Message.TIMEOUT, + return this._message._display( + MessageType._Message, + this._options.text['message.action.resubscribed'], + MESSAGE_TIMEOUT, ); }) .then(() => { this._ignoreSubscriptionState = false; - this.launcher.clearIfWasInactive(); - return this.launcher.inactivate(); + this._launcher._clearIfWasInactive(); + return this._launcher._inactivate(); }) .then(() => { return this.updateState(); @@ -226,22 +197,22 @@ export default class Bell { }); }); - OneSignal.emitter.on(Bell.EVENTS.UNSUBSCRIBE_CLICK, () => { - this.dialog.unsubscribeButton.disabled = true; + OneSignal.emitter.on(Events.UnsubscribeClick, () => { + this._dialog._unsubscribeButton!.disabled = true; OneSignal.User.PushSubscription.optOut() .then(() => { - this.dialog.unsubscribeButton.disabled = false; - return this.dialog.hide(); + this._dialog._unsubscribeButton!.disabled = false; + return this._dialog._hide(); }) .then(() => { - this.launcher.clearIfWasInactive(); - return this.launcher.activate(); + this._launcher._clearIfWasInactive(); + return this._launcher._activate(); }) .then(() => { - return this.message.display( - Message.TYPES.MESSAGE, - this.options.text['message.action.unsubscribed'], - Message.TIMEOUT, + return this._message._display( + MessageType._Message, + this._options.text['message.action.unsubscribed'], + MESSAGE_TIMEOUT, ); }) .then(() => { @@ -249,84 +220,84 @@ export default class Bell { }); }); - OneSignal.emitter.on(Bell.EVENTS.HOVERING, () => { - this.hovering = true; - this.launcher.activateIfInactive(); + OneSignal.emitter.on(Events.Hovering, () => { + this._hovering = true; + this._launcher._activateIfInactive(); // If there's already a message being force shown, do not override - if (this.message.shown || this.dialog.shown) { - this.hovering = false; + if (this._message._shown || this._dialog._shown) { + this._hovering = false; return; } // If the message is a message and not a tip, don't show it (only show tips) // Messages will go away on their own - if (this.message.contentType === Message.TYPES.MESSAGE) { - this.hovering = false; + if (this._message._contentType === MessageType._Message) { + this._hovering = false; return; } new Promise((resolve) => { // If a message is being shown - if (this.message.queued.length > 0) { - return this.message.dequeue().then((msg: any) => { - this.message.content = msg; - this.message.contentType = Message.TYPES.QUEUED; + if (this._message._queued.length > 0) { + return this._message._dequeue().then((msg: any) => { + this._message._content = msg; + this._message._contentType = MessageType._Queued; resolve(); }); } else { - this.message.content = decodeHtmlEntities( - this.message.getTipForState(), + this._message._content = decodeHtmlEntities( + this._message._getTipForState(), ); - this.message.contentType = Message.TYPES.TIP; + this._message._contentType = MessageType._Tip; resolve(); } }) .then(() => { - return this.message.show(); + return this._message._show(); }) .then(() => { - this.hovering = false; + this._hovering = false; }) .catch((err) => { Log._error(err); }); }); - OneSignal.emitter.on(Bell.EVENTS.HOVERED, () => { + OneSignal.emitter.on(Events.Hovered, () => { // If a message is displayed (and not a tip), don't control it. Visitors have no control over messages - if (this.message.contentType === Message.TYPES.MESSAGE) { + if (this._message._contentType === MessageType._Message) { return; } - if (!this.dialog.hidden) { + if (this._dialog._shown) { // If the dialog is being brought up when clicking button, don't shrink return; } - if (this.hovering) { - this.hovering = false; + if (this._hovering) { + this._hovering = false; // Hovering still being true here happens on mobile where the message could still be showing (i.e. animating) // when a HOVERED event fires. In other words, you tap on mobile, HOVERING fires, and then HOVERED fires // immediately after because of the way mobile click events work. Basically only happens if HOVERING and HOVERED // fire within a few milliseconds of each other - this.message - .waitUntilShown() - .then(() => delay(Message.TIMEOUT)) - .then(() => this.message.hide()) + this._message + ._show() + .then(() => delay(MESSAGE_TIMEOUT)) + .then(() => this._message._hide()) .then(() => { - if (this.launcher.wasInactive && this.dialog.hidden) { - this.launcher.inactivate(); - this.launcher.wasInactive = false; + if (this._launcher._wasInactive && !this._dialog._shown) { + this._launcher._inactivate(); + this._launcher._wasInactive = false; } }); } - if (this.message.shown) { - this.message.hide().then(() => { - if (this.launcher.wasInactive && this.dialog.hidden) { - this.launcher.inactivate(); - this.launcher.wasInactive = false; + if (this._message._shown) { + this._message._hide().then(() => { + if (this._launcher._wasInactive && !this._dialog._shown) { + this._launcher._inactivate(); + this._launcher._wasInactive = false; } }); } @@ -336,12 +307,12 @@ export default class Bell { OneSignal.EVENTS.SUBSCRIPTION_CHANGED, async (isSubscribed: SubscriptionChangeEvent) => { if (isSubscribed.current.optedIn) { - if (this.badge.shown && this.options.prenotify) { - this.badge.hide(); + if (this._badge._shown && this._options.prenotify) { + this._badge._hide(); } - if (this.dialog.notificationIcons === null) { + if (this._dialog._notificationIcons === null) { const icons = await MainHelper.getNotificationIcons(); - this.dialog.notificationIcons = icons; + this._dialog._notificationIcons = icons; } } @@ -349,25 +320,25 @@ export default class Bell { await OneSignal.context._permissionManager.getPermissionStatus(); let bellState: BellState; if (isSubscribed.current.optedIn) { - bellState = Bell.STATES.SUBSCRIBED; + bellState = 'subscribed'; } else if (permission === 'denied') { - bellState = Bell.STATES.BLOCKED; + bellState = 'blocked'; } else { - bellState = Bell.STATES.UNSUBSCRIBED; + bellState = 'unsubscribed'; } this.setState(bellState, this._ignoreSubscriptionState); }, ); - OneSignal.emitter.on(Bell.EVENTS.STATE_CHANGED, (state) => { - if (!this.launcher.element) { + OneSignal.emitter.on(Events.StateChanged, (state) => { + if (!this._launcher._element) { // Notify button doesn't exist return; } - if (state.to === Bell.STATES.SUBSCRIBED) { - this.launcher.inactivate(); - } else if (state.to === Bell.STATES.UNSUBSCRIBED || Bell.STATES.BLOCKED) { - this.launcher.activate(); + if (state.to === 'subscribed') { + this._launcher._inactivate(); + } else if (state.to === 'unsubscribed' || state.to === 'blocked') { + this._launcher._activate(); } }); @@ -382,47 +353,47 @@ export default class Bell { private addDefaultClasses() { // Add default classes const container = this.container; - if (this.options.position === 'bottom-left') { + if (this._options.position === 'bottom-left') { if (container) { addCssClass(container, 'onesignal-bell-container-bottom-left'); } addCssClass( - this.launcher.selector, + this._launcher._selector, 'onesignal-bell-launcher-bottom-left', ); - } else if (this.options.position === 'bottom-right') { + } else if (this._options.position === 'bottom-right') { if (container) { addCssClass(container, 'onesignal-bell-container-bottom-right'); } addCssClass( - this.launcher.selector, + this._launcher._selector, 'onesignal-bell-launcher-bottom-right', ); } else { throw new Error( - `Invalid OneSignal notify button position ${this.options.position}`, + `Invalid OneSignal notify button position ${this._options.position}`, ); } - if (this.options.theme === 'default') { + if (this._options.theme === 'default') { addCssClass( - this.launcher.selector, + this._launcher._selector, 'onesignal-bell-launcher-theme-default', ); - } else if (this.options.theme === 'inverse') { + } else if (this._options.theme === 'inverse') { addCssClass( - this.launcher.selector, + this._launcher._selector, 'onesignal-bell-launcher-theme-inverse', ); } else { throw new Error( - `Invalid OneSignal notify button theme ${this.options.theme}`, + `Invalid OneSignal notify button theme ${this._options.theme}`, ); } } async create() { - if (!this.options.enable) return; + if (!this._options.enable) return; const sdkStylesLoadResult = await OneSignal.context._dynamicResourceLoader.loadSdkStylesheet(); @@ -453,35 +424,35 @@ export default class Bell { // Insert the bell launcher button addDomElement( - this.launcher.selector, + this._launcher._selector, 'beforeend', '
', ); // Insert the bell launcher badge addDomElement( - this.launcher.selector, + this._launcher._selector, 'beforeend', '
', ); // Insert the bell launcher message addDomElement( - this.launcher.selector, + this._launcher._selector, 'beforeend', '
', ); addDomElement( - this.message.selector, + this._message._selector, 'beforeend', '
', ); // Insert the bell launcher dialog addDomElement( - this.launcher.selector, + this._launcher._selector, 'beforeend', '
', ); addDomElement( - this.dialog.selector, + this._dialog._selector, 'beforeend', '
', ); @@ -489,7 +460,7 @@ export default class Bell { // Install events // Add visual elements - addDomElement(this.button.selector, 'beforeend', logoSvg); + addDomElement(this._button._selector, 'beforeend', logoSvg); const isPushEnabled = await OneSignal.context._subscriptionManager.isPushNotificationsEnabled(); @@ -499,8 +470,8 @@ export default class Bell { // where the bell, at a different size than small, jerks sideways to go from large -> small or medium -> small const resizeTo = isPushEnabled ? 'small' - : this.options.size || this.DEFAULT_SIZE; - await this.launcher.resize(resizeTo); + : this._options.size || DEFAULT_SIZE; + await this._launcher._resize(resizeTo); this.addDefaultClasses(); @@ -510,57 +481,57 @@ export default class Bell { Log._info('Showing the notify button.'); - await (isPushEnabled ? this.launcher.inactivate() : nothing()) + await (isPushEnabled ? this._launcher._inactivate() : nothing()) .then(() => { - if (isPushEnabled && this.dialog.notificationIcons === null) { + if (isPushEnabled && this._dialog._notificationIcons === null) { return MainHelper.getNotificationIcons().then((icons) => { - this.dialog.notificationIcons = icons; + this._dialog._notificationIcons = icons; }); } else return nothing(); }) - .then(() => delay(this.options.showLauncherAfter || 0)) + .then(() => delay(this._options.showLauncherAfter || 0)) .then(() => { - return this.launcher.show(); + return this._launcher._show(); }) .then(() => { - return delay(this.options.showBadgeAfter || 0); + return delay(this._options.showBadgeAfter || 0); }) .then(() => { if ( - this.options.prenotify && + this._options.prenotify && !isPushEnabled && OneSignal._isNewVisitor ) { - return this.message - .enqueue(this.options.text['message.prenotify']) - .then(() => this.badge.show()); + return this._message + ._enqueue(this._options.text['message.prenotify']) + .then(() => this._badge._show()); } else return nothing(); }) - .then(() => (this.initialized = true)); + .then(() => (this._initialized = true)); } addBadgeShadow() { const bellShadow = `drop-shadow(0 2px 4px rgba(34,36,38,0.35));`; const badgeShadow = `drop-shadow(0 2px 4px rgba(34,36,38,0));`; const dialogShadow = `drop-shadow(0px 2px 2px rgba(34,36,38,.15));`; - this.graphic.setAttribute( + this.graphic!.setAttribute( 'style', `filter: ${bellShadow}; -webkit-filter: ${bellShadow};`, ); - this.badge.element.setAttribute( + this._badge._element!.setAttribute( 'style', `filter: ${badgeShadow}; -webkit-filter: ${badgeShadow};`, ); - this.dialog.element.setAttribute( + this._dialog._element!.setAttribute( 'style', `filter: ${dialogShadow}; -webkit-filter: ${dialogShadow};`, ); } applyOffsetIfSpecified() { - const offset = this.options.offset; + const offset = this._options.offset; if (offset) { - const element = this.launcher.element as HTMLElement; + const element = this._launcher._element as HTMLElement; if (!element) { Log._error('Could not find bell dom element'); @@ -573,11 +544,11 @@ export default class Bell { element.style.cssText += `bottom: ${offset.bottom};`; } - if (this.options.position === 'bottom-right') { + if (this._options.position === 'bottom-right') { if (offset.right) { element.style.cssText += `right: ${offset.right};`; } - } else if (this.options.position === 'bottom-left') { + } else if (this._options.position === 'bottom-left') { if (offset.left) { element.style.cssText += `left: ${offset.left};`; } @@ -587,58 +558,70 @@ export default class Bell { setCustomColorsIfSpecified() { // Some common vars first - const dialogButton = this.dialog.element.querySelector('button.action'); - const pulseRing = this.button.element.querySelector('.pulse-ring'); + const dialogButton = this._dialog._element!.querySelector( + 'button.action', + ) as HTMLElement; + const pulseRing = this._button._element!.querySelector( + '.pulse-ring', + ) as HTMLElement; // Reset added styles first - this.graphic.querySelector('.background').style.cssText = ''; - const foregroundElements = this.graphic.querySelectorAll('.foreground'); + (this.graphic!.querySelector('.background') as HTMLElement).style.cssText = + ''; + const foregroundElements = this.graphic!.querySelectorAll( + '.foreground', + ) as NodeListOf; for (let i = 0; i < foregroundElements.length; i++) { const element = foregroundElements[i]; element.style.cssText = ''; } - this.graphic.querySelector('.stroke').style.cssText = ''; - this.badge.element.style.cssText = ''; + (this.graphic!.querySelector('.stroke') as HTMLElement).style.cssText = ''; + this._badge._element!.style.cssText = ''; if (dialogButton) { dialogButton.style.cssText = ''; - dialogButton.style.cssText = ''; } if (pulseRing) { pulseRing.style.cssText = ''; } // Set new styles - if (this.options.colors) { - const colors = this.options.colors; + if (this._options.colors) { + const colors = this._options.colors; if (colors['circle.background']) { - this.graphic.querySelector('.background').style.cssText += - `fill: ${colors['circle.background']}`; + ( + this.graphic!.querySelector('.background') as HTMLElement + ).style.cssText += `fill: ${colors['circle.background']}`; } if (colors['circle.foreground']) { - const foregroundElements = this.graphic.querySelectorAll('.foreground'); + const foregroundElements = this.graphic!.querySelectorAll( + '.foreground', + ) as NodeListOf; for (let i = 0; i < foregroundElements.length; i++) { const element = foregroundElements[i]; element.style.cssText += `fill: ${colors['circle.foreground']}`; } - this.graphic.querySelector('.stroke').style.cssText += + (this.graphic!.querySelector('.stroke') as HTMLElement).style.cssText += `stroke: ${colors['circle.foreground']}`; } if (colors['badge.background']) { - this.badge.element.style.cssText += `background: ${colors['badge.background']}`; + this._badge._element!.style.cssText += `background: ${colors['badge.background']}`; } if (colors['badge.bordercolor']) { - this.badge.element.style.cssText += `border-color: ${colors['badge.bordercolor']}`; + this._badge._element!.style.cssText += `border-color: ${colors['badge.bordercolor']}`; } if (colors['badge.foreground']) { - this.badge.element.style.cssText += `color: ${colors['badge.foreground']}`; + this._badge._element!.style.cssText += `color: ${colors['badge.foreground']}`; } if (dialogButton) { if (colors['dialog.button.background']) { - this.dialog.element.querySelector('button.action').style.cssText += + ( + this._dialog._element!.querySelector('button.action') as HTMLElement + ).style.cssText += `background: ${colors['dialog.button.background']}`; } if (colors['dialog.button.foreground']) { - this.dialog.element.querySelector('button.action').style.cssText += - `color: ${colors['dialog.button.foreground']}`; + ( + this._dialog._element!.querySelector('button.action') as HTMLElement + ).style.cssText += `color: ${colors['dialog.button.foreground']}`; } if (colors['dialog.button.background.hovering']) { this.addCssToHead( @@ -655,8 +638,9 @@ export default class Bell { } if (pulseRing) { if (colors['pulse.color']) { - this.button.element.querySelector('.pulse-ring').style.cssText = - `border-color: ${colors['pulse.color']}`; + ( + this._button._element!.querySelector('.pulse-ring') as HTMLElement + ).style.cssText = `border-color: ${colors['pulse.color']}`; } } } @@ -681,11 +665,9 @@ export default class Bell { OneSignal.context._permissionManager.getPermissionStatus(), ]) .then(([isEnabled, permission]) => { - this.setState( - isEnabled ? Bell.STATES.SUBSCRIBED : Bell.STATES.UNSUBSCRIBED, - ); + this.setState(isEnabled ? 'subscribed' : 'unsubscribed'); if (permission === 'denied') { - this.setState(Bell.STATES.BLOCKED); + this.setState('blocked'); } }) .catch((e) => { @@ -698,10 +680,10 @@ export default class Bell { * @param newState One of ['subscribed', 'unsubscribed']. */ setState(newState: BellState, silent = false) { - const lastState = this.state; - this.state = newState; + const lastState = this._state; + this._state = newState; if (lastState !== newState && !silent) { - OneSignalEvent.trigger(Bell.EVENTS.STATE_CHANGED, { + OneSignalEvent.trigger(Events.StateChanged, { from: lastState, to: newState, }); @@ -716,43 +698,43 @@ export default class Bell { } get graphic() { - return this.button.element.querySelector('svg'); + return this._button._element!.querySelector('svg'); } - get launcher() { - if (!this._launcher) this._launcher = new Launcher(this); - return this._launcher; + get _launcher() { + if (!this._launcherEl) this._launcherEl = new Launcher(this); + return this._launcherEl; } - get button() { - if (!this._button) this._button = new Button(this); - return this._button; + get _button() { + if (!this._buttonEl) this._buttonEl = new Button(this); + return this._buttonEl; } - get badge() { - if (!this._badge) this._badge = new Badge(); - return this._badge; + get _badge() { + if (!this._badgeEl) this._badgeEl = new Badge(); + return this._badgeEl; } - get message() { - if (!this._message) this._message = new Message(this); - return this._message; + get _message() { + if (!this._messageEl) this._messageEl = new Message(this); + return this._messageEl; } - get dialog() { - if (!this._dialog) this._dialog = new Dialog(this); - return this._dialog; + get _dialog() { + if (!this._dialogEl) this._dialogEl = new Dialog(this); + return this._dialogEl; } - get subscribed() { - return this.state === Bell.STATES.SUBSCRIBED; + get _subscribed() { + return this._state === 'subscribed'; } - get unsubscribed() { - return this.state === Bell.STATES.UNSUBSCRIBED; + get _unsubscribed() { + return this._state === 'unsubscribed'; } - get blocked() { - return this.state === Bell.STATES.BLOCKED; + get _blocked() { + return this._state === 'blocked'; } } diff --git a/src/page/bell/Button.ts b/src/page/bell/Button.ts index fc121b6b7..bc4b54d87 100755 --- a/src/page/bell/Button.ts +++ b/src/page/bell/Button.ts @@ -2,154 +2,173 @@ import { addDomElement, removeDomElement } from 'src/shared/helpers/dom'; import { registerForPushNotifications } from 'src/shared/helpers/init'; import LimitStore from 'src/shared/services/LimitStore'; import OneSignalEvent from 'src/shared/services/OneSignalEvent'; -import ActiveAnimatedElement from './ActiveAnimatedElement'; +import AnimatedElement from './AnimatedElement'; import Bell from './Bell'; -import Message from './Message'; +import { Events, MESSAGE_TIMEOUT, MessageType } from './constants'; -export default class Button extends ActiveAnimatedElement { - public events: any; - public bell: Bell; +export default class Button extends AnimatedElement { + public _events: Record; + public _bell: Bell; + private _isHandlingClick = false; constructor(bell: Bell) { super( '.onesignal-bell-launcher-button', undefined, - undefined, 'onesignal-bell-launcher-button-active', undefined, - 'shown', - '', ); - this.bell = bell; - this.events = { + this._bell = bell; + this._events = { mouse: 'bell.launcher.button.mouse', }; - const element = this.element; + const element = this._element; if (element) { element.addEventListener( 'touchstart', () => { - this.onHovering(); - this.onTap(); + this._onHovering(); + this._onTap(); }, { passive: true }, ); element.addEventListener('mouseenter', () => { - this.onHovering(); + this._onHovering(); }); element.addEventListener('mouseleave', () => { - this.onHovered(); + this._onHovered(); }); element.addEventListener( 'touchmove', () => { - this.onHovered(); + this._onHovered(); }, { passive: true }, ); element.addEventListener('mousedown', () => { - this.onTap(); + this._onTap(); }); element.addEventListener('mouseup', () => { - this.onEndTap(); + this._onEndTap(); }); element.addEventListener('click', () => { - this.onHovered(); - this.onClick(); + this._onClick(); }); } } - onHovering() { + _onHovering() { if ( - LimitStore.isEmpty(this.events.mouse) || - LimitStore.getLast(this.events.mouse) === 'out' + LimitStore.isEmpty(this._events.mouse) || + LimitStore.getLast(this._events.mouse) === 'out' ) { - OneSignalEvent.trigger(Bell.EVENTS.HOVERING); + OneSignalEvent.trigger(Events.Hovering); } - LimitStore.put(this.events.mouse, 'over'); + LimitStore.put(this._events.mouse, 'over'); } - onHovered() { - LimitStore.put(this.events.mouse, 'out'); - OneSignalEvent.trigger(Bell.EVENTS.HOVERED); + _onHovered() { + LimitStore.put(this._events.mouse, 'out'); + OneSignalEvent.trigger(Events.Hovered); } - onTap() { - this.pulse(); - this.activate(); - this.bell.badge.activate(); + _onTap() { + this._pulse(); + this._activate(); + this._bell._badge._activate(); } - onEndTap() { - this.inactivate(); - this.bell.badge.inactivate(); + _onEndTap() { + this._inactivate(); + this._bell._badge._inactivate(); } - onClick() { - OneSignalEvent.trigger(Bell.EVENTS.BELL_CLICK); - OneSignalEvent.trigger(Bell.EVENTS.LAUNCHER_CLICK); - - if ( - this.bell.message.shown && - this.bell.message.contentType == Message.TYPES.MESSAGE - ) { - // A message is being shown, it'll disappear soon + async _onClick() { + // Prevent concurrent clicks (fixes GitHub issue #409) + if (this._isHandlingClick) { return; } + this._isHandlingClick = true; + + try { + OneSignalEvent.trigger(Events.BellClick); + OneSignalEvent.trigger(Events.LauncherClick); + + if ( + this._bell._message._shown && + this._bell._message._contentType == MessageType._Message + ) { + // A message is being shown, it'll disappear soon + return; + } + + const optedOut = LimitStore.getLast('subscription.optedOut'); - const optedOut = LimitStore.getLast('subscription.optedOut'); - if (this.bell.unsubscribed) { - if (optedOut) { - // The user is manually opted out, but still "really" subscribed - this.bell.launcher.activateIfInactive().then(() => { - this.bell.showDialogProcedure(); - }); - } else { - // The user is actually subscribed, register him for notifications + // Handle resubscription case + if (this._bell._unsubscribed && !optedOut) { registerForPushNotifications(); - this.bell._ignoreSubscriptionState = true; - OneSignal.emitter.once(OneSignal.EVENTS.SUBSCRIPTION_CHANGED, () => { - this.bell.message - .display( - Message.TYPES.MESSAGE, - this.bell.options.text['message.action.subscribed'], - Message.TIMEOUT, - ) - .then(() => { - this.bell._ignoreSubscriptionState = false; - this.bell.launcher.inactivate(); - }); - }); + this._bell._ignoreSubscriptionState = true; + OneSignal.emitter.once( + OneSignal.EVENTS.SUBSCRIPTION_CHANGED, + async () => { + try { + await this._bell._message._display( + MessageType._Message, + this._bell._options.text['message.action.subscribed'], + MESSAGE_TIMEOUT, + ); + this._bell._ignoreSubscriptionState = false; + await this._bell._launcher._inactivate(); + } catch (error) { + this._bell._ignoreSubscriptionState = false; + throw error; + } + }, + ); + return; } - } else if (this.bell.subscribed) { - this.bell.launcher.activateIfInactive().then(() => { - this.bell.showDialogProcedure(); - }); - } else if (this.bell.blocked) { - this.bell.launcher.activateIfInactive().then(() => { - this.bell.showDialogProcedure(); - }); + + // Handle dialog toggle for all other cases + if ( + this._bell._unsubscribed || + this._bell._subscribed || + this._bell._blocked + ) { + await this._bell._launcher._activateIfInactive(); + await this._toggleDialog(); + } + + await this._bell._message._hide(); + } finally { + this._isHandlingClick = false; + } + } + + async _toggleDialog() { + if (this._bell._dialog._shown) { + // Close dialog if already open (toggle behavior) + await this._bell._dialog._hide(); + await this._bell._launcher._inactivateIfWasInactive(); + } else { + await this._bell._showDialogProcedure(); } - return this.bell.message.hide(); } - pulse() { + _pulse() { removeDomElement('.pulse-ring'); - if (this.element) { + if (this._element) { addDomElement( - this.element, + this._element, 'beforeend', '
', ); } - this.bell.setCustomColorsIfSpecified(); } } diff --git a/src/page/bell/Dialog.ts b/src/page/bell/Dialog.ts index c235d553b..e63009ca7 100755 --- a/src/page/bell/Dialog.ts +++ b/src/page/bell/Dialog.ts @@ -10,79 +10,78 @@ import { getPlatformNotificationIcon } from 'src/shared/utils/utils'; import OneSignalEvent from '../../shared/services/OneSignalEvent'; import AnimatedElement from './AnimatedElement'; import Bell from './Bell'; +import { + Events, + SUBSCRIBE_BUTTON_ID, + UNSUBSCRIBE_BUTTON_ID, +} from './constants'; const STATIC_RESOURCES_URL = new URL('https://media.onesignal.com/web-sdk'); export default class Dialog extends AnimatedElement { - public bell: Bell; - public subscribeButtonId: string; - public unsubscribeButtonId: string; - public notificationIcons: NotificationIcons | null; + public _bell: Bell; + public _notificationIcons: NotificationIcons | null; constructor(bell: Bell) { super( '.onesignal-bell-launcher-dialog', 'onesignal-bell-launcher-dialog-opened', undefined, - 'hidden', - ['opacity', 'transform'], + undefined, '.onesignal-bell-launcher-dialog-body', ); - this.bell = bell; - this.subscribeButtonId = - '#onesignal-bell-container .onesignal-bell-launcher #subscribe-button'; - this.unsubscribeButtonId = - '#onesignal-bell-container .onesignal-bell-launcher #unsubscribe-button'; - this.notificationIcons = null; + this._bell = bell; + this._notificationIcons = null; } - show() { - return this.updateBellLauncherDialogBody().then(() => super.show()); - } - - get subscribeButtonSelectorId() { - return 'subscribe-button'; - } + async _show(): Promise { + if (this._shown) { + return this; + } - get unsubscribeButtonSelectorId() { - return 'unsubscribe-button'; + await this._updateBellLauncherDialogBody(); + return await super._show(); } - get subscribeButton() { - return this.element - ? this.element.querySelector('#' + this.subscribeButtonSelectorId) + get _subscribeButton() { + return this._element + ? (this._element.querySelector( + '#' + SUBSCRIBE_BUTTON_ID, + ) as HTMLButtonElement) : null; } - get unsubscribeButton() { - return this.element - ? this.element.querySelector('#' + this.unsubscribeButtonSelectorId) + get _unsubscribeButton() { + return this._element + ? (this._element.querySelector( + '#' + UNSUBSCRIBE_BUTTON_ID, + ) as HTMLButtonElement) : null; } - updateBellLauncherDialogBody() { + _updateBellLauncherDialogBody() { return OneSignal.context._subscriptionManager .isPushNotificationsEnabled() .then((currentSetSubscription: boolean) => { - if (this.nestedContentSelector) { - clearDomElementChildren(this.nestedContentSelector); + if (this._nestedContentSelector) { + clearDomElementChildren(this._nestedContentSelector); } let contents = 'Nothing to show.'; let footer = ''; - if (this.bell.options.showCredit) { + if (this._bell._options.showCredit) { footer = `
Powered by OneSignal
`; } if ( - (this.bell.state === Bell.STATES.SUBSCRIBED && + (this._bell._state === 'subscribed' && currentSetSubscription === true) || - (this.bell.state === Bell.STATES.UNSUBSCRIBED && + (this._bell._state === 'unsubscribed' && currentSetSubscription === false) ) { let notificationIconHtml = ''; - const imageUrl = getPlatformNotificationIcon(this.notificationIcons); + const imageUrl = getPlatformNotificationIcon(this._notificationIcons); if (imageUrl != 'default-icon') { notificationIconHtml = `
`; } else { @@ -90,13 +89,13 @@ export default class Dialog extends AnimatedElement { } let buttonHtml = ''; - if (this.bell.state !== Bell.STATES.SUBSCRIBED) - buttonHtml = ``; + if (this._bell._state !== 'subscribed') + buttonHtml = ``; else - buttonHtml = ``; + buttonHtml = ``; - contents = `

${this.bell.options.text['dialog.main.title']}

${notificationIconHtml}
${buttonHtml}
${footer}`; - } else if (this.bell.state === Bell.STATES.BLOCKED) { + contents = `

${this._bell._options.text['dialog.main.title']}

${notificationIconHtml}
${buttonHtml}
${footer}`; + } else if (this._bell._state === 'blocked') { let imageUrl = null; const browserName = getBrowserName(); @@ -122,13 +121,14 @@ export default class Dialog extends AnimatedElement { ) { instructionsHtml = `
  1. Access Settings by tapping the three menu dots
  2. Click Site settings under Advanced.
  3. Click Notifications.
  4. Find and click this entry for this website.
  5. Click Notifications and set it to Allow.
`; } - contents = `

${this.bell.options.text['dialog.blocked.title']}

${this.bell.options.text['dialog.blocked.message']}

${instructionsHtml}
${footer}`; + contents = `

${this._bell._options.text['dialog.blocked.title']}

${this._bell._options.text['dialog.blocked.message']}

${instructionsHtml}
${footer}`; } - if (this.nestedContentSelector) { - addDomElement(this.nestedContentSelector, 'beforeend', contents); + if (this._nestedContentSelector) { + addDomElement(this._nestedContentSelector, 'beforeend', contents); } - if (this.subscribeButton) { - this.subscribeButton.addEventListener('click', () => { + // Add event listeners (race conditions now prevented at Button/Bell level) + if (this._subscribeButton) { + this._subscribeButton.addEventListener('click', () => { /* The welcome notification should only be shown if the user is subscribing for the first time and resubscribing via the notify @@ -139,15 +139,14 @@ export default class Dialog extends AnimatedElement { a notification shown in this resubscription case. */ OneSignal.__doNotShowWelcomeNotification = false; - OneSignalEvent.trigger(Bell.EVENTS.SUBSCRIBE_CLICK); + OneSignalEvent.trigger(Events.SubscribeClick); }); } - if (this.unsubscribeButton) { - this.unsubscribeButton.addEventListener('click', () => - OneSignalEvent.trigger(Bell.EVENTS.UNSUBSCRIBE_CLICK), + if (this._unsubscribeButton) { + this._unsubscribeButton.addEventListener('click', () => + OneSignalEvent.trigger(Events.UnsubscribeClick), ); } - this.bell.setCustomColorsIfSpecified(); }); } } diff --git a/src/page/bell/Launcher.ts b/src/page/bell/Launcher.ts index 35b8f1b02..492986702 100755 --- a/src/page/bell/Launcher.ts +++ b/src/page/bell/Launcher.ts @@ -1,144 +1,111 @@ -import { containsMatch } from 'src/shared/context/helpers'; import { addCssClass, hasCssClass, removeCssClass, } from 'src/shared/helpers/dom'; -import { nothing } from 'src/shared/helpers/general'; -import Log from 'src/shared/libraries/Log'; import type { BellSize } from 'src/shared/prompts/types'; -import { once } from 'src/shared/utils/utils'; -import ActiveAnimatedElement from './ActiveAnimatedElement'; +import AnimatedElement from './AnimatedElement'; import Bell from './Bell'; -export default class Launcher extends ActiveAnimatedElement { - public bell: Bell; - public wasInactive: boolean; +export default class Launcher extends AnimatedElement { + public _bell: Bell; + public _wasInactive: boolean; constructor(bell: Bell) { super( '.onesignal-bell-launcher', 'onesignal-bell-launcher-active', undefined, - undefined, 'onesignal-bell-launcher-inactive', - 'hidden', - 'active', ); - this.bell = bell; - this.wasInactive = false; + this._bell = bell; + this._wasInactive = false; } - async resize(size: BellSize) { - if (!this.element) { - // Notify button doesn't exist - throw new Error('Missing DOM element'); + async _resize(size: BellSize) { + if (!this._element) { + // DOM element doesn't exist yet, skip resize + return this; } // If the size is the same, do nothing and resolve an empty promise if ( (size === 'small' && - hasCssClass(this.element, 'onesignal-bell-launcher-sm')) || + hasCssClass(this._element, 'onesignal-bell-launcher-sm')) || (size === 'medium' && - hasCssClass(this.element, 'onesignal-bell-launcher-md')) || + hasCssClass(this._element, 'onesignal-bell-launcher-md')) || (size === 'large' && - hasCssClass(this.element, 'onesignal-bell-launcher-lg')) + hasCssClass(this._element, 'onesignal-bell-launcher-lg')) ) { return Promise.resolve(this); } - removeCssClass(this.element, 'onesignal-bell-launcher-sm'); - removeCssClass(this.element, 'onesignal-bell-launcher-md'); - removeCssClass(this.element, 'onesignal-bell-launcher-lg'); + removeCssClass(this._element, 'onesignal-bell-launcher-sm'); + removeCssClass(this._element, 'onesignal-bell-launcher-md'); + removeCssClass(this._element, 'onesignal-bell-launcher-lg'); if (size === 'small') { - addCssClass(this.element, 'onesignal-bell-launcher-sm'); + addCssClass(this._element, 'onesignal-bell-launcher-sm'); } else if (size === 'medium') { - addCssClass(this.element, 'onesignal-bell-launcher-md'); + addCssClass(this._element, 'onesignal-bell-launcher-md'); } else if (size === 'large') { - addCssClass(this.element, 'onesignal-bell-launcher-lg'); + addCssClass(this._element, 'onesignal-bell-launcher-lg'); } else { throw new Error('Invalid OneSignal bell size ' + size); } - if (!this.shown) { + if (!this._shown) { return this; } else { - return await new Promise((resolve) => { - // Once the launcher has finished shrinking down - if (this.targetTransitionEvents.length == 0) { - return resolve(this); - } else { - const timerId = setTimeout(() => { - Log._debug( - `Launcher did not completely resize (state: ${this.state}, activeState: ${this.activeState}).`, - ); - }, this.transitionCheckTimeout); - once( - this.element!, - 'transitionend', - (event: Event, destroyListenerFn: () => void) => { - if ( - event.target === this.element && - containsMatch( - this.targetTransitionEvents, - (event as any).propertyName, - ) - ) { - clearTimeout(timerId); - // Uninstall the event listener for transitionend - destroyListenerFn(); - return resolve(this); - } - }, - true, - ); - } - }); + // Wait for animations using the modern approach + await this._waitForAnimations(); + return this; } } - activateIfInactive() { - if (this.inactive) { - this.wasInactive = true; - return this.activate(); - } else return nothing(); + async _activateIfInactive() { + if (!this._active) { + this._wasInactive = true; + await this._activate(); + } + return this; } - inactivateIfWasInactive() { - if (this.wasInactive) { - this.wasInactive = false; - return this.inactivate(); - } else return nothing(); + async _inactivateIfWasInactive() { + if (this._wasInactive) { + this._wasInactive = false; + await this._inactivate(); + return this; + } else { + return this; + } } - clearIfWasInactive() { - this.wasInactive = false; + _clearIfWasInactive() { + this._wasInactive = false; } - inactivate() { - return this.bell.message.hide().then(() => { - if (this.bell.badge.content.length > 0) { - return this.bell.badge - .hide() - .then(() => Promise.all([super.inactivate(), this.resize('small')])) - .then(() => this.bell.badge.show()); - } else { - return Promise.all([super.inactivate(), this.resize('small')]); - } - }); + async _inactivate(): Promise { + await this._bell._message._hide(); + if (this._bell._badge._content.length > 0) { + await this._bell._badge._hide(); + await super._inactivate(); + await this._resize('small'); + await this._bell._badge._show(); + } else { + await super._inactivate(); + await this._resize('small'); + } + return this; } - activate() { - if (this.bell.badge.content.length > 0) { - return this.bell.badge - .hide() - .then(() => - Promise.all([super.activate(), this.resize(this.bell.options.size!)]), - ); + async _activate(): Promise { + if (this._bell._badge._content.length > 0) { + await this._bell._badge._hide(); + await super._activate(); + await this._resize(this._bell._options.size!); } else { - return Promise.all([ - super.activate(), - this.resize(this.bell.options.size!), - ]); + await super._activate(); + await this._resize(this._bell._options.size!); } + return this; } } diff --git a/src/page/bell/Message.ts b/src/page/bell/Message.ts index 734331c72..09a346951 100755 --- a/src/page/bell/Message.ts +++ b/src/page/bell/Message.ts @@ -5,102 +5,91 @@ import AnimatedElement from './AnimatedElement'; import Bell from './Bell'; export default class Message extends AnimatedElement { - public bell: Bell; - public contentType: string; - public queued: any; + public _bell: Bell; + public _contentType: string; + public _queued: string[]; constructor(bell: Bell) { super( '.onesignal-bell-launcher-message', 'onesignal-bell-launcher-message-opened', undefined, - 'hidden', - ['opacity', 'transform'], + undefined, '.onesignal-bell-launcher-message-body', ); - this.bell = bell; - this.contentType = ''; - this.queued = []; - } - - static get TIMEOUT() { - return 2500; - } - - static get TYPES() { - return { - TIP: 'tip', // Appears on button hover, disappears on button endhover - MESSAGE: 'message', // Appears manually for a specified duration, site visitor cannot control its display. Messages override tips - QUEUED: 'queued', // This message was a user-queued message - }; + this._bell = bell; + this._contentType = ''; + this._queued = []; } - display(type: string, content: string, duration = 0) { + _display(type: string, content: string, duration = 0) { Log._debug(`Calling display(${type}, ${content}, ${duration}).`); - return (this.shown ? this.hide() : nothing()) + return (this._shown ? this._hide() : nothing()) .then(() => { - this.content = decodeHtmlEntities(content); - this.contentType = type; + this._content = decodeHtmlEntities(content); + this._contentType = type; }) .then(() => { - return this.show(); + return this._show(); }) .then(() => delay(duration)) .then(() => { - return this.hide(); + return this._hide(); }) .then(() => { // Reset back to normal content type so stuff can show a gain - this.content = this.getTipForState(); - this.contentType = 'tip'; + this._content = this._getTipForState(); + this._contentType = 'tip'; }); } - getTipForState(): string { - if (this.bell.state === Bell.STATES.UNSUBSCRIBED) - return this.bell.options.text['tip.state.unsubscribed']; - else if (this.bell.state === Bell.STATES.SUBSCRIBED) - return this.bell.options.text['tip.state.subscribed']; - else if (this.bell.state === Bell.STATES.BLOCKED) - return this.bell.options.text['tip.state.blocked']; + _getTipForState(): string { + if (this._bell._state === 'unsubscribed') + return this._bell._options.text['tip.state.unsubscribed']; + else if (this._bell._state === 'subscribed') + return this._bell._options.text['tip.state.subscribed']; + else if (this._bell._state === 'blocked') + return this._bell._options.text['tip.state.blocked']; return ''; } - enqueue(message: string) { - this.queued.push(decodeHtmlEntities(message)); + _enqueue(message: string) { + this._queued.push(decodeHtmlEntities(message)); return new Promise((resolve) => { - if (this.bell.badge.shown) { - this.bell.badge - .hide() - .then(() => this.bell.badge.increment()) - .then(() => this.bell.badge.show()) - .then(resolve); + if (this._bell._badge._shown) { + this._bell._badge + ._hide() + .then(() => this._bell._badge._increment()) + .then(() => this._bell._badge._show()) + .then(() => resolve()); } else { - this.bell.badge.increment(); - if (this.bell.initialized) this.bell.badge.show().then(resolve); + this._bell._badge._increment(); + if (this._bell._initialized) + this._bell._badge._show().then(() => resolve()); else resolve(); } }); } - dequeue(message: string) { - const dequeuedMessage = this.queued.pop(message); + _dequeue() { + const dequeuedMessage = this._queued.pop(); return new Promise((resolve) => { - if (this.bell.badge.shown) { - this.bell.badge - .hide() - .then(() => this.bell.badge.decrement()) - .then((numMessagesLeft: number) => { + if (this._bell._badge._shown) { + this._bell._badge + ._hide() + .then(() => this._bell._badge._decrement()) + .then(() => { + const numMessagesLeft = Number(this._bell._badge._content) || 0; if (numMessagesLeft > 0) { - return this.bell.badge.show(); + return this._bell._badge._show(); } else { return Promise.resolve(this); } }) - .then(resolve(dequeuedMessage)); + .then(() => resolve(dequeuedMessage)); } else { - this.bell.badge.decrement(); + this._bell._badge._decrement(); resolve(dequeuedMessage); } }); diff --git a/src/page/bell/constants.ts b/src/page/bell/constants.ts new file mode 100644 index 000000000..041d8cc70 --- /dev/null +++ b/src/page/bell/constants.ts @@ -0,0 +1,21 @@ +export const MESSAGE_TIMEOUT = 2500; + +export const MessageType = { + _Tip: 'tip', // Appears on button hover, disappears on button endhover + _Message: 'message', // Appears manually for a specified duration, site visitor cannot control its display. Messages override tips + _Queued: 'queued', // This message was a user-queued message +} as const; + +// Button IDs +export const SUBSCRIBE_BUTTON_ID = 'subscribe-button'; +export const UNSUBSCRIBE_BUTTON_ID = 'unsubscribe-button'; + +export const Events = { + StateChanged: 'notify0', + LauncherClick: 'notify1', + BellClick: 'notify2', + SubscribeClick: 'notify3', + UnsubscribeClick: 'notify4', + Hovering: 'notify5', + Hovered: 'notify6', +}; diff --git a/src/page/slidedown/Slidedown.ts b/src/page/slidedown/Slidedown.ts index 3723f3229..9f4947d52 100755 --- a/src/page/slidedown/Slidedown.ts +++ b/src/page/slidedown/Slidedown.ts @@ -344,16 +344,16 @@ export function manageNotifyButtonStateWhileSlidedownShows(): void { const notifyButton = OneSignal.notifyButton; if ( notifyButton && - notifyButton.options?.enable && - OneSignal.notifyButton?.launcher?.state !== 'hidden' + notifyButton._options?.enable && + OneSignal.notifyButton?._launcher?._shown ) { - OneSignal.notifyButton?.launcher?.waitUntilShown().then(() => { - OneSignal.notifyButton?.launcher?.hide(); + OneSignal.notifyButton?._launcher?._show().then(() => { + OneSignal.notifyButton?._launcher?._hide(); }); } OneSignal.emitter.once(Slidedown.EVENTS.CLOSED, () => { - if (OneSignal.notifyButton && OneSignal.notifyButton.options.enable) { - OneSignal.notifyButton.launcher.show(); + if (OneSignal.notifyButton && OneSignal.notifyButton._options.enable) { + OneSignal.notifyButton._launcher._show(); } }); } diff --git a/src/shared/listeners.ts b/src/shared/listeners.ts index e503d5d1f..6d8c4fc2d 100644 --- a/src/shared/listeners.ts +++ b/src/shared/listeners.ts @@ -170,12 +170,12 @@ async function onSubscriptionChanged_evaluateNotifyButtonDisplayPredicate() { Log._debug( 'Showing notify button because display predicate returned true.', ); - OneSignal.notifyButton.launcher.show(); + OneSignal.notifyButton._launcher._show(); } else { Log._debug( 'Hiding notify button because display predicate returned false.', ); - OneSignal.notifyButton.launcher.hide(); + OneSignal.notifyButton._launcher._hide(); } } } diff --git a/src/shared/services/OneSignalEvent.ts b/src/shared/services/OneSignalEvent.ts index b3454ff74..9178eac36 100755 --- a/src/shared/services/OneSignalEvent.ts +++ b/src/shared/services/OneSignalEvent.ts @@ -1,3 +1,4 @@ +import { Events } from 'src/page/bell/constants'; import { containsMatch } from '../context/helpers'; import { windowEnvString } from '../environment/detect'; import Emitter from '../libraries/Emitter'; @@ -5,18 +6,10 @@ import Log from '../libraries/Log'; import { IS_SERVICE_WORKER } from '../utils/EnvVariables'; const SILENT_EVENTS = [ - 'notifyButtonHovering', - 'notifyButtonHover', - 'notifyButtonButtonClick', - 'notifyButtonLauncherClick', - 'animatedElementHiding', - 'animatedElementHidden', - 'animatedElementShowing', - 'animatedElementShown', - 'activeAnimatedElementActivating', - 'activeAnimatedElementActive', - 'activeAnimatedElementInactivating', - 'activeAnimatedElementInactive', + Events.Hovering, + Events.Hovered, + Events.BellClick, + Events.LauncherClick, ]; export default class OneSignalEvent {