From cc0ca704280ffc6d77ac742695df066c74fde4e0 Mon Sep 17 00:00:00 2001 From: luvi Date: Wed, 26 Jul 2023 14:25:42 +0200 Subject: [PATCH] [IMP] mail, web: call the installation prompt from the notification area The NotificationItem component has been modified to support a new slot allowing to display some content at the right of the notification item. It is used in this commit to display an additional 'Install button', inviting the user to click on the notification. Note that the entire notification is still clickable, it is only a visual indicator. Once the notification is clicked, the installation process is triggered: - In case the system supports the native installation prompt, we now display it, and the user is free to install the PWA from there. - On Apple devices, a custom popup is displayed to the user, explaining the steps to install the website as an application, since iOS and macOS don't support a native prompt, and won't be using one anytime soon, since it's a design decision from their side. Some tests have been added to mock and test the display of the notification. patchBrowserWithCleanup has been adapted to also remove any BeforeInstallPromptEvent for every tests by default. This allows to test without the risk of issues with messaging tests reading the number of notifications, or clicking the first notification item when the service checks whether the prompt can be shown or not (depending the availability of a native prompt, the OS, or the localStorage, etc.). To test the service correctly, you need to patch the test accordingly. task-3275275 closes odoo/odoo#135866 Related: odoo/enterprise#48968 Signed-off-by: Pierre Paridans (app) --- .../static/src/core/web/messaging_menu.js | 21 ++++ .../static/src/core/web/messaging_menu.xml | 18 +++ .../static/src/core/web/notification_item.xml | 3 + .../messaging_menu/messaging_menu_tests.js | 105 +++++++++++++++++- addons/web/static/src/core/browser/browser.js | 1 + .../src/core/install_prompt/install_prompt.js | 25 +++++ .../core/install_prompt/install_prompt.scss | 30 +++++ .../core/install_prompt/install_prompt.xml | 34 ++++++ .../install_prompt/install_prompt_service.js | 83 ++++++++++++++ addons/web/static/tests/setup.js | 3 + 10 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 addons/web/static/src/core/install_prompt/install_prompt.js create mode 100644 addons/web/static/src/core/install_prompt/install_prompt.scss create mode 100644 addons/web/static/src/core/install_prompt/install_prompt.xml create mode 100644 addons/web/static/src/core/install_prompt/install_prompt_service.js diff --git a/addons/mail/static/src/core/web/messaging_menu.js b/addons/mail/static/src/core/web/messaging_menu.js index ef788890cdb..d3aff54ea86 100644 --- a/addons/mail/static/src/core/web/messaging_menu.js +++ b/addons/mail/static/src/core/web/messaging_menu.js @@ -24,6 +24,7 @@ export class MessagingMenu extends Component { this.chatWindowService = useState(useService("mail.chat_window")); this.threadService = useState(useService("mail.thread")); this.action = useService("action"); + this.installPrompt = useState(useService("installPrompt")); this.ui = useState(useService("ui")); this.state = useState({ addingChat: false, @@ -71,6 +72,10 @@ export class MessagingMenu extends Component { return tab === "chat" ? ["chat", "group"] : [tab]; } + get canPromptToInstall() { + return this.installPrompt.canPromptToInstall; + } + get hasPreviews() { return ( this.threads.length > 0 || @@ -80,6 +85,19 @@ export class MessagingMenu extends Component { ); } + get installationRequest() { + return { + body: _t("Come here often? Install Odoo on your device!"), + displayName: _t("%s has a suggestion", this.store.odoobot.name), + onClick: () => { + this.installPrompt.show(); + }, + iconSrc: this.threadService.avatarUrl(this.store.odoobot), + partner: this.store.odoobot, + isShown: this.canPromptToInstall, + }; + } + get notificationRequest() { return { body: _t("Enable desktop notifications to chat"), @@ -301,6 +319,9 @@ export class MessagingMenu extends Component { (acc, ng) => acc + parseInt(ng.notifications.length), 0 ); + if (this.canPromptToInstall) { + value++; + } if (this.notification.permission === "prompt") { value++; } diff --git a/addons/mail/static/src/core/web/messaging_menu.xml b/addons/mail/static/src/core/web/messaging_menu.xml index 40816c97576..585f6a1926f 100644 --- a/addons/mail/static/src/core/web/messaging_menu.xml +++ b/addons/mail/static/src/core/web/messaging_menu.xml @@ -28,6 +28,24 @@
No conversation yet...
+ + + + + + + + + Install + + + + + + + diff --git a/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js b/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js index 504f458a2ba..5d6d1b4207e 100644 --- a/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js +++ b/addons/mail/static/tests/messaging_menu/messaging_menu_tests.js @@ -8,7 +8,7 @@ import { patchUiSize, SIZES } from "@mail/../tests/helpers/patch_ui_size"; import { start } from "@mail/../tests/helpers/test_utils"; import { browser } from "@web/core/browser/browser"; -import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils"; import { click, contains, triggerEvents } from "@web/../tests/utils"; QUnit.module("messaging menu"); @@ -123,6 +123,109 @@ QUnit.test("no 'OdooBot has a request' in mobile app", async () => { await contains(".o-mail-NotificationItem", { count: 0 }); }); +QUnit.test("rendering with PWA installation request", async (assert) => { + const target = getFixture(); + + patchWithCleanup(browser, { + BeforeInstallPromptEvent: () => {}, + }); + patchWithCleanup(browser.localStorage, { + getItem(key) { + if (key === "pwa.installationState") { + assert.step("getItem " + key); + // in this test, installation has not yet proceeded + return null; + } + return super.getItem(key); + }, + }); + + const { env } = await start(); + patchWithCleanup(env.services.installPrompt, { + show() { + assert.step("show prompt"); + }, + }); + assert.verifySteps(["getItem pwa.installationState"]); + + await contains(".o-mail-MessagingMenu-counter"); + await contains(".o-mail-MessagingMenu-counter", { text: "1" }); + await click(".o_menu_systray i[aria-label='Messages']"); + await contains(".o-mail-NotificationItem"); + assert.ok( + target + .querySelector(".o-mail-NotificationItem img") + .dataset.src.includes("/web/image?field=avatar_128&id=2&model=res.partner") + ); + assert.strictEqual( + target.querySelector(".o-mail-NotificationItem-name").textContent, + "OdooBot has a suggestion" + ); + assert.strictEqual( + target.querySelector(".o-mail-NotificationItem-text").textContent, + "Come here often? Install Odoo on your device!" + ); + + await click(".o-mail-NotificationItem a.btn-primary"); + assert.verifySteps(["show prompt"]); +}); + +QUnit.test("rendering with PWA installation request (dismissed)", async (assert) => { + const target = getFixture(); + + patchWithCleanup(browser, { + BeforeInstallPromptEvent: () => {}, + }); + patchWithCleanup(browser.localStorage, { + getItem(key) { + if (key === "pwa.installationState") { + assert.step("getItem " + key); + // in this test, installation has been previously dismissed by the user + return "dismissed"; + } + return super.getItem(key); + }, + }); + + await start(); + assert.verifySteps(["getItem pwa.installationState"]); + assert.containsNone(target, ".o-mail-MessagingMenu-counter"); + + await click(".o_menu_systray i[aria-label='Messages']"); + assert.containsNone(target, ".o-mail-NotificationItem"); +}); + +QUnit.test("rendering with PWA installation request (already running as PWA)", async (assert) => { + const target = getFixture(); + + patchWithCleanup(browser, { + BeforeInstallPromptEvent: () => {}, + matchMedia(media) { + if (media === "(display-mode: standalone)") { + return { matches: true }; + } + return super.matchMedia(media); + }, + }); + patchWithCleanup(browser.localStorage, { + getItem(key) { + if (key === "pwa.installationState") { + assert.step("getItem " + key); + // in this test, we remove any value that could contain localStorage so the service would be allowed to prompt + return null; + } + return super.getItem(key); + }, + }); + + await start(); + assert.verifySteps(["getItem pwa.installationState"]); + assert.containsNone(target, ".o-mail-MessagingMenu-counter"); + + await click(".o_menu_systray i[aria-label='Messages']"); + assert.containsNone(target, ".o-mail-NotificationItem"); +}); + QUnit.test("Is closed after clicking on new message", async () => { await start(); await click(".o_menu_systray i[aria-label='Messages']"); diff --git a/addons/web/static/src/core/browser/browser.js b/addons/web/static/src/core/browser/browser.js index 1b083a30a01..1d5ed6bd621 100644 --- a/addons/web/static/src/core/browser/browser.js +++ b/addons/web/static/src/core/browser/browser.js @@ -27,6 +27,7 @@ export const browser = { AudioBufferSourceNode: window.AudioBufferSourceNode, AudioContext: window.AudioContext, AudioWorkletNode: window.AudioWorkletNode, + BeforeInstallPromptEvent: window.BeforeInstallPromptEvent?.bind(window), GainNode: window.GainNode, MediaStreamAudioSourceNode: window.MediaStreamAudioSourceNode, removeEventListener: window.removeEventListener.bind(window), diff --git a/addons/web/static/src/core/install_prompt/install_prompt.js b/addons/web/static/src/core/install_prompt/install_prompt.js new file mode 100644 index 00000000000..9a441d1af1e --- /dev/null +++ b/addons/web/static/src/core/install_prompt/install_prompt.js @@ -0,0 +1,25 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { isIOS } from "@web/core/browser/feature_detection"; + +export class InstallPrompt extends Component { + static props = { + close: true, + onClose: { type: Function }, + }; + static components = { + Dialog, + }; + static template = "web.InstallPrompt"; + + get isMobileSafari() { + return isIOS(); + } + + onClose() { + this.props.close(); + this.props.onClose(); + } +} diff --git a/addons/web/static/src/core/install_prompt/install_prompt.scss b/addons/web/static/src/core/install_prompt/install_prompt.scss new file mode 100644 index 00000000000..4808872b863 --- /dev/null +++ b/addons/web/static/src/core/install_prompt/install_prompt.scss @@ -0,0 +1,30 @@ +.o_install_prompt { + /* We don't use a rounded-x class for this dialog to match the look of a native OS dialog */ + border-radius: 20px; + height: unset !important; + -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); + background: rgba($white, .7); + inset: 0 auto auto 0 !important; + width: fit-content !important; + + &.o_touch_bounce { + animation: none; + } + + @media screen and (max-width: 768px) { + inset: auto auto 0 0 !important; + width: 90% !important; + margin: 5% !important; + transform: translateX(-50%); + + .modal-header { + background: none !important; + border: none !important; + + button { + color: $black !important; + } + } + } +} diff --git a/addons/web/static/src/core/install_prompt/install_prompt.xml b/addons/web/static/src/core/install_prompt/install_prompt.xml new file mode 100644 index 00000000000..890012df0b4 --- /dev/null +++ b/addons/web/static/src/core/install_prompt/install_prompt.xml @@ -0,0 +1,34 @@ + + + + + +
+

