From 13c04eb18028396014723736bb6e3c4f2442145b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20K=C3=BChn?= Date: Fri, 24 Oct 2025 18:54:11 +0200 Subject: [PATCH 1/2] [IMP] mail, im_livechat: easy local storage field + upgrade Before this commit, having a record field saved in local storage and synced among browser tabs was cumbersome. Roughly this was defined as follow: ```js class Settings extends Record { static new(...args) { const settings = super.new(...args); settings.onStorage = settings.onStorage.bind(settings); browser.addEventListener("storage", onStorage); return settings; } delete() { super.delete(); browser.removeEventListener("storage", onStorage); } onStorage(ev) { if (ev.key === USE_BLUR_KEY) { this.useBlur = ev.newValue === "true"; } } useBlur = fields.Attr(false, { compute: () => browser.localStorage.getItem(USE_BLUR_KEY) === "true", onUpdate: () => this.useBlur ? browser.localStorage.seItem(USE_BLUR_KEY, "true") : browser.localStorage.removeItem(USE_BLUR_KEY), }); } ``` This commit simplifies drastically how to define fields that are saved and synced to local storage: ```js class Settings extends Record { useBlur = fields.Attr(false, { localStorage: true }); } ``` The write in local storage is made only when the field value differs from default: - if value is same as default value, then remove from local storage - if value is different than default value, write current value in local storage This commit also introduces local storage upgrade scripts. Since local storage keys and values are expected to change but we want to keep user preferences, these scripts help upgrade old local storage values to newer ones. To define an upgrade script: ```js import { addUpgrade } from "./upgrade_helpers"; addUpgrade({ version: NEW_VERSION, key: OLD_KEY, upgrade: ({ value: oldValue }) => ({ key: NEW_KEY, value: NEW_VALUE, }), }); ``` This upgrades `OLD_KEY` local storage entry to `NEW_KEY` local storage entry with `NEW_VALUE` with `NEW_VERSION`. `NEW_VERSION` matches the targeted odoo version, as a string, in the format `MAJOR.MINOR`, e.g. "19.1" and "20.0". Upgrades are executed from oldest to newest version. Range is based on version in local storage at bottom and curent odoo version at the top. For example, let's say we have upgrades for 19.1, 19.3 and 20.0: - current odoo version is 19.1 and local storage has no version, then upgrade using 19.1 scripts. - current odoo version is 20.0 and local storage has version 19.2, then upgrade using 19.3 scripts then 20.0 scripts. - current odoo version is 17.0 and local storage has version 20.0, don't upgrade. Part of Task-5003012 --- .../src/core/public_web/upgrade_19_1.js | 6 + .../static/tests/upgrade_19_1.test.js | 45 ++++++ .../static/src/core/common/settings_model.js | 68 +-------- .../static/src/core/common/store_service.js | 2 +- .../src/core/common/upgrade/upgrade_19_1.js | 30 ++++ .../core/common/upgrade/upgrade_helpers.js | 118 ++++++++++++++ .../core/common/upgrade/upgrade_service.js | 19 +++ .../discuss_app/discuss_app_model.js | 72 +-------- .../public_web/discuss_app/upgrade_19_1.js | 26 ++++ .../src/discuss/call/common/call_settings.xml | 2 +- .../discuss_app/discuss_app_category_model.js | 42 +---- addons/mail/static/src/model/make_store.js | 3 - addons/mail/static/src/model/misc.js | 4 + .../mail/static/src/model/model_internal.js | 53 ++++++- addons/mail/static/src/model/record.js | 3 + .../mail/static/src/model/record_internal.js | 38 ++++- addons/mail/static/src/model/store.js | 9 ++ .../mail/static/src/model/store_internal.js | 45 ++++++ .../static/src/utils/common/local_storage.js | 67 ++++++++ .../tests/core/common/upgrade_19_1.test.js | 144 ++++++++++++++++++ addons/mail/static/tests/core/record.test.js | 33 +++- .../discuss/call/call_settings_menu.test.js | 13 +- .../static/tests/discuss_app/discuss.test.js | 22 ++- .../static/tests/discuss_app/sidebar.test.js | 12 +- addons/mail/static/tests/mail_test_helpers.js | 14 +- 25 files changed, 683 insertions(+), 207 deletions(-) create mode 100644 addons/im_livechat/static/src/core/public_web/upgrade_19_1.js create mode 100644 addons/im_livechat/static/tests/upgrade_19_1.test.js create mode 100644 addons/mail/static/src/core/common/upgrade/upgrade_19_1.js create mode 100644 addons/mail/static/src/core/common/upgrade/upgrade_helpers.js create mode 100644 addons/mail/static/src/core/common/upgrade/upgrade_service.js create mode 100644 addons/mail/static/src/core/public_web/discuss_app/upgrade_19_1.js create mode 100644 addons/mail/static/src/utils/common/local_storage.js create mode 100644 addons/mail/static/tests/core/common/upgrade_19_1.test.js diff --git a/addons/im_livechat/static/src/core/public_web/upgrade_19_1.js b/addons/im_livechat/static/src/core/public_web/upgrade_19_1.js new file mode 100644 index 0000000000000..6b7560cc44973 --- /dev/null +++ b/addons/im_livechat/static/src/core/public_web/upgrade_19_1.js @@ -0,0 +1,6 @@ +import { upgrade_19_1 } from "@mail/core/common/upgrade/upgrade_19_1"; + +upgrade_19_1.add("discuss_sidebar_category_folded_im_livechat.category_default", { + key: "DiscussAppCategory,im_livechat.category_default:is_open", + value: false, +}); diff --git a/addons/im_livechat/static/tests/upgrade_19_1.test.js b/addons/im_livechat/static/tests/upgrade_19_1.test.js new file mode 100644 index 0000000000000..a71624c00df8a --- /dev/null +++ b/addons/im_livechat/static/tests/upgrade_19_1.test.js @@ -0,0 +1,45 @@ +import { defineLivechatModels } from "@im_livechat/../tests/livechat_test_helpers"; + +import { DiscussAppCategory } from "@mail/discuss/core/public_web/discuss_app/discuss_app_category_model"; +import { makeRecordFieldLocalId } from "@mail/model/misc"; +import { toRawValue } from "@mail/utils/common/local_storage"; +import { contains, openDiscuss, start, startServer } from "@mail/../tests/mail_test_helpers"; + +import { beforeEach, describe, expect, test } from "@odoo/hoot"; + +import { Command, serverState } from "@web/../tests/web_test_helpers"; + +describe.current.tags("desktop"); +defineLivechatModels(); + +beforeEach(() => { + serverState.serverVersion = [99, 9]; // high version so following upgrades keep good working of feature +}); + +test("category 'Livechat' is folded", async () => { + const pyEnv = await startServer(); + const guestId = pyEnv["mail.guest"].create({ name: "Visitor" }); + pyEnv["discuss.channel"].create({ + channel_member_ids: [ + Command.create({ + livechat_member_type: "agent", + partner_id: serverState.partnerId, + }), + Command.create({ guest_id: guestId, livechat_member_type: "visitor" }), + ], + channel_type: "livechat", + livechat_operator_id: serverState.partnerId, + }); + localStorage.setItem("discuss_sidebar_category_folded_im_livechat.category_default", "true"); + await start(); + await openDiscuss(); + await contains(".o-mail-DiscussSidebarCategory:contains('Livechat') .oi.oi-chevron-right"); + const defaultLivechat_is_open = makeRecordFieldLocalId( + DiscussAppCategory.localId("im_livechat.category_default"), + "is_open" + ); + expect(localStorage.getItem(defaultLivechat_is_open)).toBe(toRawValue(false)); + expect( + localStorage.getItem("discuss_sidebar_category_folded_im_livechat.category_default") + ).toBe(null); +}); diff --git a/addons/mail/static/src/core/common/settings_model.js b/addons/mail/static/src/core/common/settings_model.js index 5cb29f23da3c4..ff72d95de38b1 100644 --- a/addons/mail/static/src/core/common/settings_model.js +++ b/addons/mail/static/src/core/common/settings_model.js @@ -5,18 +5,9 @@ import { fields, Record } from "@mail/model/export"; import { debounce } from "@web/core/utils/timing"; import { rpc } from "@web/core/network/rpc"; -const MESSAGE_SOUND = "mail.user_setting.message_sound"; - export class Settings extends Record { id; - static new() { - const record = super.new(...arguments); - record.onStorage = record.onStorage.bind(record); - browser.addEventListener("storage", record.onStorage); - return record; - } - setup() { super.setup(); this.saveVoiceThresholdDebounce = debounce(() => { @@ -30,11 +21,6 @@ export class Settings extends Record { this._loadLocalSettings(); } - delete() { - browser.removeEventListener("storage", this.onStorage); - super.delete(...arguments); - } - // Notification settings /** * @type {"mentions"|"all"|"no_notif"} @@ -44,33 +30,8 @@ export class Settings extends Record { return this.channel_notifications === false ? "mentions" : this.channel_notifications; }, }); - messageSound = fields.Attr(true, { - compute() { - return browser.localStorage.getItem(MESSAGE_SOUND) !== "false"; - }, - /** @this {import("models").Settings} */ - onUpdate() { - if (this.messageSound) { - browser.localStorage.removeItem(MESSAGE_SOUND); - } else { - browser.localStorage.setItem(MESSAGE_SOUND, "false"); - } - }, - }); - useCallAutoFocus = fields.Attr(true, { - /** @this {import("models").Settings} */ - compute() { - return !browser.localStorage.getItem("mail_user_setting_disable_call_auto_focus"); - }, - /** @this {import("models").Settings} */ - onUpdate() { - if (this.useCallAutoFocus) { - browser.localStorage.removeItem("mail_user_setting_disable_call_auto_focus"); - return; - } - browser.localStorage.setItem("mail_user_setting_disable_call_auto_focus", "true"); - }, - }); + messageSound = fields.Attr(true, { localStorage: true }); + useCallAutoFocus = fields.Attr(true, { localStorage: true }); // Voice settings // DeviceId of the audio input selected by the user @@ -91,19 +52,7 @@ export class Settings extends Record { backgroundBlurAmount = 10; edgeBlurAmount = 10; showOnlyVideo = false; - useBlur = fields.Attr(false, { - compute() { - return browser.localStorage.getItem("mail_user_setting_use_blur") === "true"; - }, - /** @this {import("models").Settings} */ - onUpdate() { - if (this.useBlur) { - browser.localStorage.setItem("mail_user_setting_use_blur", "true"); - } else { - browser.localStorage.removeItem("mail_user_setting_use_blur"); - } - }, - }); + useBlur = fields.Attr(false, { localStorage: true }); blurPerformanceWarning = fields.Attr(false, { compute() { const rtc = this.store.rtc; @@ -388,9 +337,6 @@ export class Settings extends Record { this.backgroundBlurAmount = backgroundBlurAmount ? parseInt(backgroundBlurAmount) : 10; const edgeBlurAmount = browser.localStorage.getItem("mail_user_setting_edge_blur_amount"); this.edgeBlurAmount = edgeBlurAmount ? parseInt(edgeBlurAmount) : 10; - this.useCallAutoFocus = !browser.localStorage.getItem( - "mail_user_setting_disable_call_auto_focus" - ); } /** * @private @@ -425,14 +371,6 @@ export class Settings extends Record { { guest_id: guestId } ); } - onStorage(ev) { - if (ev.key === MESSAGE_SOUND) { - this.messageSound = ev.newValue !== "false"; - } - if (ev.key === "mail_user_setting_use_blur") { - this.useBlur = ev.newValue === "true"; - } - } /** * @private */ diff --git a/addons/mail/static/src/core/common/store_service.js b/addons/mail/static/src/core/common/store_service.js index 5caca0ca753b3..772521f246fd0 100644 --- a/addons/mail/static/src/core/common/store_service.js +++ b/addons/mail/static/src/core/common/store_service.js @@ -730,7 +730,7 @@ export class Store extends BaseStore { Store.register(); export const storeService = { - dependencies: ["bus_service", "im_status", "ui", "popover"], + dependencies: ["bus_service", "im_status", "ui", "popover", "discuss.upgrade"], /** * @param {import("@web/env").OdooEnv} env * @param {import("services").ServiceFactories} services diff --git a/addons/mail/static/src/core/common/upgrade/upgrade_19_1.js b/addons/mail/static/src/core/common/upgrade/upgrade_19_1.js new file mode 100644 index 0000000000000..d5134d7bc850e --- /dev/null +++ b/addons/mail/static/src/core/common/upgrade/upgrade_19_1.js @@ -0,0 +1,30 @@ +import { addUpgrade } from "@mail/core/common/upgrade/upgrade_helpers"; + +/** @typedef {import("@mail/core/common/upgrade/upgrade_helpers").UpgradeFnParam} UpgradeFnParam */ +/** @typedef {import("@mail/core/common/upgrade/upgrade_helpers").UpgradeFnReturn} UpgradeFnReturn */ + +export const upgrade_19_1 = { + /** + * @param {string} key + * @param {((param: UpgradeFnParam) => UpgradeFnReturn)|UpgradeFnReturn} upgrade + * @returns {UpgradeFnReturn} + */ + add(key, upgrade) { + return addUpgrade({ key, version: "19.1", upgrade }); + }, +}; + +upgrade_19_1.add("mail.user_setting.message_sound", { + key: "Settings,undefined:messageSound", + value: false, +}); + +upgrade_19_1.add("mail_user_setting_disable_call_auto_focus", { + key: "Settings,undefined:useCallAutoFocus", + value: false, +}); + +upgrade_19_1.add("mail_user_setting_use_blur", { + key: "Settings,undefined:useBlur", + value: true, +}); diff --git a/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js b/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js new file mode 100644 index 0000000000000..2328b2a8c7727 --- /dev/null +++ b/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js @@ -0,0 +1,118 @@ +import { getCurrentLocalStorageVersion, LocalStorageEntry } from "@mail/utils/common/local_storage"; +import { parseVersion } from "@mail/utils/common/misc"; +import { registry } from "@web/core/registry"; + +/** + * @typedef {Object} UpgradeData + * @property {string} version + * @property {string} key + * @property {((param: UpgradeFnParam) => UpgradeFnReturn)|UpgradeFnReturn} upgrade + */ +/** + * @typedef {Object} UpgradeDataWithoutVersion + * @property {string} key + * @property {((param: UpgradeFnParam) => UpgradeFnReturn)|UpgradeFnReturn} upgrade + */ +/** + * @typedef {Object} UpgradeFnParam + * @property {string} key + * @property {any} value + */ +/** + * @typedef {Object} UpgradeFnReturn + * @property {string} key + * @property {any} value + */ + +/** + * Register a `key` and `upgrade` function for a `version` of local storage. + * + * When there's request to upgrade a local storage + * + * Example: + * - we have versions 19.1, 19.3, and 20.0. + * - define upgrade function with 19.1 is to upgrade to 19.1 + * - define ugrade function with 20.0 is to upgrade to 20.0 + * + * Upgrades are applied in sequence, i.e.: + * - if version is 20.0 and local storage data are in version 0, this will apply upgrade to 19.1, then to 19.3 and finally to 20.0. + * - if version is 20.0 and local storage data are in version 19.1, this will apply upgrade to 19.3 and finally to 20.0. + * - if version is 20.0 and local storage data are in version 19.2, this will apply upgrade to 19.3 and finally to 20.0. + * - if version is 20.0 and local storage data are in version 20.0, this will not upgrade data. + * + * @param {UpgradeData} param0 + */ +export function addUpgrade({ version, key, upgrade }) { + /** @type {Map} */ + const map = getUpgradeMap(); + if (!map.has(version)) { + map.set(version, new Map()); + } + map.get(version).set(key, { version, key, upgrade }); +} + +/** @param {string} version */ +export function upgradeFrom(version) { + const orderedUpgradeList = Array.from(getUpgradeMap().entries()) + .filter( + ([v]) => + !parseVersion(v).isLowerThan(version) && + !parseVersion(getCurrentLocalStorageVersion()).isLowerThan(v) + ) + .sort(([v1], [v2]) => (parseVersion(v1).isLowerThan(v2) ? -1 : 1)); + for (const [, keyMap] of orderedUpgradeList) { + for (const upgradeData of keyMap.values()) { + applyUpgrade(upgradeData); + } + } +} + +const upgradeRegistry = registry.category("discuss.upgrade"); +upgradeRegistry.add(null, new Map()); + +/** + * A Map of version numbers to a Map of keys and upgrade functions. + * Basically: + * + * Map: { + * "19.1": { + * key_1: upgradeData_1, + * key_2: upgradeData_2, + * }, + * "19.2": { + * key_3: upgradeData_3, + * key_4: upgradeData_4, + * ... + * }, + * ... + * } + * + * To upgrade a key in a given version, find the key in version + * and applyUpgrade using upgradeData to upgrade with new key, value and version. + * + * @return {Map>} + */ +function getUpgradeMap() { + return upgradeRegistry.get(null); +} + +/** + * Upgrade local storage using `upgrade` data. + * i.e. call `upgradeData.upgrade` to get new key and value. + * + * @param {UpgradeData} upgradeData + */ +function applyUpgrade(upgradeData) { + const oldEntry = new LocalStorageEntry(upgradeData.key); + const oldValue = oldEntry.rawGet() ?? oldEntry.get(); + if (oldValue === undefined) { + return; // could not upgrade (cannot parse or more recent version) + } + const { key, value } = + typeof upgradeData.upgrade === "function" + ? upgradeData.upgrade({ key: upgradeData.key, value: oldValue }) + : upgradeData.upgrade; + oldEntry.remove(); + const newEntry = new LocalStorageEntry(key); + newEntry.set(value); +} diff --git a/addons/mail/static/src/core/common/upgrade/upgrade_service.js b/addons/mail/static/src/core/common/upgrade/upgrade_service.js new file mode 100644 index 0000000000000..07b1f58735b1c --- /dev/null +++ b/addons/mail/static/src/core/common/upgrade/upgrade_service.js @@ -0,0 +1,19 @@ +import { registry } from "@web/core/registry"; +import { upgradeFrom } from "./upgrade_helpers"; +import { getCurrentLocalStorageVersion, LocalStorageEntry } from "@mail/utils/common/local_storage"; +import { parseVersion } from "@mail/utils/common/misc"; + +export const discussUpgradeService = { + dependencies: [], + start() { + const lse = new LocalStorageEntry("discuss.upgrade.version"); + const oldVersion = lse.get() ?? "1.0"; + const currentVersion = getCurrentLocalStorageVersion(); + lse.set(currentVersion); + if (parseVersion(oldVersion).isLowerThan(currentVersion)) { + upgradeFrom(oldVersion); + } + }, +}; + +registry.category("services").add("discuss.upgrade", discussUpgradeService); diff --git a/addons/mail/static/src/core/public_web/discuss_app/discuss_app_model.js b/addons/mail/static/src/core/public_web/discuss_app/discuss_app_model.js index 637fa17329106..79efb4f591c8b 100644 --- a/addons/mail/static/src/core/public_web/discuss_app/discuss_app_model.js +++ b/addons/mail/static/src/core/public_web/discuss_app/discuss_app_model.js @@ -1,9 +1,4 @@ import { fields, Record } from "@mail/model/export"; -import { browser } from "@web/core/browser/browser"; - -const NO_MEMBERS_DEFAULT_OPEN_LS = "mail.user_setting.no_members_default_open"; -export const DISCUSS_SIDEBAR_COMPACT_LS = "mail.user_setting.discuss_sidebar_compact"; -export const LAST_DISCUSS_ACTIVE_ID_LS = "mail.user_setting.discuss_last_active_id"; export class DiscussApp extends Record { INSPECTOR_WIDTH = 300; @@ -12,49 +7,9 @@ export class DiscussApp extends Record { activeTab = "notification"; searchTerm = ""; isActive = false; - isMemberPanelOpenByDefault = fields.Attr(true, { - compute() { - return browser.localStorage.getItem(NO_MEMBERS_DEFAULT_OPEN_LS) !== "true"; - }, - /** @this {import("models").DiscussApp} */ - onUpdate() { - if (this.isMemberPanelOpenByDefault) { - browser.localStorage.removeItem(NO_MEMBERS_DEFAULT_OPEN_LS); - } else { - browser.localStorage.setItem(NO_MEMBERS_DEFAULT_OPEN_LS, "true"); - } - }, - }); - isSidebarCompact = fields.Attr(false, { - compute() { - return browser.localStorage.getItem(DISCUSS_SIDEBAR_COMPACT_LS) === "true"; - }, - /** @this {import("models").DiscussApp} */ - onUpdate() { - if (this.isSidebarCompact) { - browser.localStorage.setItem( - DISCUSS_SIDEBAR_COMPACT_LS, - this.isSidebarCompact.toString() - ); - } else { - browser.localStorage.removeItem(DISCUSS_SIDEBAR_COMPACT_LS); - } - }, - }); - lastActiveId = fields.Attr(undefined, { - /** @this {import("models").DiscussApp} */ - compute() { - return browser.localStorage.getItem(LAST_DISCUSS_ACTIVE_ID_LS) ?? undefined; - }, - /** @this {import("models").DiscussApp} */ - onUpdate() { - if (this.lastActiveId) { - browser.localStorage.setItem(LAST_DISCUSS_ACTIVE_ID_LS, this.lastActiveId); - } else { - browser.localStorage.removeItem(LAST_DISCUSS_ACTIVE_ID_LS); - } - }, - }); + isMemberPanelOpenByDefault = fields.Attr(true, { localStorage: true }); + isSidebarCompact = fields.Attr(false, { localStorage: true }); + lastActiveId = fields.Attr(undefined, { localStorage: true }); thread = fields.One("mail.thread", { /** @this {import("models").DiscussApp} */ onUpdate() { @@ -62,27 +17,6 @@ export class DiscussApp extends Record { }, }); hasRestoredThread = false; - - static new() { - const record = super.new(...arguments); - record.onStorage = record.onStorage.bind(record); - browser.addEventListener("storage", record.onStorage); - return record; - } - - delete() { - browser.removeEventListener("storage", this.onStorage); - super.delete(...arguments); - } - - onStorage(ev) { - if (ev.key === DISCUSS_SIDEBAR_COMPACT_LS) { - this.isSidebarCompact = ev.newValue === "true"; - } - if (ev.key === NO_MEMBERS_DEFAULT_OPEN_LS) { - this.isMemberPanelOpenByDefault = ev.newValue !== "true"; - } - } } DiscussApp.register(); diff --git a/addons/mail/static/src/core/public_web/discuss_app/upgrade_19_1.js b/addons/mail/static/src/core/public_web/discuss_app/upgrade_19_1.js new file mode 100644 index 0000000000000..26dd8d5fc2d53 --- /dev/null +++ b/addons/mail/static/src/core/public_web/discuss_app/upgrade_19_1.js @@ -0,0 +1,26 @@ +import { upgrade_19_1 } from "@mail/core/common/upgrade/upgrade_19_1"; + +upgrade_19_1.add("mail.user_setting.no_members_default_open", { + key: "DiscussApp,undefined:isMemberPanelOpenByDefault", + value: false, +}); + +upgrade_19_1.add("mail.user_setting.discuss_sidebar_compact", { + key: "DiscussApp,undefined:isSidebarCompact", + value: true, +}); + +upgrade_19_1.add("mail.user_setting.discuss_last_active_id", ({ value }) => ({ + key: "DiscussApp,undefined:lastActiveId", + value, +})); + +upgrade_19_1.add("discuss_sidebar_category_folded_channels", { + key: "DiscussAppCategory,channels:is_open", + value: false, +}); + +upgrade_19_1.add("discuss_sidebar_category_folded_chats", { + key: "DiscussAppCategory,chats:is_open", + value: false, +}); diff --git a/addons/mail/static/src/discuss/call/common/call_settings.xml b/addons/mail/static/src/discuss/call/common/call_settings.xml index 1477cb8a168d6..ab455181520c8 100644 --- a/addons/mail/static/src/discuss/call/common/call_settings.xml +++ b/addons/mail/static/src/discuss/call/common/call_settings.xml @@ -99,7 +99,7 @@ -
+
Blur video background diff --git a/addons/mail/static/src/discuss/core/public_web/discuss_app/discuss_app_category_model.js b/addons/mail/static/src/discuss/core/public_web/discuss_app/discuss_app_category_model.js index eb7a2a6a2be43..b136edc138e1f 100644 --- a/addons/mail/static/src/discuss/core/public_web/discuss_app/discuss_app_category_model.js +++ b/addons/mail/static/src/discuss/core/public_web/discuss_app/discuss_app_category_model.js @@ -1,30 +1,9 @@ import { compareDatetime } from "@mail/utils/common/misc"; import { fields, Record } from "@mail/model/export"; -import { browser } from "@web/core/browser/browser"; - -export const DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS = "discuss_sidebar_category_folded_"; export class DiscussAppCategory extends Record { static id = "id"; - static new() { - const record = super.new(...arguments); - record.onStorage = record.onStorage.bind(record); - browser.addEventListener("storage", record.onStorage); - return record; - } - - delete() { - browser.removeEventListener("storage", this.onStorage); - super.delete(...arguments); - } - - onStorage(ev) { - if (ev.key === `${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${this.id}`) { - this.is_open = ev.newValue !== "true"; - } - } - /** * @param {import("models").Thread} t1 * @param {import("models").Thread} t2 @@ -70,26 +49,7 @@ export class DiscussAppCategory extends Record { /** @type {number} */ sequence; - is_open = fields.Attr(false, { - /** @this {import("models").DiscussApp} */ - compute() { - return !( - browser.localStorage.getItem(`${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${this.id}`) ?? - false - ); - }, - /** @this {import("models").DiscussApp} */ - onUpdate() { - if (!this.is_open) { - browser.localStorage.setItem( - `${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${this.id}`, - true - ); - } else { - browser.localStorage.removeItem(`${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${this.id}`); - } - }, - }); + is_open = fields.Attr(true, { localStorage: true }); threads = fields.Many("mail.thread", { sort(t1, t2) { diff --git a/addons/mail/static/src/model/make_store.js b/addons/mail/static/src/model/make_store.js index 5b7bdb40b9672..67b687e101376 100644 --- a/addons/mail/static/src/model/make_store.js +++ b/addons/mail/static/src/model/make_store.js @@ -166,9 +166,6 @@ export function makeStore(env, { localRegistry } = {}) { store = record; Record.store = store; } - for (const name of Model._.fields.keys()) { - record._.prepareField(record, name, recordProxy); - } return recordProxy; } }, diff --git a/addons/mail/static/src/model/misc.js b/addons/mail/static/src/model/misc.js index 2a54b90af810e..b64b7bb5903c1 100644 --- a/addons/mail/static/src/model/misc.js +++ b/addons/mail/static/src/model/misc.js @@ -203,4 +203,8 @@ export const fields = { }, }; +export function makeRecordFieldLocalId(recordLocalId, fieldName) { + return `${recordLocalId}:${fieldName}`; +} + export const technicalKeysOnRecords = ["_", "_proxy", "_proxyInternal", "_raw", "env", "Model"]; diff --git a/addons/mail/static/src/model/model_internal.js b/addons/mail/static/src/model/model_internal.js index 77cc814aa3f81..8138e156b8ee8 100644 --- a/addons/mail/static/src/model/model_internal.js +++ b/addons/mail/static/src/model/model_internal.js @@ -1,3 +1,4 @@ +import { toRaw } from "@odoo/owl"; import { ATTR_SYM, MANY_SYM, ONE_SYM } from "./misc"; export class ModelInternal { @@ -13,7 +14,7 @@ export class ModelInternal { fieldsHtml = new Map(); /** @type {Map} */ fieldsTargetModel = new Map(); - /** @type {Map any>} */ + /** @type {Map Function[]>} */ fieldsCompute = new Map(); /** @type {Map} */ fieldsEager = new Map(); @@ -23,12 +24,14 @@ export class ModelInternal { fieldsOnAdd = new Map(); /** @type {Map void>} */ fieldsOnDelete = new Map(); - /** @type {Map void>} */ + /** @type {Map void>>} */ fieldsOnUpdate = new Map(); /** @type {Map number>} */ fieldsSort = new Map(); /** @type {Map} */ fieldsType = new Map(); + /** @type {Set} */ + fieldsLocalStorage = new Set(); /** * Set of field names on the current model that are _inherits fields. * @@ -59,6 +62,42 @@ export class ModelInternal { if (data[MANY_SYM]) { this.fieldsMany.set(fieldName, true); } + if (data.localStorage) { + if (data.compute) { + throw new Error( + `The field "${fieldName}" cannot have "localStorage" and "compute" at the same time. "localStorage" is implicitly a computed field` + ); + } + this.fieldsLocalStorage.add(fieldName); + this.fieldsCompute.set( + fieldName, + /** @this {import("./record").Record}*/ + function fieldLocalStorageCompute() { + const record = toRaw(this)._raw; + const lse = record._.fieldsLocalStorage.get(fieldName); + const value = lse.get(); + if (value === undefined) { + lse.remove(); + return this[fieldName]; + } + return value; + } + ); + + this.registerOnUpdate( + fieldName, + /** @this {import("./record").Record}*/ + function fieldLocalStorageOnChange(value) { + const record = toRaw(this)._raw; + const lse = record._.fieldsLocalStorage.get(fieldName); + if (value === record._.fieldsDefault.get(fieldName)) { + lse.remove(); + } else { + lse.set(value); + } + } + ); + } for (const key in data) { const value = data[key]; switch (key) { @@ -101,7 +140,7 @@ export class ModelInternal { break; } case "onUpdate": { - this.fieldsOnUpdate.set(fieldName, value); + this.registerOnUpdate(fieldName, value); break; } case "type": { @@ -111,4 +150,12 @@ export class ModelInternal { } } } + registerOnUpdate(fieldName, onUpdate) { + let onUpdateList = this.fieldsOnUpdate.get(fieldName); + if (!onUpdateList) { + onUpdateList = []; + this.fieldsOnUpdate.set(fieldName, onUpdateList); + } + onUpdateList.push(onUpdate); + } } diff --git a/addons/mail/static/src/model/record.js b/addons/mail/static/src/model/record.js index f53437153ee87..28da984369ffb 100644 --- a/addons/mail/static/src/model/record.js +++ b/addons/mail/static/src/model/record.js @@ -192,6 +192,9 @@ export class Record { const recordProxy = new Model.Class(); const record = toRaw(recordProxy)._raw; Object.assign(record._, { localId: Model.localId(ids) }); + for (const name of Model._.fields.keys()) { + record._.prepareField(record, name, recordProxy); + } Object.assign(recordProxy, { ...ids }); Model.records[record.localId] = recordProxy; if (record.Model.getName() === "Store") { diff --git a/addons/mail/static/src/model/record_internal.js b/addons/mail/static/src/model/record_internal.js index cbb43231e1d3a..f43b3892ba81e 100644 --- a/addons/mail/static/src/model/record_internal.js +++ b/addons/mail/static/src/model/record_internal.js @@ -2,10 +2,17 @@ /** @typedef {import("./record_list").RecordList} RecordList */ import { onChange } from "@mail/utils/common/misc"; -import { IS_DELETED_SYM, IS_RECORD_SYM, isFieldDefinition, isRelation } from "./misc"; +import { + IS_DELETED_SYM, + IS_RECORD_SYM, + isFieldDefinition, + isRelation, + makeRecordFieldLocalId, +} from "./misc"; import { RecordList } from "./record_list"; import { reactive, toRaw } from "@odoo/owl"; import { RecordUses } from "./record_uses"; +import { LocalStorageEntry } from "@mail/utils/common/local_storage"; export class RecordInternal { [IS_RECORD_SYM] = true; @@ -53,16 +60,26 @@ export class RecordInternal { fieldsComputeOnNeed = new Map(); /** @type {Map void>} */ fieldsOnUpdateObserves = new Map(); - /** @type {Map} */ + /** @type {Map} */ fieldsSortProxy2 = new Map(); - /** @type {Map} */ + /** @type {Map} */ fieldsComputeProxy2 = new Map(); + /** @type {Map} */ + fieldsDefault = new Map(); uses = new RecordUses(); updatingAttrs = new Map(); proxyUsed = new Map(); /** @type {string} */ localId; gettingField = false; + /** + * For fields that use local storage, this map contains the "ls" object that eases interactions on the related + * local storage entry. For instance, instead of having to write `browser.localStorage.setItem(EXACT_LOCAL_STORAGE_ENTRY_OF_FIELD, value)`, + * this "ls" object allow to just write the equivalent expression with `ls.set(value)` + * + * @type {Map} + */ + fieldsLocalStorage = new Map(); /** * @param {Record} record @@ -93,6 +110,17 @@ export class RecordInternal { ? record[fieldName].default : record[fieldName]; } + this.fieldsDefault.set(fieldName, record[fieldName]); + // register local storage fields + for (const lsFieldName of Model._.fieldsLocalStorage) { + const { localStorageKeyToRecordFields } = record.store._; + const localStorageKey = makeRecordFieldLocalId(record.localId, lsFieldName); + if (!localStorageKeyToRecordFields.has(localStorageKey)) { + localStorageKeyToRecordFields.set(localStorageKey, new Map()); + } + localStorageKeyToRecordFields.get(localStorageKey).set(record, lsFieldName); + this.fieldsLocalStorage.set(lsFieldName, new LocalStorageEntry(localStorageKey)); + } if (Model._.fieldsCompute.get(fieldName)) { if (!Model._.fieldsEager.get(fieldName)) { onChange(recordProxy, fieldName, () => { @@ -256,7 +284,9 @@ export class RecordInternal { * need reactive (observe is called separately). */ try { - Model._.fieldsOnUpdate.get(fieldName).call(record._proxyInternal); + Model._.fieldsOnUpdate + .get(fieldName) + .forEach((fn) => fn.call(record._proxyInternal, record._proxyInternal[fieldName])); } catch (err) { store.handleError(err); } diff --git a/addons/mail/static/src/model/store.js b/addons/mail/static/src/model/store.js index f699606824cfd..f052613ad710e 100644 --- a/addons/mail/static/src/model/store.js +++ b/addons/mail/static/src/model/store.js @@ -161,6 +161,15 @@ export class Store extends Record { } } } + for (const lsFieldName of record.Model._.fieldsLocalStorage) { + const { localStorageKeyToRecordFields } = record.store._; + const key = record._.fieldsLocalStorage.get(lsFieldName).key; + const lsKeyMap = localStorageKeyToRecordFields.get(key); + lsKeyMap.delete(record); + if (lsKeyMap.size === 0) { + localStorageKeyToRecordFields.delete(key); + } + } deletingRecordsByLocalId.set(record.localId, record); this.recordByLocalId.delete(record.localId); this._.ADD_QUEUE("hard_delete", toRaw(record)); diff --git a/addons/mail/static/src/model/store_internal.js b/addons/mail/static/src/model/store_internal.js index 5f5ca6ca1f4e5..3f7bd3313c6b5 100644 --- a/addons/mail/static/src/model/store_internal.js +++ b/addons/mail/static/src/model/store_internal.js @@ -5,9 +5,14 @@ import { htmlEscape, markup, toRaw } from "@odoo/owl"; import { RecordInternal } from "./record_internal"; import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates"; import { IS_DELETED_SYM, isCommand, isMany } from "./misc"; +import { browser } from "@web/core/browser/browser"; +import { parseRawValue } from "@mail/utils/common/local_storage"; const Markup = markup().constructor; +/** @typedef {string} LocalStorageKey */ +/** @typedef {string} FieldName */ + export class StoreInternal extends RecordInternal { /** @type {Map>} */ FC_QUEUE = new Map(); // field-computes @@ -27,6 +32,37 @@ export class StoreInternal extends RecordInternal { RHD_QUEUE = new Map(); // record-hard-deletes ERRORS = []; UPDATE = 0; + /** + * Map of local storage keys of fields synced with local storage to the record and field name. + * + * @type {Map>} + */ + localStorageKeyToRecordFields = new Map(); + + constructor() { + super(...arguments); + this.onStorage = this.onStorage.bind(this); + browser.addEventListener("storage", this.onStorage); + } + + onStorage(ev) { + const entryMap = this.localStorageKeyToRecordFields.get(ev.key); + if (!entryMap) { + return; + } + for (const [record, fieldName] of entryMap.entries()) { + if (ev.newValue === null) { + record._proxy[fieldName] = record._.fieldsDefault.get(fieldName); + } else { + const parsed = parseRawValue(ev.newValue); + if (!parsed) { + record._proxy[fieldName] = record._.fieldsDefault.get(fieldName); + } else { + record._proxy[fieldName] = parsed.value; + } + } + } + } /** * @param {"compute"|"sort"|"onAdd"|"onDelete"|"onUpdate"|"hard_delete"} type @@ -194,6 +230,15 @@ export class StoreInternal extends RecordInternal { */ updateFields(record, vals) { for (const [fieldName, value] of Object.entries(vals)) { + if (record.Model._.fieldsLocalStorage.has(fieldName)) { + // should immediately write in local storage, for immediately correct next compute + const lse = record._.fieldsLocalStorage.get(fieldName); + if (value === record._.fieldsDefault.get(fieldName)) { + lse.remove(); + } else { + lse.set(value); + } + } if (!record.Model._.fields.get(fieldName) || record.Model._.fieldsAttr.get(fieldName)) { this.updateAttr(record, fieldName, value); } else { diff --git a/addons/mail/static/src/utils/common/local_storage.js b/addons/mail/static/src/utils/common/local_storage.js new file mode 100644 index 0000000000000..79775cae590d8 --- /dev/null +++ b/addons/mail/static/src/utils/common/local_storage.js @@ -0,0 +1,67 @@ +import { browser } from "@web/core/browser/browser"; +import { session } from "@web/session"; + +const LOCAL_STORAGE_SUBVERSION = 0; + +/** + * @typedef {Object} VersionedValue + * @property {any} value + * @property {string} version + */ + +export function getCurrentLocalStorageVersion() { + const [major, minor] = session.server_version_info ?? "1.0"; + return [major, minor, LOCAL_STORAGE_SUBVERSION].join("."); +} + +/** + * Utility class to simplify interaction on local storage with constant local storage key and with versioning. + * When a value is set, this is done as `{ value, version }`. + * Note: The object syntax is necessary to properly handle types, like "false" vs false. + */ +export class LocalStorageEntry { + /** @type {string} */ + key; + constructor(key) { + this.key = key; + } + get() { + const rawValue = this.rawGet(); + if (rawValue === null) { + return undefined; + } + return parseRawValue(rawValue)?.value; + } + set(value) { + const oldValue = this.get(); + if (oldValue !== undefined && oldValue === value) { + return; + } + browser.localStorage.setItem(this.key, toRawValue(value)); + } + rawGet() { + return browser.localStorage.getItem(this.key); + } + remove() { + if (this.rawGet() === null) { + return; + } + browser.localStorage.removeItem(this.key); + } +} + +export function toRawValue(value) { + return JSON.stringify({ value }); +} + +/** + * @param {string} rawValue + * @returns {VersionedValue} + */ +export function parseRawValue(rawValue) { + try { + return JSON.parse(rawValue); + } catch { + // noop + } +} diff --git a/addons/mail/static/tests/core/common/upgrade_19_1.test.js b/addons/mail/static/tests/core/common/upgrade_19_1.test.js new file mode 100644 index 0000000000000..382089b2fbed4 --- /dev/null +++ b/addons/mail/static/tests/core/common/upgrade_19_1.test.js @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { + click, + contains, + defineMailModels, + mockGetMedia, + openDiscuss, + patchUiSize, + SIZES, + start, + startServer, +} from "@mail/../tests/mail_test_helpers"; +import { makeRecordFieldLocalId } from "@mail/model/misc"; +import { toRawValue } from "@mail/utils/common/local_storage"; +import { Settings } from "@mail/core/common/settings_model"; +import { DiscussApp } from "@mail/core/public_web/discuss_app/discuss_app_model"; +import { DiscussAppCategory } from "@mail/discuss/core/public_web/discuss_app/discuss_app_category_model"; +import { getService, serverState } from "@web/../tests/web_test_helpers"; + +describe.current.tags("desktop"); +defineMailModels(); + +beforeEach(() => { + serverState.serverVersion = [99, 9]; // high version so following upgrades keep good working of feature +}); + +test("message sound is 'off'", async () => { + localStorage.setItem("mail.user_setting.message_sound", "false"); + await start(); + getService("action").doAction({ + tag: "mail.discuss_notification_settings_action", + type: "ir.actions.client", + }); + await contains("label:has(h5:contains('Message sound')) input:not(:checked)"); + const messageSoundKey = makeRecordFieldLocalId(Settings.localId(), "messageSound"); + expect(localStorage.getItem(messageSoundKey)).toBe(toRawValue(false)); + expect(localStorage.getItem("mail.user_setting.message_sound")).toBe(null); +}); + +test("use blur is 'on'", async () => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "test" }); + localStorage.setItem("mail_user_setting_use_blur", "true"); + patchUiSize({ size: SIZES.SM }); + await start(); + await openDiscuss(channelId); + // dropdown requires an extra delay before click (because handler is registered in useEffect) + await contains("[title='Open Actions Menu']"); + await click("[title='Open Actions Menu']"); + await click(".o-dropdown-item", { text: "Call Settings" }); + await contains(".o-discuss-CallSettings"); + await contains( + ".o-discuss-CallSettings-item:has(label:contains('Blur video background')) input:checked" + ); + const useBlurKey = makeRecordFieldLocalId(Settings.localId(), "useBlur"); + expect(localStorage.getItem(useBlurKey)).toBe(toRawValue(true)); + expect(localStorage.getItem("mail_user_setting_use_blur")).toBe(null); +}); + +test("member default open is 'off'", async () => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "test" }); + localStorage.setItem("mail.user_setting.no_members_default_open", "true"); + await start(); + await openDiscuss(channelId); + await contains(".o-mail-Thread:contains('Welcome to #test')"); + await contains(".o-mail-ActionList-button[title='Members']"); + await contains(".o-mail-ActionList-button[title='Members']:not(.active)"); + const isMemberPanelOpenByDefaultKey = makeRecordFieldLocalId( + DiscussApp.localId(), + "isMemberPanelOpenByDefault" + ); + expect(localStorage.getItem(isMemberPanelOpenByDefaultKey)).toBe(toRawValue(false)); + expect(localStorage.getItem("mail.user_setting.no_members_default_open")).toBe(null); + await click(".o-mail-ActionList-button[title='Members']"); + await contains(".o-mail-ActionList-button[title='Members'].active"); // just to validate .active is correct selector + expect(localStorage.getItem(isMemberPanelOpenByDefaultKey)).toBe(null); +}); + +test("sidebar compact is 'on'", async () => { + localStorage.setItem("mail.user_setting.discuss_sidebar_compact", "true"); + await start(); + await openDiscuss(); + await contains(".o-mail-DiscussSidebar.o-compact"); + const isSidebarCompact = makeRecordFieldLocalId(DiscussApp.localId(), "isSidebarCompact"); + expect(localStorage.getItem(isSidebarCompact)).toBe(toRawValue(true)); + expect(localStorage.getItem("mail.user_setting.discuss_sidebar_compact")).toBe(null); +}); + +test("category 'Channels' is folded", async () => { + localStorage.setItem("discuss_sidebar_category_folded_channels", "true"); + await start(); + await openDiscuss(); + await contains(".o-mail-DiscussSidebarCategory:contains('Channels') .oi.oi-chevron-right"); + const channels_is_open = makeRecordFieldLocalId( + DiscussAppCategory.localId("channels"), + "is_open" + ); + expect(localStorage.getItem(channels_is_open)).toBe(toRawValue(false)); + expect(localStorage.getItem("discuss_sidebar_category_folded_channels")).toBe(null); +}); + +test("category 'Direct messages' is folded", async () => { + localStorage.setItem("discuss_sidebar_category_folded_chats", "true"); + await start(); + await openDiscuss(); + await contains( + ".o-mail-DiscussSidebarCategory:contains('Direct messages') .oi.oi-chevron-right" + ); + const chats_is_open = makeRecordFieldLocalId(DiscussAppCategory.localId("chats"), "is_open"); + expect(localStorage.getItem(chats_is_open)).toBe(toRawValue(false)); + expect(localStorage.getItem("discuss_sidebar_category_folded_chats")).toBe(null); +}); + +test("last active id of discuss app", async () => { + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "test" }); + localStorage.setItem( + "mail.user_setting.discuss_last_active_id", + `discuss.channel_${channelId}` + ); + await start(); + await openDiscuss(); + await contains(".o-mail-Thread:contains('Welcome to #test')"); + const lastActiveId = makeRecordFieldLocalId(DiscussApp.localId(), "lastActiveId"); + expect(localStorage.getItem(lastActiveId)).toBe(toRawValue(`discuss.channel_${channelId}`)); + expect(localStorage.getItem("mail.user_setting.discuss_last_active_id")).toBe(null); +}); + +test("call auto focus is 'off", async () => { + mockGetMedia(); + const pyEnv = await startServer(); + const channelId = pyEnv["discuss.channel"].create({ name: "test" }); + localStorage.setItem("mail_user_setting_disable_call_auto_focus", "true"); + await start(); + await openDiscuss(channelId); + await click("[title='Start Call']"); + await click(".o-discuss-CallActionList [title='More']"); + await contains(".o-dropdown-item:contains('Autofocus speaker')"); + // correct local storage values + const useCallAutoFocusKey = makeRecordFieldLocalId(Settings.localId(), "useCallAutoFocus"); + expect(localStorage.getItem(useCallAutoFocusKey)).toBe(toRawValue(false)); + expect(localStorage.getItem("mail_user_setting_disable_call_auto_focus")).toBe(null); +}); diff --git a/addons/mail/static/tests/core/record.test.js b/addons/mail/static/tests/core/record.test.js index 75619caef7782..d1ac16f77a329 100644 --- a/addons/mail/static/tests/core/record.test.js +++ b/addons/mail/static/tests/core/record.test.js @@ -1,10 +1,11 @@ +import { toRawValue } from "@mail/utils/common/local_storage"; import { defineMailModels, start as start2 } from "@mail/../tests/mail_test_helpers"; import { afterEach, beforeEach, describe, expect, test } from "@odoo/hoot"; import { markup, reactive, toRaw } from "@odoo/owl"; import { mockService } from "@web/../tests/web_test_helpers"; import { Record, Store, makeStore } from "@mail/model/export"; -import { AND, fields } from "@mail/model/misc"; +import { AND, fields, makeRecordFieldLocalId } from "@mail/model/misc"; import { serializeDateTime } from "@web/core/l10n/dates"; import { registry } from "@web/core/registry"; @@ -1465,3 +1466,33 @@ test("accessing fields through empty _inherits parent returns empty values", asy expect(user.name).toBe(undefined); expect(user.partners).toHaveLength(0); }); + +test("Fields with { localStorage: true } are saved in local storage", async () => { + (class Message extends Record { + static id = "id"; + id; + body = fields.Attr("", { localStorage: true }); + }).register(localRegistry); + const store = await start(); + const message = store.Message.insert(1); + const bodyLocalId = makeRecordFieldLocalId(message.localId, "body"); + expect(localStorage.getItem(bodyLocalId)).toBe(null); + message.body = "test"; + expect(localStorage.getItem(bodyLocalId)).toBe(toRawValue("test")); + message.body = "test2"; + expect(localStorage.getItem(bodyLocalId)).toBe(toRawValue("test2")); +}); + +test("Fields with { localStorage: true } are restored from local storage", async () => { + class Message extends Record { + static id = "id"; + id; + body = fields.Attr("", { localStorage: true }); + } + Message.register(localRegistry); + const bodyLocalId = makeRecordFieldLocalId(Message.localId(1), "body"); + localStorage.setItem(bodyLocalId, toRawValue("test")); + const store = await start(); + const message = store.Message.insert(1); + expect(message.body).toBe("test"); +}); diff --git a/addons/mail/static/tests/discuss/call/call_settings_menu.test.js b/addons/mail/static/tests/discuss/call/call_settings_menu.test.js index e9a9060044eaf..2609e23e61519 100644 --- a/addons/mail/static/tests/discuss/call/call_settings_menu.test.js +++ b/addons/mail/static/tests/discuss/call/call_settings_menu.test.js @@ -9,6 +9,9 @@ import { start, startServer, } from "@mail/../tests/mail_test_helpers"; +import { toRawValue } from "@mail/utils/common/local_storage"; +import { Settings } from "@mail/core/common/settings_model"; +import { makeRecordFieldLocalId } from "@mail/model/misc"; import { describe, test, expect } from "@odoo/hoot"; import { advanceTime } from "@odoo/hoot-mock"; import { patchWithCleanup } from "@web/../tests/web_test_helpers"; @@ -94,7 +97,8 @@ test("local storage for call settings", async () => { localStorage.setItem("mail_user_setting_background_blur_amount", "3"); localStorage.setItem("mail_user_setting_edge_blur_amount", "5"); localStorage.setItem("mail_user_setting_show_only_video", "true"); - localStorage.setItem("mail_user_setting_use_blur", "true"); + const useBlurLocalStorageKey = makeRecordFieldLocalId(Settings.localId(), "useBlur"); + localStorage.setItem(useBlurLocalStorageKey, toRawValue(true)); patchWithCleanup(localStorage, { setItem(key, value) { if (key.startsWith("mail_user_setting")) { @@ -118,12 +122,9 @@ test("local storage for call settings", async () => { // testing save to local storage await click("input[title='Show video participants only']"); - await expect.waitForSteps([ - "mail_user_setting_use_blur: true", - "mail_user_setting_show_only_video: false", - ]); + await expect.waitForSteps(["mail_user_setting_show_only_video: false"]); await click("input[title='Blur video background']"); - expect(localStorage.getItem("mail_user_setting_use_blur")).toBe(null); + expect(localStorage.getItem(useBlurLocalStorageKey)).toBe(null); await editInput(document.body, ".o-Discuss-CallSettings-thresholdInput", 0.3); await advanceTime(2000); // threshold setting debounce timer await expect.waitForSteps(["mail_user_setting_voice_threshold: 0.3"]); diff --git a/addons/mail/static/tests/discuss_app/discuss.test.js b/addons/mail/static/tests/discuss_app/discuss.test.js index d72b7df99c636..0c6043163c93d 100644 --- a/addons/mail/static/tests/discuss_app/discuss.test.js +++ b/addons/mail/static/tests/discuss_app/discuss.test.js @@ -1,7 +1,7 @@ import { waitUntilSubscribe } from "@bus/../tests/bus_test_helpers"; import { OutOfFocusService } from "@mail/core/common/out_of_focus_service"; -import { LAST_DISCUSS_ACTIVE_ID_LS } from "@mail/core/public_web/discuss_app/discuss_app_model"; +import { DiscussApp } from "@mail/core/public_web/discuss_app/discuss_app_model"; import { click, contains, @@ -39,6 +39,9 @@ import { serverState, withUser, } from "@web/../tests/web_test_helpers"; +import { makeRecordFieldLocalId } from "@mail/model/misc"; +import { Settings } from "@mail/core/common/settings_model"; +import { toRawValue } from "@mail/utils/common/local_storage"; describe.current.tags("desktop"); defineMailModels(); @@ -597,7 +600,11 @@ test("sidebar: Inbox should have icon", async () => { test("last discuss conversation is remembered", async () => { const pyEnv = await startServer(); const channelId = pyEnv["discuss.channel"].create({ name: "General" }); - browser.localStorage.setItem(LAST_DISCUSS_ACTIVE_ID_LS, "discuss.channel_" + channelId); + const LAST_DISCUSS_ACTIVE_ID_LS = makeRecordFieldLocalId(DiscussApp.localId(), "lastActiveId"); + browser.localStorage.setItem( + LAST_DISCUSS_ACTIVE_ID_LS, + toRawValue(`${"discuss.channel_" + channelId}`) + ); await start(); await openDiscuss(); await contains('[role="heading"]', { text: "Welcome to #General!" }); @@ -622,6 +629,7 @@ test("channel deletion fallbacks to no conversation selected", async () => { test("sidebar: change active", async () => { const pyEnv = await startServer(); pyEnv["res.users"].write(serverState.userId, { notification_type: "inbox" }); + const LAST_DISCUSS_ACTIVE_ID_LS = makeRecordFieldLocalId(DiscussApp.localId(), "lastActiveId"); await start(); await openDiscuss("mail.box_inbox"); await contains("button.o-active", { text: "Inbox" }); @@ -629,7 +637,9 @@ test("sidebar: change active", async () => { await click("button", { text: "Starred messages" }); await contains("button:not(.o-active)", { text: "Inbox" }); await contains("button.o-active", { text: "Starred messages" }); - expect(browser.localStorage.getItem(LAST_DISCUSS_ACTIVE_ID_LS)).toBe("mail.box_starred"); + expect(browser.localStorage.getItem(LAST_DISCUSS_ACTIVE_ID_LS)).toBe( + toRawValue("mail.box_starred") + ); }); test("sidebar: basic channel rendering", async () => { @@ -1531,7 +1541,8 @@ test("message sound on receiving new message based on user preferences", async ( await waitFor(".o-mail-ChatBubble .badge:contains(1)", { timeout: 3000 }); await expect.waitForSteps(["sound:new-message"]); // simulate message sound settings turned off - browser.localStorage.setItem("mail.user_setting.message_sound", false); + const MESSAGE_SOUND_LS = makeRecordFieldLocalId(Settings.localId(), "messageSound"); + browser.localStorage.setItem(MESSAGE_SOUND_LS, toRawValue(false)); await animationFrame(); await withUser(userId, () => rpc("/mail/message/post", { @@ -1546,8 +1557,7 @@ test("message sound on receiving new message based on user preferences", async ( await waitFor(".o-mail-ChatBubble .badge:contains(2)", { timeout: 3000 }); expect.verifySteps([]); // simulate message sound settings turned on - browser.localStorage.setItem("mail.user_setting.message_sound", null); - browser.localStorage.removeItem("mail.user_setting.message_sound"); + browser.localStorage.removeItem(MESSAGE_SOUND_LS); await withUser(userId, () => rpc("/mail/message/post", { post_data: { diff --git a/addons/mail/static/tests/discuss_app/sidebar.test.js b/addons/mail/static/tests/discuss_app/sidebar.test.js index cc986682d3261..c1a09e3312ae1 100644 --- a/addons/mail/static/tests/discuss_app/sidebar.test.js +++ b/addons/mail/static/tests/discuss_app/sidebar.test.js @@ -14,7 +14,9 @@ import { triggerHotkey, waitStoreFetch, } from "@mail/../tests/mail_test_helpers"; -import { DISCUSS_SIDEBAR_COMPACT_LS } from "@mail/core/public_web/discuss_app/discuss_app_model"; +import { toRawValue } from "@mail/utils/common/local_storage"; +import { DiscussApp } from "@mail/core/public_web/discuss_app/discuss_app_model"; +import { makeRecordFieldLocalId } from "@mail/model/misc"; import { describe, expect, test } from "@odoo/hoot"; import { animationFrame, drag, press, queryFirst } from "@odoo/hoot-dom"; import { Deferred, mockDate } from "@odoo/hoot-mock"; @@ -1021,7 +1023,11 @@ test("Can make sidebar smaller", async () => { }); test("Sidebar compact is locally persistent (saved in local storage)", async () => { - browser.localStorage.setItem(DISCUSS_SIDEBAR_COMPACT_LS, true); + const DISCUSS_SIDEBAR_COMPACT_LS = makeRecordFieldLocalId( + DiscussApp.localId(), + "isSidebarCompact" + ); + browser.localStorage.setItem(DISCUSS_SIDEBAR_COMPACT_LS, toRawValue(true)); await start(); await openDiscuss(); await contains(".o-mail-DiscussSidebar.o-compact"); @@ -1038,7 +1044,7 @@ test("Sidebar compact is locally persistent (saved in local storage)", async () position: { x: 0 }, }); await contains(".o-mail-DiscussSidebar.o-compact"); - expect(browser.localStorage.getItem(DISCUSS_SIDEBAR_COMPACT_LS)).toBe("true"); + expect(browser.localStorage.getItem(DISCUSS_SIDEBAR_COMPACT_LS)).toBe(toRawValue(true)); }); test("Sidebar compact is crosstab synced", async () => { diff --git a/addons/mail/static/tests/mail_test_helpers.js b/addons/mail/static/tests/mail_test_helpers.js index 1f1cbbd517fd3..6504a0ea859de 100644 --- a/addons/mail/static/tests/mail_test_helpers.js +++ b/addons/mail/static/tests/mail_test_helpers.js @@ -78,7 +78,9 @@ import { ResUsersSettingsVolumes } from "./mock_server/mock_models/res_users_set import { Network } from "@mail/discuss/call/common/rtc_service"; import { UPDATE_EVENT } from "@mail/discuss/call/common/peer_to_peer"; import { SoundEffects } from "@mail/core/common/sound_effects_service"; -import { DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS } from "@mail/discuss/core/public_web/discuss_app/discuss_app_category_model"; +import { DiscussAppCategory } from "@mail/discuss/core/public_web/discuss_app/discuss_app_category_model"; +import { makeRecordFieldLocalId } from "@mail/model/misc"; +import { LocalStorageEntry } from "@mail/utils/common/local_storage"; export * from "./mail_test_helpers_contains"; @@ -771,15 +773,19 @@ export function setupChatHub({ opened = [], folded = [] } = {}) { } export function setDiscussSidebarCategoryFoldState(categoryId, val) { + const localId = DiscussAppCategory.localId(categoryId); + const lse = new LocalStorageEntry(makeRecordFieldLocalId(localId, "is_open")); if (val) { - localStorage.setItem(`${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${categoryId}`, val); + lse.set(!val); } else { - localStorage.removeItem(`${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${categoryId}`); + lse.remove(); } } export function isDiscussSidebarCategoryFolded(categoryId) { - return localStorage.getItem(`${DISCUSS_SIDEBAR_CATEGORY_FOLDED_LS}${categoryId}`) === "true"; + const localId = DiscussAppCategory.localId(categoryId); + const lse = new LocalStorageEntry(makeRecordFieldLocalId(localId, "is_open")); + return !(lse.get() ?? true); } export function assertChatHub({ opened = [], folded = [] }) { From 6e83eaf8a3aa6e9e895aaafa0641ee0a99a99733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20K=C3=BChn?= Date: Fri, 24 Oct 2025 18:54:11 +0200 Subject: [PATCH 2/2] [IMP] mail, im_livechat: manage downgrade of local storage This commit adds versioning to local storage entries, so that when version is more recent than current version, business code can have a strategy to drop the values. This strategy is the one used by local storage field: older versions are most updated and upgraded fields so 19.1 is implicitly valid for 19.2 and 19.3. 20.0 is invalid as it can potentially be affected by unknown upgrade scripts of current version (19.3), thus it's dropped. Part of Task-5003012 --- .../core/common/upgrade/upgrade_helpers.js | 10 ++++--- .../core/common/upgrade/upgrade_service.js | 5 ++-- .../mail/static/src/model/model_internal.js | 9 ++++--- .../mail/static/src/model/store_internal.js | 4 +-- .../static/src/utils/common/local_storage.js | 26 ++++++++----------- addons/mail/static/tests/mail_test_helpers.js | 2 +- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js b/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js index 2328b2a8c7727..945d5777e79e4 100644 --- a/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js +++ b/addons/mail/static/src/core/common/upgrade/upgrade_helpers.js @@ -104,8 +104,12 @@ function getUpgradeMap() { */ function applyUpgrade(upgradeData) { const oldEntry = new LocalStorageEntry(upgradeData.key); - const oldValue = oldEntry.rawGet() ?? oldEntry.get(); - if (oldValue === undefined) { + const parsed = oldEntry.parse(); + const oldValue = parsed ?? oldEntry.get(); + if ( + oldValue === null || + (parsed && parsed.version && parseVersion(upgradeData.version).isLowerThan(parsed.version)) + ) { return; // could not upgrade (cannot parse or more recent version) } const { key, value } = @@ -114,5 +118,5 @@ function applyUpgrade(upgradeData) { : upgradeData.upgrade; oldEntry.remove(); const newEntry = new LocalStorageEntry(key); - newEntry.set(value); + newEntry.set(value, upgradeData.version); } diff --git a/addons/mail/static/src/core/common/upgrade/upgrade_service.js b/addons/mail/static/src/core/common/upgrade/upgrade_service.js index 07b1f58735b1c..a39062d9b686c 100644 --- a/addons/mail/static/src/core/common/upgrade/upgrade_service.js +++ b/addons/mail/static/src/core/common/upgrade/upgrade_service.js @@ -7,9 +7,10 @@ export const discussUpgradeService = { dependencies: [], start() { const lse = new LocalStorageEntry("discuss.upgrade.version"); - const oldVersion = lse.get() ?? "1.0"; + const parsed = lse.parse(); + const oldVersion = parsed?.version ?? "1.0"; const currentVersion = getCurrentLocalStorageVersion(); - lse.set(currentVersion); + lse.set(true, currentVersion); if (parseVersion(oldVersion).isLowerThan(currentVersion)) { upgradeFrom(oldVersion); } diff --git a/addons/mail/static/src/model/model_internal.js b/addons/mail/static/src/model/model_internal.js index 8138e156b8ee8..4e9918bc32b78 100644 --- a/addons/mail/static/src/model/model_internal.js +++ b/addons/mail/static/src/model/model_internal.js @@ -1,5 +1,7 @@ import { toRaw } from "@odoo/owl"; import { ATTR_SYM, MANY_SYM, ONE_SYM } from "./misc"; +import { parseVersion } from "@mail/utils/common/misc"; +import { getCurrentLocalStorageVersion } from "@mail/utils/common/local_storage"; export class ModelInternal { /** @type {Map} */ @@ -75,12 +77,13 @@ export class ModelInternal { function fieldLocalStorageCompute() { const record = toRaw(this)._raw; const lse = record._.fieldsLocalStorage.get(fieldName); - const value = lse.get(); - if (value === undefined) { + const parsed = lse.parse(); + const currentOdooVersion = getCurrentLocalStorageVersion(); + if (!parsed || parseVersion(currentOdooVersion).isLowerThan(parsed.version)) { lse.remove(); return this[fieldName]; } - return value; + return parsed.value; } ); diff --git a/addons/mail/static/src/model/store_internal.js b/addons/mail/static/src/model/store_internal.js index 3f7bd3313c6b5..598baa90f076f 100644 --- a/addons/mail/static/src/model/store_internal.js +++ b/addons/mail/static/src/model/store_internal.js @@ -6,7 +6,7 @@ import { RecordInternal } from "./record_internal"; import { deserializeDate, deserializeDateTime } from "@web/core/l10n/dates"; import { IS_DELETED_SYM, isCommand, isMany } from "./misc"; import { browser } from "@web/core/browser/browser"; -import { parseRawValue } from "@mail/utils/common/local_storage"; +import { getCurrentLocalStorageVersion, parseRawValue } from "@mail/utils/common/local_storage"; const Markup = markup().constructor; @@ -55,7 +55,7 @@ export class StoreInternal extends RecordInternal { record._proxy[fieldName] = record._.fieldsDefault.get(fieldName); } else { const parsed = parseRawValue(ev.newValue); - if (!parsed) { + if (!parsed || parsed.version !== getCurrentLocalStorageVersion()) { record._proxy[fieldName] = record._.fieldsDefault.get(fieldName); } else { record._proxy[fieldName] = parsed.value; diff --git a/addons/mail/static/src/utils/common/local_storage.js b/addons/mail/static/src/utils/common/local_storage.js index 79775cae590d8..b951305090880 100644 --- a/addons/mail/static/src/utils/common/local_storage.js +++ b/addons/mail/static/src/utils/common/local_storage.js @@ -26,32 +26,28 @@ export class LocalStorageEntry { this.key = key; } get() { - const rawValue = this.rawGet(); - if (rawValue === null) { - return undefined; - } - return parseRawValue(rawValue)?.value; + return browser.localStorage.getItem(this.key); + } + parse() { + return parseRawValue(this.get()); } - set(value) { - const oldValue = this.get(); - if (oldValue !== undefined && oldValue === value) { + set(value, version = getCurrentLocalStorageVersion()) { + const parsed = this.parse(); + if (parsed && parsed.value === value && parsed.version === version) { return; } - browser.localStorage.setItem(this.key, toRawValue(value)); - } - rawGet() { - return browser.localStorage.getItem(this.key); + browser.localStorage.setItem(this.key, toRawValue(value, version)); } remove() { - if (this.rawGet() === null) { + if (this.get() === null) { return; } browser.localStorage.removeItem(this.key); } } -export function toRawValue(value) { - return JSON.stringify({ value }); +export function toRawValue(value, version = getCurrentLocalStorageVersion()) { + return JSON.stringify({ value, version }); } /** diff --git a/addons/mail/static/tests/mail_test_helpers.js b/addons/mail/static/tests/mail_test_helpers.js index 6504a0ea859de..f334cafd7752a 100644 --- a/addons/mail/static/tests/mail_test_helpers.js +++ b/addons/mail/static/tests/mail_test_helpers.js @@ -785,7 +785,7 @@ export function setDiscussSidebarCategoryFoldState(categoryId, val) { export function isDiscussSidebarCategoryFolded(categoryId) { const localId = DiscussAppCategory.localId(categoryId); const lse = new LocalStorageEntry(makeRecordFieldLocalId(localId, "is_open")); - return !(lse.get() ?? true); + return !(lse.parse()?.value ?? true); } export function assertChatHub({ opened = [], folded = [] }) {