[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:
luvi
2023-10-16 10:39:56 +00:00
parent 1c56280382
commit cc0ca70428
10 changed files with 322 additions and 1 deletions
@@ -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);
+3
View File
@@ -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,