How to get the application

+
+
+ +

Install Odoo on your device to access it easily. Here are the steps to follow:

+ +
    +
  • + Tap on the share icon +
  • +
  • + Select "Add to home screen" +
  • +
+
+ +
    +
  • + Open "File" menu from your browser +
  • +
  • + Select "Add to dock" +
  • +
+
+
+
+
diff --git a/addons/web/static/src/core/install_prompt/install_prompt_service.js b/addons/web/static/src/core/install_prompt/install_prompt_service.js new file mode 100644 index 00000000000..af5de4a4b29 --- /dev/null +++ b/addons/web/static/src/core/install_prompt/install_prompt_service.js @@ -0,0 +1,83 @@ +/** @odoo-module **/ + +import { reactive } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { + isDisplayStandalone, + isIOS, + isMacOS, + isBrowserSafari, +} from "@web/core/browser/feature_detection"; +import { registry } from "@web/core/registry"; +import { InstallPrompt } from "./install_prompt"; + +const serviceRegistry = registry.category("services"); + +const installPromptService = { + dependencies: ["dialog"], + start(env, { dialog }) { + let nativePrompt; + + const state = reactive({ + canPromptToInstall: false, + decline, + show, + }); + + // The PWA can only be installed if the app is not already launched (display-mode standalone) + // For Apple devices, PWA are supported on any mobile version of Safari, or in desktop since version 17 + const canBeInstalled = + !isDisplayStandalone() && + (browser.BeforeInstallPromptEvent !== undefined || + (isBrowserSafari() && + (isIOS() || + (isMacOS() && + browser.navigator.userAgent.match(/Version\/(\d+)/)[1] >= 17)))); + + const installationState = browser.localStorage.getItem("pwa.installationState"); + + // It is possible that the browser still has the installationState stored in the localstorage once + // the app has been uninstalled. This is why we don't use installationState directly in canBeInstalled + state.canPromptToInstall = canBeInstalled && !installationState; + + const isDeclined = installationState === "dismissed"; + + if (canBeInstalled && !isDeclined) { + browser.addEventListener("beforeinstallprompt", (ev) => { + if (installationState === "accepted") { + // If this event is triggered with the installationState stored, it means that the app has been + // removed since its installation. The prompt can be displayed, and the installation state is reset. + state.canPromptToInstall = true; + browser.localStorage.removeItem("pwa.installationState"); + } + ev.preventDefault(); + nativePrompt = ev; + }); + } + + async function show() { + if (!state.canPromptToInstall) { + return; + } + if (nativePrompt) { + const res = await nativePrompt.prompt(); + browser.localStorage.setItem("pwa.installationState", res.outcome); + state.canPromptToInstall = false; + } else if (isBrowserSafari()) { + // since those platforms don't support a native installation prompt yet, we + // show a custom dialog to explain how to pin the app to the application menu + dialog.add(InstallPrompt, { + onClose: () => this.decline(), + }); + } + } + + function decline() { + browser.localStorage.setItem("pwa.installationState", "dismissed"); + state.canPromptToInstall = false; + } + + return state; + }, +}; +serviceRegistry.add("installPrompt", installPromptService); diff --git a/addons/web/static/tests/setup.js b/addons/web/static/tests/setup.js index ad35feeae7c..a82c010a927 100644 --- a/addons/web/static/tests/setup.js +++ b/addons/web/static/tests/setup.js @@ -136,6 +136,9 @@ function patchBrowserWithCleanup() { }); return interval; }, + // patch BeforeInstallPromptEvent to prevent the installPrompt service to return an uncontrolled + // canPromptToInstall value depending the browser settings (we ensure the value is always falsy) + BeforeInstallPromptEvent: undefined, navigator: { mediaDevices: browser.navigator.mediaDevices, permissions: browser.navigator.permissions,