From bf2aead36f0963f97bd54842b0a8ab6c91ffb492 Mon Sep 17 00:00:00 2001 From: Pierre Paridans Date: Tue, 14 Jul 2020 14:02:21 +0000 Subject: [PATCH] [IMP] website_event_track_online: add PWA support RATIONALE Events are sometimes held online, gathering a community. In this merge we improve Event application to better support full-online events with improved tracks, wishlists, chat rooms, ... PURPOSE Add PWA support. This will improve adoption and use of our Events application. LINKS Task ID-2252655 (Main Online Event task) Task ID-2283796 (Event B2Basics / Registration Flow) Task ID-1896623 (PWA Support) --- .../controllers/event_track.py | 48 ++++ .../models/__init__.py | 1 + .../models/website.py | 28 +++ .../static/lib/idb-keyval/idb-keyval.js | 81 +++++++ .../static/src/js/event_track_reminder.js | 6 +- .../static/src/js/service_worker.js | 228 ++++++++++++++++++ .../static/src/js/website_event_pwa_widget.js | 132 ++++++++++ .../static/src/scss/pwa_frontend.scss | 29 +++ .../static/src/xml/website_event_pwa.xml | 10 + .../views/assets.xml | 3 + .../views/event_templates_registration.xml | 26 ++ 11 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 addons/website_event_track_online/models/website.py create mode 100644 addons/website_event_track_online/static/lib/idb-keyval/idb-keyval.js create mode 100644 addons/website_event_track_online/static/src/js/service_worker.js create mode 100644 addons/website_event_track_online/static/src/js/website_event_pwa_widget.js create mode 100644 addons/website_event_track_online/static/src/scss/pwa_frontend.scss create mode 100644 addons/website_event_track_online/static/src/xml/website_event_pwa.xml diff --git a/addons/website_event_track_online/controllers/event_track.py b/addons/website_event_track_online/controllers/event_track.py index 9e36151e09d..9b79a5650da 100644 --- a/addons/website_event_track_online/controllers/event_track.py +++ b/addons/website_event_track_online/controllers/event_track.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json + from werkzeug.exceptions import Forbidden, NotFound from datetime import timedelta import pytz @@ -8,6 +10,9 @@ import pytz from odoo import exceptions, http, fields from odoo.addons.website_event_track.controllers.main import WebsiteEventTrackController from odoo.http import request +from odoo.modules.module import get_module_resource +from odoo.tools import ustr +from odoo.tools.translate import _ class EventTrackOnlineController(WebsiteEventTrackController): @@ -225,3 +230,46 @@ class EventTrackOnlineController(WebsiteEventTrackController): result['visitor_uuid'] = visitor_sudo.access_token return result + + @http.route('/event/manifest.webmanifest', type='http', auth='public', methods=['GET'], website=True, sitemap=False) + def webmanifest(self): + """ Returns a WebManifest describing the metadata associated with a web application. + Using this metadata, user agents can provide developers with means to create user + experiences that are more comparable to that of a native application. + """ + company = request.env.company + website = request.website + manifest = { + 'name': _('%s Online Events') % company.name, + 'short_name': company.name, + 'description': _('%s Online Events') % company.name, + 'scope': '/event', + 'start_url': '/event', + 'display': 'standalone', + 'background_color': '#ffffff', + 'theme_color': '#875A7B', + } + icon_sizes = ['192x192', '512x512'] + manifest['icons'] = [{ + 'src': website.image_url(website, 'app_icon', size=size), + 'sizes': size, + 'type': 'image/png', + } for size in icon_sizes] + body = json.dumps(manifest, default=ustr) + response = request.make_response(body, [ + ('Content-Type', 'application/manifest+json'), + ]) + return response + + @http.route('/event/service-worker.js', type='http', auth='public', methods=['GET'], website=True, sitemap=False) + def service_worker(self): + """ Returns a ServiceWorker javascript file scoped for website_event + """ + sw_file = get_module_resource('website_event_track_online', 'static/src/js/service_worker.js') + with open(sw_file, 'rb') as fp: + body = fp.read() + response = request.make_response(body, [ + ('Content-Type', 'text/javascript'), + ('Service-Worker-Allowed', '/event'), + ]) + return response diff --git a/addons/website_event_track_online/models/__init__.py b/addons/website_event_track_online/models/__init__.py index b47218d4ecf..c8add27a1e3 100644 --- a/addons/website_event_track_online/models/__init__.py +++ b/addons/website_event_track_online/models/__init__.py @@ -9,5 +9,6 @@ from . import event_track from . import event_track_stage from . import event_track_tag from . import event_type +from . import website from . import website_event_menu from . import website_visitor diff --git a/addons/website_event_track_online/models/website.py b/addons/website_event_track_online/models/website.py new file mode 100644 index 00000000000..79102ea61f8 --- /dev/null +++ b/addons/website_event_track_online/models/website.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +from PIL import Image + +from odoo import api, fields, models +from odoo.tools import ImageProcess + + +class Website(models.Model): + _inherit = "website" + + app_icon = fields.Image(string='Website App Icon', compute='_compute_app_icon', stored=True, readonly=True, help='This field holds the image used as mobile app icon on the website (PNG format).') + + @api.depends('favicon') + def _compute_app_icon(self): + """ Computes a squared image based on the favicon to be used as mobile webapp icon. + App Icon should be in PNG format and size of at least 512x512. + """ + for website in self: + image = ImageProcess(website.favicon) + w, h = image.image.size + square_size = w if w > h else h + image.crop_resize(square_size, square_size) + image.image = image.image.resize((512, 512)) + image.operationsCount += 1 + website.app_icon = image.image_base64(output_format='PNG') diff --git a/addons/website_event_track_online/static/lib/idb-keyval/idb-keyval.js b/addons/website_event_track_online/static/lib/idb-keyval/idb-keyval.js new file mode 100644 index 00000000000..c08909dc79a --- /dev/null +++ b/addons/website_event_track_online/static/lib/idb-keyval/idb-keyval.js @@ -0,0 +1,81 @@ +// idb-keyval.js 3.2.0 +// https://github.com/jakearchibald/idb-keyval +// Copyright 2016, Jake Archibald +// Licensed under the Apache License, Version 2.0 + +var idbKeyval = (function (exports) { + 'use strict'; + + class Store { + constructor(dbName = 'keyval-store', storeName = 'keyval') { + this.storeName = storeName; + this._dbp = new Promise((resolve, reject) => { + const openreq = indexedDB.open(dbName, 1); + openreq.onerror = () => reject(openreq.error); + openreq.onsuccess = () => resolve(openreq.result); + // First time setup: create an empty object store + openreq.onupgradeneeded = () => { + openreq.result.createObjectStore(storeName); + }; + }); + } + _withIDBStore(type, callback) { + return this._dbp.then(db => new Promise((resolve, reject) => { + const transaction = db.transaction(this.storeName, type); + transaction.oncomplete = () => resolve(); + transaction.onabort = transaction.onerror = () => reject(transaction.error); + callback(transaction.objectStore(this.storeName)); + })); + } + } + let store; + function getDefaultStore() { + if (!store) + store = new Store(); + return store; + } + function get(key, store = getDefaultStore()) { + let req; + return store._withIDBStore('readonly', store => { + req = store.get(key); + }).then(() => req.result); + } + function set(key, value, store = getDefaultStore()) { + return store._withIDBStore('readwrite', store => { + store.put(value, key); + }); + } + function del(key, store = getDefaultStore()) { + return store._withIDBStore('readwrite', store => { + store.delete(key); + }); + } + function clear(store = getDefaultStore()) { + return store._withIDBStore('readwrite', store => { + store.clear(); + }); + } + function keys(store = getDefaultStore()) { + const keys = []; + return store._withIDBStore('readonly', store => { + // This would be store.getAllKeys(), but it isn't supported by Edge or Safari. + // And openKeyCursor isn't supported by Safari. + (store.openKeyCursor || store.openCursor).call(store).onsuccess = function () { + if (!this.result) + return; + keys.push(this.result.key); + this.result.continue(); + }; + }).then(() => keys); + } + + exports.Store = Store; + exports.get = get; + exports.set = set; + exports.del = del; + exports.clear = clear; + exports.keys = keys; + + return exports; + + }({})); diff --git a/addons/website_event_track_online/static/src/js/event_track_reminder.js b/addons/website_event_track_online/static/src/js/event_track_reminder.js index cd0f59a44ef..a4d86dd9452 100644 --- a/addons/website_event_track_online/static/src/js/event_track_reminder.js +++ b/addons/website_event_track_online/static/src/js/event_track_reminder.js @@ -38,11 +38,13 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({ this.reminderOn = $trackLink.data('reminderOn'); } + var reminderOnValue = !this.reminderOn; + this._rpc({ route: '/event/track/toggle_reminder', params: { track_id: $trackLink.data('trackId'), - set_reminder_on: !this.reminderOn + set_reminder_on: reminderOnValue, }, }).then(function (result) { if (result.error && result.error === 'ignored') { @@ -52,7 +54,7 @@ publicWidget.registry.websiteEventTrackReminder = publicWidget.Widget.extend({ message: _.str.sprintf(_t('Talk already in your Favorites')), }); } else { - self.reminderOn = result.reminderOn; + self.reminderOn = reminderOnValue; var reminderText = self.reminderOn ? _t('Reminder On') : _t('Set Reminder'); self.$('.o_wetrack_js_reminder_text').text(reminderText); self._updateDisplay(); diff --git a/addons/website_event_track_online/static/src/js/service_worker.js b/addons/website_event_track_online/static/src/js/service_worker.js new file mode 100644 index 00000000000..21b64b0c8a1 --- /dev/null +++ b/addons/website_event_track_online/static/src/js/service_worker.js @@ -0,0 +1,228 @@ +importScripts("/website_event_track_online/static/lib/idb-keyval/idb-keyval.js"); + +const PREFIX = "odoo-event"; +const SYNCABLE_ROUTES = ["/event/track/toggle_reminder"]; +const CACHABLE_ROUTES = ["/web/webclient/version_info"]; + +const { Store, set, get, del } = idbKeyval; +const pendingRequestsQueueName = `${PREFIX}-pending-requests`; +const cacheName = `${PREFIX}-cache`; +const syncStore = new Store(`${PREFIX}-sync-db`, `${PREFIX}-sync-store`); +const cacheStore = new Store(`${PREFIX}-cache-db`, `${PREFIX}-cache-store`); + +/** + * + * @param {string} url + * @returns {string} + */ +const urlPathname = (url) => new URL(url).pathname; + +/** + * + * @param {Array} whitelist + * @returns {Function} + */ +const canHandleRoutes = (whitelist) => (url) => whitelist.includes(urlPathname(url)); + +/** + * + * @param {Request} request + * @returns {boolean} + */ +const isGET = (request) => request.method === "GET"; + +/** + * + * @returns {Function} + */ +const isSyncableURL = canHandleRoutes(SYNCABLE_ROUTES); + +/** + * + * @returns {Function} + */ +const isCachableURL = canHandleRoutes(CACHABLE_ROUTES); + +/** + * + * @param {Request} req + * @returns {Promise} + */ +const serializeRequest = async (req) => ({ + url: req.url, + method: req.method, + headers: Object.fromEntries(req.headers.entries()), + body: await req.text(), + mode: req.mode, + credentials: req.credentials, + cache: req.cache, + redirect: req.redirect, + referrer: req.referrer, + integrity: req.integrity, +}); + +/** + * + * @param {Object} requestData + * @returns {Request} + */ +const deserializeRequest = (requestData) => { + const { url } = requestData; + delete requestData.url; + return new Request(url, requestData); +}; + +/** + * + * @param {Response} res + * @returns {Promise} + */ +const serializeResponse = async (res) => ({ + body: await res.text(), + status: res.status, + statusText: res.statusText, + headers: Object.fromEntries(res.headers.entries()), +}); + +/** + * + * @param {Object} responseData + * @returns {Response} + */ +const deserializeResponse = (responseData) => { + const { body } = responseData; + delete responseData.body; + return new Response(body, responseData); +}; + +/** + * + * @param {Object} serializedRequest + * @returns {string} + */ +const buildCacheKey = ({ url, body: { method, params } }) => + JSON.stringify({ + url, + method, + params, + }); + +/** + * + * @returns {int} + */ +const uniqueRequestId = () => Math.floor(Math.random() * 1000 * 1000 * 1000); + +/** + * + * @returns {Response} + */ +const buildEmptyResponse = () => new Response(JSON.stringify({ jsonrpc: "2.0", id: uniqueRequestId(), result: {} })); + +/** + * + * @param {Request} request + * @param {Response} response + * @returns {Promise} + */ +const cacheRequest = async (request, response) => { + if (isGET(request)) { + const cache = await caches.open(cacheName); + await cache.put(request, response.clone()); + } else if (isCachableURL(request.url)) { + const serializedResponse = await serializeResponse(response.clone()); + await set(buildCacheKey(serializedRequest), serializedResponse, cacheStore); + } +}; + +/** + * + * @param {Request} request + * @returns {boolean} + */ +const isCachableRequest = (request) => isGET(request) || isCachableURL(request.url); + +/** + * + * @param {Request} request + * @returns {Promise} + */ +const matchCache = async (request) => { + if (isGET(request)) { + const cache = await caches.open(cacheName); + return await cache.match(request); + } + if (isCachableURL(request.url)) { + const serializedRequest = await serializeRequest(requestCopy); + const cachedResponse = await get(buildCacheKey(serializedRequest), cacheStore); + if (cachedResponse) { + return deserializeResponse(cachedResponse); + } + } + return null; +}; + +/** + * + * @param {FetchEvent} param0 + * @returns {Promise} + */ +const processFetchEvent = async ({ request }) => { + const requestCopy = request.clone(); + let response; + try { + response = await fetch(request); + await cacheRequest(request, response); + } catch (requestError) { + if (isCachableRequest(requestCopy)) { + response = await matchCache(requestCopy); + } else if (isSyncableURL(requestCopy.url)) { + const pendingRequests = (await get(pendingRequestsQueueName, syncStore)) || []; + const serializedRequest = await serializeRequest(requestCopy); + await set(pendingRequestsQueueName, [...pendingRequests, serializedRequest], syncStore); + if (self.registration.sync) { + await self.registration.sync.register(pendingRequestsQueueName).catch((err) => { + console.warn("Cannot use BackgroundSync", err); + throw requestError; + }); + } + return buildEmptyResponse(); + } else { + console.warn(`Offline ${requestCopy.method} request currently not supported`, requestCopy); + } + + if (!response) { + throw requestError; + } + } + return response; +}; + +/** + * + * @returns {Promise} + */ +const processPendingRequests = async () => { + const pendingRequests = (await get(pendingRequestsQueueName, syncStore)) || []; + if (!pendingRequests.length) { + console.info("Nothing to sync!"); + return; + } + let pendingRequest; + while ((pendingRequest = pendingRequests.shift())) { + const request = deserializeRequest(pendingRequest); + await fetch(request); + await set(pendingRequestsQueueName, pendingRequests, syncStore); + } +}; + +self.addEventListener("fetch", (event) => { + event.respondWith(processFetchEvent(event)); +}); + +self.addEventListener("sync", (event) => { + console.info(`Syncing pending requests...`); + if (event.tag === pendingRequestsQueueName) { + event.waitUntil(processPendingRequests()); + } +}); diff --git a/addons/website_event_track_online/static/src/js/website_event_pwa_widget.js b/addons/website_event_track_online/static/src/js/website_event_pwa_widget.js new file mode 100644 index 00000000000..f8fffb3f9b1 --- /dev/null +++ b/addons/website_event_track_online/static/src/js/website_event_pwa_widget.js @@ -0,0 +1,132 @@ +odoo.define("website_event_track_online.website_event_pwa_widget", function (require) { + "use strict"; + + var config = require("web.config"); + var publicWidget = require("web.public.widget"); + + var PWAInstallBanner = publicWidget.Widget.extend({ + xmlDependencies: ["/website_event_track_online/static/src/xml/website_event_pwa.xml"], + template: "pwa_install_banner", + events: { + "click .o_btn_install": "_onClickInstall", + }, + + /** + * @private + */ + _onClickInstall: function () { + this.trigger_up("prompt_install"); + }, + }); + + publicWidget.registry.WebsiteEventPWAWidget = publicWidget.Widget.extend({ + selector: "#wrapwrap.event", + custom_events: { + prompt_install: "_onPromptInstall", + }, + + /** + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.beforeInstallPromptHandler = this._onBeforeInstallPrompt.bind(this); + }, + + /** + * + * @override + */ + start: function () { + var superProm = this._super.apply(this, arguments); + window.addEventListener("beforeinstallprompt", this.beforeInstallPromptHandler); + return superProm.then(this._registerServiceWorker); + }, + + /** + * + * @override + */ + destroy: function () { + window.removeEventListener("beforeinstallprompt", this.beforeInstallPromptHandler); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _hideInstallBanner: function () { + this.installBanner ? this.installBanner.destroy() : undefined; + }, + + /** + * + * @private + */ + _registerServiceWorker: function () { + if (!("serviceWorker" in navigator)) { + return; + } + navigator.serviceWorker + .register("/event/service-worker.js", { scope: "/event" }) + .then((registration) => { + console.info("Registration successful, scope is:", registration.scope); + }) + .catch((error) => { + console.error("Service worker registration failed, error:", error); + }); + }, + + /** + * @private + */ + _showInstallBanner: function () { + this.installBanner = new PWAInstallBanner(this); + this.installBanner.appendTo(this.$el); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param ev {Event} + */ + _onBeforeInstallPrompt: function (ev) { + if (!config.device.isMobile) { + return; + } + ev.preventDefault(); + this.deferredPrompt = ev; + this._showInstallBanner(); + }, + + /** + * @private + * @param ev {Event} + */ + _onPromptInstall: function (ev) { + ev.stopPropagation(); + this.deferredPrompt.prompt(); + this._hideInstallBanner(); + this.deferredPrompt.userChoice.then((choiceResult) => { + if (choiceResult.outcome === "accepted") { + console.log("User accepted the install prompt"); + } else { + console.log("User dismissed the install prompt"); + } + }); + }, + }); + + return { + PWAInstallBanner: PWAInstallBanner, + WebsiteEventPWAWidget: publicWidget.registry.WebsiteEventPWAWidget, + }; +}); diff --git a/addons/website_event_track_online/static/src/scss/pwa_frontend.scss b/addons/website_event_track_online/static/src/scss/pwa_frontend.scss new file mode 100644 index 00000000000..f7ce1f5b0af --- /dev/null +++ b/addons/website_event_track_online/static/src/scss/pwa_frontend.scss @@ -0,0 +1,29 @@ +@media all and (display-mode: standalone) { + #wrapwrap.event { + #top, + #bottom { + display: none; + } + } +} + +#wrapwrap.event { + .o_pwa_install_banner { + position: fixed; + display: flex; + bottom: 0; + right: 0; + left: 0; + margin: 0; + justify-content: space-between; + align-items: center; + background-color: rgba(255, 255, 255, 1); + border-top: 1px solid $gray-200; + box-shadow: 0 -0.125rem 0.25rem rgba(0, 0, 0, 0.075); + font-size: 1rem; + + .o_btn_install { + margin-left: auto; + } + } +} diff --git a/addons/website_event_track_online/static/src/xml/website_event_pwa.xml b/addons/website_event_track_online/static/src/xml/website_event_pwa.xml new file mode 100644 index 00000000000..41c75ab3f34 --- /dev/null +++ b/addons/website_event_track_online/static/src/xml/website_event_pwa.xml @@ -0,0 +1,10 @@ + + + +
+ Install Application + +
+
+ +
diff --git a/addons/website_event_track_online/views/assets.xml b/addons/website_event_track_online/views/assets.xml index 6fc02ebf98c..3590b1c8b56 100644 --- a/addons/website_event_track_online/views/assets.xml +++ b/addons/website_event_track_online/views/assets.xml @@ -4,10 +4,13 @@