UNPKG

onesignal-web-sdk

Version:

Web push notifications from OneSignal.

640 lines (572 loc) 24.9 kB
import bowser from 'bowser'; import { AppUserConfigNotifyButton, BellSize, BellPosition, BellText } from "../models/AppConfig"; import { NotificationPermission } from "../models/NotificationPermission"; import OneSignalEvent from '../Event'; import MainHelper from '../helpers/MainHelper'; import { ResourceLoadState } from '../services/DynamicResourceLoader'; import { addCssClass, addDomElement, contains, decodeHtmlEntities, delay, nothing, once, removeDomElement, isUsingSubscriptionWorkaround } from '../utils'; import Badge from './Badge'; import Button from './Button'; import Dialog from './Dialog'; import Launcher from './Launcher'; import Message from './Message'; import Log from '../libraries/Log'; import OneSignal from "../OneSignal"; var logoSvg = `<svg class="onesignal-bell-svg" xmlns="http://www.w3.org/2000/svg" width="99.7" height="99.7" viewBox="0 0 99.7 99.7"><circle class="background" cx="49.9" cy="49.9" r="49.9"/><path class="foreground" d="M50.1 66.2H27.7s-2-.2-2-2.1c0-1.9 1.7-2 1.7-2s6.7-3.2 6.7-5.5S33 52.7 33 43.3s6-16.6 13.2-16.6c0 0 1-2.4 3.9-2.4 2.8 0 3.8 2.4 3.8 2.4 7.2 0 13.2 7.2 13.2 16.6s-1 11-1 13.3c0 2.3 6.7 5.5 6.7 5.5s1.7.1 1.7 2c0 1.8-2.1 2.1-2.1 2.1H50.1zm-7.2 2.3h14.5s-1 6.3-7.2 6.3-7.3-6.3-7.3-6.3z"/><ellipse class="stroke" cx="49.9" cy="49.9" rx="37.4" ry="36.9"/></svg>`; type BellState = 'uninitialized' | 'subscribed' | 'unsubscribed' | 'blocked'; export default class Bell { public options: AppUserConfigNotifyButton; public state: BellState = Bell.STATES.UNINITIALIZED; public _ignoreSubscriptionState: boolean = false; public hovering: boolean = false; public initialized: boolean = 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: string = "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' } } } constructor(config: Partial<AppUserConfigNotifyButton>, launcher?: Launcher) { this.options = { enable: config.enable || false, size: config.size || this.DEFAULT_SIZE, position: config.position || this.DEFAULT_POSITION, theme: config.theme || this.DEFAULT_THEME, showLauncherAfter: config.showLauncherAfter || 10, showBadgeAfter: config.showBadgeAfter || 300, text: this.setDefaultTextOptions(config.text || {}), prenotify: config.prenotify, showCredit: config.showCredit, colors: config.colors, offset: config.offset, }; if (launcher) { this._launcher = launcher; } if (!this.options.enable) return; this.validateOptions(this.options); this.state = Bell.STATES.UNINITIALIZED; this._ignoreSubscriptionState = false; this.installEventHooks(); this.updateState(); } showDialogProcedure() { if (!this.dialog.shown) { this.dialog.show() .then(() => { once(document, 'click', (e: Event, destroyEventListener: Function) => { let wasDialogClicked = this.dialog.element.contains(e.target); if (wasDialogClicked) { } else { destroyEventListener(); if (this.dialog.shown) { this.dialog.hide() .then(() => { this.launcher.inactivateIfWasInactive(); }); } } }, true); }); } } private validateOptions(options: AppUserConfigNotifyButton) { if (!options.size || !contains(['small', 'medium', 'large'], options.size)) throw new Error(`Invalid size ${options.size} for notify button. Choose among 'small', 'medium', or 'large'.`); if (!options.position || !contains(['bottom-left', 'bottom-right'], options.position)) throw new Error(`Invalid position ${options.position} for notify button. Choose either 'bottom-left', or 'bottom-right'.`); if (!options.theme || !contains(['default', 'inverse'], options.theme)) throw new Error(`Invalid theme ${options.theme} for notify button. Choose either 'default', or 'inverse'.`); 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.`); 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.`); } private setDefaultTextOptions(text: Partial<BellText>): BellText { const finalText: BellText = { 'tip.state.unsubscribed': text['tip.state.unsubscribed'] || 'Subscribe to notifications', 'tip.state.subscribed': text['tip.state.subscribed'] || "You're subscribed to notifications", 'tip.state.blocked': text['tip.state.blocked'] || "You've blocked notifications", 'message.prenotify': text['message.prenotify'] || "Click to subscribe to notifications", 'message.action.subscribed': text['message.action.subscribed'] || "Thanks for subscribing!", 'message.action.resubscribed': text['message.action.resubscribed'] || "You're subscribed to notifications", 'message.action.subscribing': text['message.action.subscribing'] || "Click <strong>{{prompt.native.grant}}</strong> to receive notifications", 'message.action.unsubscribed': text['message.action.unsubscribed'] || "You won't receive notifications again", 'dialog.main.title': text['dialog.main.title'] || 'Manage Site Notifications', 'dialog.main.button.subscribe': text['dialog.main.button.subscribe'] || 'SUBSCRIBE', 'dialog.main.button.unsubscribe': text['dialog.main.button.unsubscribe'] || 'UNSUBSCRIBE', 'dialog.blocked.title': text['dialog.blocked.title'] || 'Unblock Notifications', 'dialog.blocked.message': text['dialog.blocked.message'] || 'Follow these instructions to allow notifications:', } return finalText; } private installEventHooks() { // Install event hooks OneSignal.emitter.on(Bell.EVENTS.SUBSCRIBE_CLICK, () => { this.dialog.subscribeButton.disabled = true; this._ignoreSubscriptionState = true; OneSignal.setSubscription(true) .then(() => { 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); }) .then(() => { this._ignoreSubscriptionState = false; this.launcher.clearIfWasInactive(); return this.launcher.inactivate(); }) .then(() => { return this.updateState(); }); }); OneSignal.emitter.on(Bell.EVENTS.UNSUBSCRIBE_CLICK, () => { this.dialog.unsubscribeButton.disabled = true; OneSignal.setSubscription(false) .then(() => { this.dialog.unsubscribeButton.disabled = false; return this.dialog.hide(); }) .then(() => { this.launcher.clearIfWasInactive(); return this.launcher.activate(); }) .then(() => { return this.message.display( Message.TYPES.MESSAGE, this.options.text['message.action.unsubscribed'], Message.TIMEOUT); }) .then(() => { return this.updateState(); }); }); OneSignal.emitter.on(Bell.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; 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; 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; resolve(); }); } else { this.message.content = decodeHtmlEntities(this.message.getTipForState()); this.message.contentType = Message.TYPES.TIP; resolve(); } }).then(() => { return this.message.show(); }) .then(() => { this.hovering = false; }) }); OneSignal.emitter.on(Bell.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) { return; } if (!this.dialog.hidden) { // If the dialog is being brought up when clicking button, don't shrink return; } 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()) .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.hidden) { this.launcher.inactivate(); this.launcher.wasInactive = false; } }); } }); OneSignal.emitter.on(OneSignal.EVENTS.SUBSCRIPTION_CHANGED, async isSubscribed => { if (isSubscribed == true) { if (this.badge.shown && this.options.prenotify) { this.badge.hide(); } if (this.dialog.notificationIcons === null) { const icons = await MainHelper.getNotificationIcons(); this.dialog.notificationIcons = icons; } } OneSignal.getNotificationPermission((permission: NotificationPermission) => { let bellState: BellState; if (isSubscribed) { bellState = Bell.STATES.SUBSCRIBED; } else if (permission === NotificationPermission.Denied) { bellState = Bell.STATES.BLOCKED; } else { bellState = Bell.STATES.UNSUBSCRIBED } this.setState(bellState, this._ignoreSubscriptionState); }); }); OneSignal.emitter.on(Bell.EVENTS.STATE_CHANGED, (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(); } }); OneSignal.emitter.on(OneSignal.EVENTS.NATIVE_PROMPT_PERMISSIONCHANGED, () => { this.updateState(); }); } private addDefaultClasses() { // Add default classes const container = this.container; if (this.options.position === 'bottom-left') { if (container) { addCssClass(container, 'onesignal-bell-container-bottom-left'); } addCssClass(this.launcher.selector, 'onesignal-bell-launcher-bottom-left'); } else if (this.options.position === 'bottom-right') { if (container) { addCssClass(container, 'onesignal-bell-container-bottom-right'); } addCssClass(this.launcher.selector, 'onesignal-bell-launcher-bottom-right') } else { throw new Error('Invalid OneSignal notify button position ' + this.options.position); } if (this.options.theme === 'default') { addCssClass(this.launcher.selector, 'onesignal-bell-launcher-theme-default') } else if (this.options.theme === 'inverse') { addCssClass(this.launcher.selector, 'onesignal-bell-launcher-theme-inverse') } else { throw new Error('Invalid OneSignal notify button theme ' + this.options.theme); } } async create() { if (!this.options.enable) return; const sdkStylesLoadResult = await OneSignal.context.dynamicResourceLoader.loadSdkStylesheet(); if (sdkStylesLoadResult !== ResourceLoadState.Loaded) { Log.debug('Not showing notify button because styles failed to load.'); return; } // Remove any existing bell if (this.container) { removeDomElement('#onesignal-bell-container'); } // Insert the bell container addDomElement('body', 'beforeend', '<div id="onesignal-bell-container" class="onesignal-bell-container onesignal-reset"></div>'); if (this.container) { // Insert the bell launcher addDomElement(this.container, 'beforeend', '<div id="onesignal-bell-launcher" class="onesignal-bell-launcher"></div>'); } // Insert the bell launcher button addDomElement(this.launcher.selector, 'beforeend', '<div class="onesignal-bell-launcher-button"></div>'); // Insert the bell launcher badge addDomElement(this.launcher.selector, 'beforeend', '<div class="onesignal-bell-launcher-badge"></div>'); // Insert the bell launcher message addDomElement(this.launcher.selector, 'beforeend', '<div class="onesignal-bell-launcher-message"></div>'); addDomElement(this.message.selector, 'beforeend', '<div class="onesignal-bell-launcher-message-body"></div>'); // Insert the bell launcher dialog addDomElement(this.launcher.selector, 'beforeend', '<div class="onesignal-bell-launcher-dialog"></div>'); addDomElement(this.dialog.selector, 'beforeend', '<div class="onesignal-bell-launcher-dialog-body"></div>'); // Install events // Add visual elements addDomElement(this.button.selector, 'beforeend', logoSvg); const isPushEnabled = await OneSignal.isPushNotificationsEnabled(); const notOptedOut = await OneSignal.getSubscription(); const doNotPrompt = await MainHelper.wasHttpsNativePromptDismissed() // Resize to small instead of specified size if enabled, otherwise there's a jerking motion where the bell, at a different size than small, jerks sideways to go from large -> small or medium -> small let resizeTo = (isPushEnabled ? 'small' : (this.options.size || this.DEFAULT_SIZE)); await this.launcher.resize(resizeTo); this.addDefaultClasses(); this.applyOffsetIfSpecified(); this.setCustomColorsIfSpecified(); this.patchSafariSvgFilterBug(); Log.info('Showing the notify button.'); await (isPushEnabled ? this.launcher.inactivate() : nothing()) .then(() => OneSignal.getSubscription()) .then((isNotOptedOut: boolean) => { if ((isPushEnabled || !isNotOptedOut) && this.dialog.notificationIcons === null) { return MainHelper.getNotificationIcons().then((icons) => { this.dialog.notificationIcons = icons; }); } else return nothing(); }) .then(() => delay(this.options.showLauncherAfter || 0)) .then(() => { if (isUsingSubscriptionWorkaround() && notOptedOut && doNotPrompt !== true && !isPushEnabled && (OneSignal.config.userConfig.promptOptions.autoPrompt === true) && !MainHelper.isHttpPromptAlreadyShown() ) { Log.debug('Not showing notify button because popover will be shown.'); return nothing(); } else { return this.launcher.show(); } }) .then(() => { return delay(this.options.showBadgeAfter || 0); }) .then(() => { if (this.options.prenotify && !isPushEnabled && OneSignal._isNewVisitor) { return this.message.enqueue(this.options.text['message.prenotify']) .then(() => this.badge.show()); } else return nothing(); }) .then(() => this.initialized = true); } patchSafariSvgFilterBug() { if (!(bowser.safari && Number(bowser.version) >= 9.1)) { let bellShadow = `drop-shadow(0 2px 4px rgba(34,36,38,0.35));`; let badgeShadow = `drop-shadow(0 2px 4px rgba(34,36,38,0));`; let dialogShadow = `drop-shadow(0px 2px 2px rgba(34,36,38,.15));`; this.graphic.setAttribute('style', `filter: ${bellShadow}; -webkit-filter: ${bellShadow};`); this.badge.element.setAttribute('style', `filter: ${badgeShadow}; -webkit-filter: ${badgeShadow};`); this.dialog.element.setAttribute('style', `filter: ${dialogShadow}; -webkit-filter: ${dialogShadow};`); } if (bowser.safari) { this.badge.element.setAttribute('style', `display: none;`); } } applyOffsetIfSpecified() { let offset = this.options.offset; if (offset) { const element = this.launcher.element as HTMLElement; if (!element) { Log.error("Could not find bell dom element"); return; } // Reset styles first element.style.cssText = ''; if (offset.bottom) { element.style.cssText += `bottom: ${offset.bottom};`; } if (this.options.position === 'bottom-right') { if (offset.right) { element.style.cssText += `right: ${offset.right};`; } } else if (this.options.position === 'bottom-left') { if (offset.left) { element.style.cssText += `left: ${offset.left};`; } } } } setCustomColorsIfSpecified() { // Some common vars first let dialogButton = this.dialog.element.querySelector('button.action'); let pulseRing = this.button.element.querySelector('.pulse-ring'); // Reset added styles first this.graphic.querySelector('.background').style.cssText = ''; let foregroundElements = this.graphic.querySelectorAll('.foreground'); for (let i = 0; i < foregroundElements.length; i++) { let element = foregroundElements[i]; element.style.cssText = ''; } this.graphic.querySelector('.stroke').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) { let colors = this.options.colors; if (colors['circle.background']) { this.graphic.querySelector('.background').style.cssText += `fill: ${colors['circle.background']}`; } if (colors['circle.foreground']) { let foregroundElements = this.graphic.querySelectorAll('.foreground'); for (let i = 0; i < foregroundElements.length; i++) { let element = foregroundElements[i]; element.style.cssText += `fill: ${colors['circle.foreground']}`; } this.graphic.querySelector('.stroke').style.cssText += `stroke: ${colors['circle.foreground']}`; } if (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']}`; } if (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 += `background: ${colors['dialog.button.background']}`; } if (colors['dialog.button.foreground']) { this.dialog.element.querySelector('button.action').style.cssText += `color: ${colors['dialog.button.foreground']}`; } if (colors['dialog.button.background.hovering']) { this.addCssToHead('onesignal-background-hover-style', `#onesignal-bell-container.onesignal-reset .onesignal-bell-launcher .onesignal-bell-launcher-dialog button.action:hover { background: ${colors['dialog.button.background.hovering']} !important; }`); } if (colors['dialog.button.background.active']) { this.addCssToHead('onesignal-background-active-style', `#onesignal-bell-container.onesignal-reset .onesignal-bell-launcher .onesignal-bell-launcher-dialog button.action:active { background: ${colors['dialog.button.background.active']} !important; }`); } } if (pulseRing) { if (colors['pulse.color']) { this.button.element.querySelector('.pulse-ring').style.cssText = `border-color: ${colors['pulse.color']}`; } } } } addCssToHead(id: string, css: string) { let existingStyleDom = document.getElementById(id); if (existingStyleDom) return; let styleDom = document.createElement('style'); styleDom.id = id; styleDom.type = 'text/css'; styleDom.appendChild(document.createTextNode(css)); document.head.appendChild(styleDom); } /** * Updates the current state to the correct new current state. Returns a promise. */ updateState() { Promise.all([ OneSignal.privateIsPushNotificationsEnabled(), OneSignal.privateGetNotificationPermission() ]) .then(([isEnabled, permission]) => { this.setState(isEnabled ? Bell.STATES.SUBSCRIBED : Bell.STATES.UNSUBSCRIBED); if (permission === NotificationPermission.Denied) { this.setState(Bell.STATES.BLOCKED); } }); } /** * Updates the current state to the specified new state. * @param newState One of ['subscribed', 'unsubscribed']. */ setState(newState: BellState, silent = false) { let lastState = this.state; this.state = newState; if (lastState !== newState && !silent) { OneSignalEvent.trigger(Bell.EVENTS.STATE_CHANGED, {from: lastState, to: newState}); // Update anything that should be changed here in the new state } // Update anything that should be reset to the same state } get container() { return document.querySelector('#onesignal-bell-container'); } get graphic() { return this.button.element.querySelector('svg'); } get launcher() { if (!this._launcher) this._launcher = new Launcher(this); return this._launcher; } get button() { if (!this._button) this._button = new Button(this); return this._button; } get badge() { if (!this._badge) this._badge = new Badge(); return this._badge; } get message() { if (!this._message) this._message = new Message(this); return this._message; } get dialog() { if (!this._dialog) this._dialog = new Dialog(this); return this._dialog; } get subscribed() { return this.state === Bell.STATES.SUBSCRIBED; } get unsubscribed() { return this.state === Bell.STATES.UNSUBSCRIBED; } get blocked() { return this.state === Bell.STATES.BLOCKED; } }