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,