[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) <app@odoo.com>
This commit is contained in:
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,24 @@
|
||||
<div class="d-flex justify-content-center py-4 px-2 text-muted" t-if="!hasPreviews">
|
||||
No conversation yet...
|
||||
</div>
|
||||
<t t-if="installationRequest.isShown">
|
||||
<NotificationItem
|
||||
body="installationRequest.body"
|
||||
displayName="installationRequest.displayName"
|
||||
iconSrc="installationRequest.iconSrc"
|
||||
onClick="installationRequest.onClick"
|
||||
>
|
||||
<t t-set-slot="icon">
|
||||
<ImStatus persona="installationRequest.partner" className="'position-absolute top-100 start-100 translate-middle mt-n1 ms-n1'"/>
|
||||
</t>
|
||||
<t t-set-slot="requestContent">
|
||||
<a t-if="ui.isSmall" class="btn fa fa-cloud-download" />
|
||||
<a t-else="" class="btn btn-primary">
|
||||
Install
|
||||
</a>
|
||||
</t>
|
||||
</NotificationItem>
|
||||
</t>
|
||||
<t t-if="notificationRequest.isShown">
|
||||
<NotificationItem
|
||||
body="notificationRequest.body"
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="props.slots and props.slots.requestContent">
|
||||
<t t-slot="requestContent" />
|
||||
</t>
|
||||
</button>
|
||||
</ActionSwiper>
|
||||
</t>
|
||||
|
||||
@@ -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']");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.InstallPrompt" owl="1">
|
||||
<Dialog contentClass="'o_install_prompt position-fixed px-2 py-3 m-2'" size="'md'" footer="false">
|
||||
<t t-set-slot="header">
|
||||
<div class="d-flex w-100">
|
||||
<h4>How to get the application</h4>
|
||||
<div t-on-click="onClose" type="button" class="btn-close" aria-label="Close"/>
|
||||
</div>
|
||||
</t>
|
||||
<p>Install Odoo on your device to access it easily. Here are the steps to follow:</p>
|
||||
<t t-if="isMobileSafari">
|
||||
<ul class="list-group list-group-numbered">
|
||||
<li>
|
||||
Tap on the share icon
|
||||
</li>
|
||||
<li>
|
||||
Select "Add to home screen"
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<ul class="list-group list-group-numbered">
|
||||
<li>
|
||||
Open "File" menu from your browser
|
||||
</li>
|
||||
<li>
|
||||
Select "Add to dock"
|
||||
</li>
|
||||
</ul>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user