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 @@
+
+
+
+
+
+
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,