diff options
| author | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
|---|---|---|
| committer | Sébastien Theys <seb@odoo.com> | 2020-06-15 18:12:21 +0000 |
| commit | 3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch) | |
| tree | 5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src | |
| parent | 0a82848606151ef60eb6523e9c4c161171f214e7 (diff) | |
[REF] mail, *: refactor messaging with OWL
This commit is a significant rewriting of client-side discuss, chatter,
chat window, and messaging menu using OWL. The behavior should be broadly
the same, with some slight functional changes here and there.
From a technical standpoint, the code of messaging is mainly organized in 2
main groups of modules:
- models, which are logical entities that depict the client-side state of
messaging as a whole.
- components, which are in charge of displaying information from models.
This refactoring also introduces new JS guidelines regarding folder structure
(/static) and naming rules for JS modules.
Community PR: https://github.com/odoo/odoo/pull/39023
Enterprise PR: https://github.com/odoo/enterprise/pull/6249
Task-1914207
This PR is a collaborative work by Alexandre, Julien, Sébastien and Xavier,
with the precious help of Lucas to speed it up towards the end.
closes odoo/odoo#39023
Related: odoo/enterprise#6249
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Co-authored-by: Alexandre Kühn <aku@odoo.com>
Co-authored-by: Julien Giannone <jgi@odoo.com>
Co-authored-by: Lucas Perais <lpe@odoo.com>
Co-authored-by: Sébastien Theys <seb@odoo.com>
Co-authored-by: Xavier Dubuc <xdu@odoo.com>
Diffstat (limited to 'addons/mail/static/src')
320 files changed, 46099 insertions, 17669 deletions
diff --git a/addons/mail/static/src/bugfix/bugfix.js b/addons/mail/static/src/bugfix/bugfix.js new file mode 100644 index 00000000000..5a7474b5c59 --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix.js @@ -0,0 +1,10 @@ +/** + * This file allows introducing new JS modules without contaminating other files. + * This is useful when bug fixing requires adding such JS modules in stable + * versions of Odoo. Any module that is defined in this file should be isolated + * in its own file in master. + */ +odoo.define('mail/static/src/bugfix/bugfix.js', function (require) { +'use strict'; + +}); diff --git a/addons/mail/static/src/bugfix/bugfix.scss b/addons/mail/static/src/bugfix/bugfix.scss new file mode 100644 index 00000000000..c4272e527b4 --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix.scss @@ -0,0 +1,6 @@ +/** +* This file allows introducing new styles without contaminating other files. +* This is useful when bug fixing requires adding new components for instance in +* stable versions of Odoo. Any style that is defined in this file should be isolated +* in its own file in master. +*/ diff --git a/addons/mail/static/src/bugfix/bugfix.xml b/addons/mail/static/src/bugfix/bugfix.xml new file mode 100644 index 00000000000..c17906f75bb --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + +<!-- + This file allows introducing new static templates without contaminating other files. + This is useful when bug fixing requires adding new components for instance in stable + versions of Odoo. Any template that is defined in this file should be isolated + in its own file in master. +--> + +</templates> diff --git a/addons/mail/static/src/bugfix/bugfix_tests.js b/addons/mail/static/src/bugfix/bugfix_tests.js new file mode 100644 index 00000000000..a5ecddded12 --- /dev/null +++ b/addons/mail/static/src/bugfix/bugfix_tests.js @@ -0,0 +1,18 @@ +odoo.define('mail/static/src/bugfix/bugfix_tests.js', function (require) { +'use strict'; + +/** + * This file allows introducing new QUnit test modules without contaminating + * other test files. This is useful when bug fixing requires adding new + * components for instance in stable versions of Odoo. Any test that is defined + * in this file should be isolated in its own file in master. + */ +QUnit.module('mail', {}, function () { +QUnit.module('bugfix', {}, function () { +QUnit.module('bugfix_tests.js', { + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js new file mode 100644 index 00000000000..2af2a2ddea3 --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js @@ -0,0 +1,88 @@ +odoo.define('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js', function (require) { +'use strict'; + +const { useRef, useState, onMounted, onWillUnmount } = owl.hooks; + +/** + * This hook handle the visibility of the dropzone based on drag & drop events. + * It needs a ref to a dropzone, so you need to specify a t-ref="dropzone" in + * the template of your component. + * + * @returns {Object} + */ +function useDragVisibleDropZone() { + /** + * Determine whether the drop zone should be visible or not. + * Note that this is an observed value, and primitive types such as + * boolean cannot be observed, hence this is an object with boolean + * value accessible from `.value` + */ + const isVisible = useState({ value: false }); + const dropzoneRef = useRef('dropzone'); + + /** + * Counts how many drag enter/leave happened globally. This is the only + * way to know if a file has been dragged out of the browser window. + */ + let dragCount = 0; + + // COMPONENTS HOOKS + onMounted(() => { + document.addEventListener('dragenter', _onDragenterListener, true); + document.addEventListener('dragleave', _onDragleaveListener, true); + document.addEventListener('drop', _onDropListener, true); + }); + + onWillUnmount(() => { + document.removeEventListener('dragenter', _onDragenterListener, true); + document.removeEventListener('dragleave', _onDragleaveListener, true); + document.removeEventListener('drop', _onDropListener, true); + }); + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Shows the dropzone when entering the browser window, to let the user know + * where he can drop its file. + * Avoids changing state when entering inner dropzones. + * + * @private + * @param {DragEvent} ev + */ + function _onDragenterListener(ev) { + if (dragCount === 0) { + isVisible.value = true; + } + dragCount++; + } + + /** + * @private + * @param {DragEvent} ev + */ + function _onDragleaveListener(ev) { + dragCount--; + if (dragCount === 0) { + isVisible.value = false; + } + } + + /** + * @private + * @param {DragEvent} ev + */ + function _onDropListener(ev) { + dragCount = 0; + if (dropzoneRef.comp && !dropzoneRef.comp.contains(ev.target)) { + isVisible.value = false; + } + } + + return isVisible; +} + +return useDragVisibleDropZone; + +}); diff --git a/addons/mail/static/src/component_hooks/use_refs/use_refs.js b/addons/mail/static/src/component_hooks/use_refs/use_refs.js new file mode 100644 index 00000000000..6c84f195222 --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_refs/use_refs.js @@ -0,0 +1,21 @@ +odoo.define('mail/static/src/component_hooks/use_refs/use_refs.js', function (require) { +'use strict'; + +const { Component } = owl; + +/** + * This hook provides support for dynamic-refs. + * + * @returns {function} returns object whose keys are t-ref values of active refs. + * and values are refs. + */ +function useRefs() { + const component = Component.current; + return function () { + return component.__owl__.refs || {}; + }; +} + +return useRefs; + +}); diff --git a/addons/mail/static/src/component_hooks/use_store/use_store.js b/addons/mail/static/src/component_hooks/use_store/use_store.js new file mode 100644 index 00000000000..7ff5ea3a9da --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_store/use_store.js @@ -0,0 +1,126 @@ +odoo.define('mail/static/src/component_hooks/use_store/use_store.js', function (require) { +'use strict'; + +/** + * Similar to owl.hooks.useStore but to decide if a new render has to be done it + * compares the keys on the result, with an optional level of depth for each + * key, given as options `compareDepth`. + * + * It assumes that the result of the selector is always an object (or array). + * + * @param {function} selector function passed as selector of original `useStore` + * with 1st parameter extended as store state. @see owl.hooks.useStore + * @param {object} [options={}] @see owl.hooks.useStore + * @param {number|object} [options.compareDepth=0] the comparison depth, either + * as number (applies to all keys) or as an object (depth for specific keys) + * @returns {Proxy} @see owl.hooks.useStore + */ +function useStore(selector, options = {}) { + const store = options.store || owl.Component.current.env.store; + const hashFn = store.observer.revNumber.bind(store.observer); + const isEqual = options.isEqual || ((a, b) => a === b); + + /** + * Returns a function comparing whether two values are the same, which is just + * calling `isEqual` on primitive values and objects, but which also works for + * Owl Proxy in a temporal way: the current result of hashFn is compared to the + * previous result of hashFn (from the last time the function was called). + * + * It means that when this function is given Proxy the first call will always + * return false, and consecutive calls will not lead to the same result: + * it returns true the first time after a change happended inside the Proxy, and + * then always returns false until a new change is made. + * + * @returns {function} function taking two arguments, and comparing them as + * explained above. + */ + function proxyComparator() { + /** + * It is important to locally save the old `revNumber` of each resulting + * value because when the "old" and "new" values are the same proxy it is + * impossible to compare them based on their current value (since it was + * updated in "both" due to the fact it is a proxy in the first place). + * + * And if the values are not proxy, `revNumber` will be 0 and the `isEqual` + * will be used to compare them. + */ + let oldRevNumber; + + function compare(a, b) { + let ok = true; + const newRevNumber = hashFn(b); + if (a === b && newRevNumber > 0) { + ok = oldRevNumber === newRevNumber; + } else { + ok = isEqual(a, b); + } + oldRevNumber = newRevNumber; + return ok; + } + + return compare; + } + + /** + * @see proxyComparator, but instead of comparing the given values, it compares + * their respective keys, with `compareDepth` level of depth. + * 0 = compare key, 1 = also compare subkeys, ... + * + * This assumes the given values are objects or arrays. + * + * @param {number|object} compareDepth the comparison depth, either as number + * (applies to all keys) or as an object (depth for specific keys) + * @returns {function} + */ + function proxyComparatorDeep(compareDepth = 0) { + const comparator = proxyComparator(); + const comparators = {}; + + function compare(a, b) { + // If a and b are (the same) proxy, it is already managing the depth + // by itself, and a simple comparator can be used. + if (a === b && hashFn(b) > 0) { + return comparator(a, b); + } + let ok = true; + const newKeys = Object.keys(b); + if (!a || (Object.keys(a).length !== newKeys.length)) { + ok = false; + } + for (const key of newKeys) { + // the depth can be given either as a number (for all keys) or as + // an object (for each key) + let depth; + if (typeof compareDepth === 'number') { + depth = compareDepth; + } else { + depth = compareDepth[key] || 0; + } + if (!(key in comparators)) { + if (depth > 0) { + comparators[key] = proxyComparatorDeep(depth - 1); + } else { + comparators[key] = proxyComparator(); + } + } + // It is important to not break too early, the comparator has to + // be called for every key to remember their current states. + if (!comparators[key](a ? a[key] : a, b[key])) { + ok = false; + } + } + return ok; + } + + return compare; + } + + const extendedSelector = (state, props) => selector(props); + return owl.hooks.useStore(extendedSelector, Object.assign({}, options, { + isEqual: proxyComparatorDeep(options.compareDepth), + })); +} + +return useStore; + +}); diff --git a/addons/mail/static/src/component_hooks/use_store/use_store_tests.js b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js new file mode 100644 index 00000000000..19751ee937d --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_store/use_store_tests.js @@ -0,0 +1,190 @@ +odoo.define('mail/static/src/component_hooks/use_store/use_store_tests.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const { + afterNextRender, + nextAnimationFrame, +} = require('mail/static/src/utils/test_utils.js'); + +const { Component, QWeb, Store } = owl; +const { useGetters } = owl.hooks; +const { xml } = owl.tags; + +QUnit.module('mail', {}, function () { +QUnit.module('component_hooks', {}, function () { +QUnit.module('use_store', {}, function () { +QUnit.module('use_store_tests.js', { + beforeEach() { + const qweb = new QWeb(); + this.env = { qweb }; + }, + afterEach() { + this.env = undefined; + this.store = undefined; + }, +}); + + +QUnit.test("compare keys, no depth, primitives", async function (assert) { + assert.expect(8); + this.store = new Store({ + env: this.env, + getters: { + get({ state }, key) { + return state[key]; + }, + }, + state: { + obj: { + subObj1: 'a', + subObj2: 'b', + use1: true, + }, + }, + }); + this.env.store = this.store; + let count = 0; + class MyComponent extends Component { + constructor() { + super(...arguments); + this.storeGetters = useGetters(); + this.storeProps = useStore(props => { + const obj = this.storeGetters.get('obj'); + return { + res: obj.use1 ? obj.subObj1 : obj.subObj2, + }; + }, { + onUpdate: () => { + count++; + }, + }); + } + } + Object.assign(MyComponent, { + env: this.env, + props: {}, + template: xml`<div t-esc="storeProps.res"/>`, + }); + + const fixture = document.querySelector('#qunit-fixture'); + + const myComponent = new MyComponent(); + await myComponent.mount(fixture); + assert.strictEqual(count, 0, + 'should not detect an update initially'); + assert.strictEqual(fixture.textContent, 'a', + 'should display the content of subObj1'); + + await afterNextRender(() => { + this.store.state.obj.use1 = false; + }); + assert.strictEqual(count, 1, + 'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)'); + assert.strictEqual(fixture.textContent, 'b', + 'should display the content of subObj2'); + + this.store.state.obj.subObj2 = 'b'; + // there must be no render here + await nextAnimationFrame(); + assert.strictEqual(count, 1, + 'should not detect an update because the same primitive value was assigned (subObj2 was already "b")'); + assert.strictEqual(fixture.textContent, 'b', + 'should still display the content of subObj2'); + + await afterNextRender(() => { + this.store.state.obj.subObj2 = 'd'; + }); + assert.strictEqual(count, 2, + 'should detect an update because the selector is returning a different value for subObj2'); + assert.strictEqual(fixture.textContent, 'd', + 'should display the new content of subObj2'); + + myComponent.destroy(); +}); + +QUnit.test("compare keys, depth 1, proxy", async function (assert) { + assert.expect(8); + this.store = new Store({ + env: this.env, + getters: { + get({ state }, key) { + return state[key]; + }, + }, + state: { + obj: { + subObj1: { a: 'a' }, + subObj2: { a: 'b' }, + use1: true, + }, + }, + }); + this.env.store = this.store; + let count = 0; + class MyComponent extends Component { + constructor() { + super(...arguments); + this.storeGetters = useGetters(); + this.storeProps = useStore(props => { + const obj = this.storeGetters.get('obj'); + return { + array: [obj.use1 ? obj.subObj1 : obj.subObj2], + }; + }, { + compareDepth: { + array: 1, + }, + onUpdate: () => { + count++; + }, + }); + } + } + Object.assign(MyComponent, { + env: this.env, + props: {}, + template: xml`<div t-esc="storeProps.array[0].a"/>`, + }); + + const fixture = document.querySelector('#qunit-fixture'); + + const myComponent = new MyComponent(); + await myComponent.mount(fixture); + assert.strictEqual(count, 0, + 'should not detect an update initially'); + assert.strictEqual(fixture.textContent, 'a', + 'should display the content of subObj1'); + + await afterNextRender(() => { + this.store.state.obj.use1 = false; + }); + assert.strictEqual(count, 1, + 'should detect an update because the selector is returning a different value (was subObj1, now is subObj2)'); + assert.strictEqual(fixture.textContent, 'b', + 'should display the content of subObj2'); + + this.store.state.obj.subObj1.a = 'c'; + // there must be no render here + await nextAnimationFrame(); + assert.strictEqual(count, 1, + 'should not detect an update because subObj1 was changed but only subObj2 is selected'); + assert.strictEqual(fixture.textContent, 'b', + 'should still display the content of subObj2'); + + await afterNextRender(() => { + this.store.state.obj.subObj2.a = 'd'; + }); + assert.strictEqual(count, 2, + 'should detect an update because the value of subObj2 changed'); + assert.strictEqual(fixture.textContent, 'd', + 'should display the new content of subObj2'); + + myComponent.destroy(); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/activity/activity.js b/addons/mail/static/src/components/activity/activity.js new file mode 100644 index 00000000000..6c70f876160 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.js @@ -0,0 +1,177 @@ +odoo.define('mail/static/src/components/activity/activity.js', function (require) { +'use strict'; + +const components = { + ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + MailTemplate: require('mail/static/src/components/mail_template/mail_template.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { + auto_str_to_date, + getLangDateFormat, + getLangDatetimeFormat, +} = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +class Activity extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + areDetailsVisible: false, + }); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + assigneeNameOrDisplayName: ( + activity && + activity.assignee && + activity.assignee.nameOrDisplayName() + ), + }; + }); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {string} + */ + get assignedUserText() { + return _.str.sprintf(this.env._t("for %s"), this.activity.assignee.nameOrDisplayName()); + } + + /** + * @returns {string} + */ + get delayLabel() { + const today = moment().startOf('day'); + const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline)); + // true means no rounding + const diff = momentDeadlineDate.diff(today, 'days', true); + if (diff === 0) { + return this.env._t("Today:"); + } else if (diff === -1) { + return this.env._t("Yesterday:"); + } else if (diff < 0) { + return _.str.sprintf(this.env._t("%d days overdue:"), Math.abs(diff)); + } else if (diff === 1) { + return this.env._t("Tomorrow:"); + } else { + return _.str.sprintf(this.env._t("Due in %d days:"), Math.abs(diff)); + } + } + + /** + * @returns {string} + */ + get formattedCreateDatetime() { + const momentCreateDate = moment(auto_str_to_date(this.activity.dateCreate)); + const datetimeFormat = getLangDatetimeFormat(); + return momentCreateDate.format(datetimeFormat); + } + + /** + * @returns {string} + */ + get formattedDeadlineDate() { + const momentDeadlineDate = moment(auto_str_to_date(this.activity.dateDeadline)); + const datetimeFormat = getLangDateFormat(); + return momentDeadlineDate.format(datetimeFormat); + } + + /** + * @returns {string} + */ + get MARK_DONE() { + return this.env._t("Mark Done"); + } + + /** + * @returns {string} + */ + get summary() { + return _.str.sprintf(this.env._t("“%s”"), this.activity.summary); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.attachment} ev.detail.attachment + */ + _onAttachmentCreated(ev) { + this.activity.markAsDone({ attachments: [ev.detail.attachment] }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickCancel(ev) { + ev.preventDefault(); + this.activity.deleteServerRecord(); + } + + /** + * @private + */ + _onClickDetailsButton() { + this.state.areDetailsVisible = !this.state.areDetailsVisible; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEdit(ev) { + this.activity.edit(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUploadDocument(ev) { + this._fileUploaderRef.comp.openBrowserFileUploader(); + } + +} + +Object.assign(Activity, { + components, + props: { + activityLocalId: String, + }, + template: 'mail.Activity', +}); + +return Activity; + +}); diff --git a/addons/mail/static/src/components/activity/activity.scss b/addons/mail/static/src/components/activity/activity.scss new file mode 100644 index 00000000000..96117afd9ea --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.scss @@ -0,0 +1,174 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Activity { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Activity_detailsUserAvatar { + margin-inline-end: map-get($spacers, 2); + object-fit: cover; + height: 18px; + width: 18px; +} + +.o_Activity_dueDateText, .o_Activity_summary { + margin-inline-end: map-get($spacers, 2); +} + +.o_Activity_iconContainer { + @include o-position-absolute($top: auto, $left: auto, $bottom: -5px, $right: -5px); + display: flex; + align-items: center; + justify-content: center; + width: 25px; + height: 25px; + border-width: 2px; +} + +.o_Activity_info { + display: flex; + align-items: baseline; +} + +.o_Activity_sidebar { + display: flex; + flex: 0 0 36px; + margin-right: map-get($spacers, 3); + justify-content: center; +} + +.o_Activity_toolButton { + padding-top: map-get($spacers, 0); +} + +.o_Activity_tools { + display: flex; +} + +.o_Activity_user { + height: 36px; + margin-left: map-get($spacers, 2); + margin-right: map-get($spacers, 2); + position: relative; + width: 36px; +} + +.o_Activity_userAvatar { + height: map-get($sizes, 100); + width: map-get($sizes, 100); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +$o-mail-activity-default-color: gray('300') !default; +$o-mail-activity-overdue-color: darken(theme-color('danger'), 10%) !default; +$o-mail-activity-planned-color: darken(theme-color('success'), 10%) !default; +$o-mail-activity-today-color: darken(theme-color('warning'), 10%) !default; + + +.o_Activity_deadlineDateText { + &.o-default { + color: $o-mail-activity-default-color; + } + + &.o-overdue { + color: $o-mail-activity-overdue-color; + } + + &.o-planned { + color: $o-mail-activity-planned-color; + } + + &.o-today { + color: $o-mail-activity-today-color; + } +} + +.o_Activity_details { + color: gray('500'); +} + +.o_Activity_detailsCreatorAvatar { + margin-inline-start: map-get($spacers, 2); +} + +.o_Activity_detailsUserAvatar { + border-radius: 50%; +} + +.o_Activity_dueDateText { + font-weight: bolder; + + &.o-default { + color: $o-mail-activity-default-color; + } + + &.o-overdue { + color: $o-mail-activity-overdue-color; + } + + &.o-planned { + color: $o-mail-activity-planned-color; + } + + &.o-today { + color: $o-mail-activity-today-color; + } +} + +/* Needed specifity to counter default bootstrap style */ +a:not([href]):not([tabindex]).o_Activity_detailsButton { + background: transparent; + opacity: 0.5; + color: gray('500'); + + &:hover { + opacity: 1; + color: gray('600'); + } +} + +.o_Activity_detailsCreator { + font-weight: bold; +} + +.o_Activity_iconContainer { + color: white; + border-color: white; + border-radius: 100%; + border-style: solid; +} + +.o_Activity_sidebar { + font-size: smaller; +} + +.o_Activity_summary { + font-weight: bolder; + color: gray('900'); +} + +.o_Activity_toolButton { + opacity: 0.5; + color: gray('500'); + + &:hover { + opacity: 1; + color: gray('600'); + } +} + +.o_Activity_userAvatar { + border-radius: 50%; +} + +.o_Activity_userName { + color: gray('500'); +} + diff --git a/addons/mail/static/src/components/activity/activity.xml b/addons/mail/static/src/components/activity/activity.xml new file mode 100644 index 00000000000..22f4f9dddc7 --- /dev/null +++ b/addons/mail/static/src/components/activity/activity.xml @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Activity" owl="1"> + <div class="o_Activity"> + <t t-if="activity"> + <div class="o_Activity_sidebar"> + <div class="o_Activity_user"> + <t t-if="activity.assignee"> + <img class="o_Activity_userAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-alt="activity.assignee.nameOrDisplayName()"/> + </t> + <div class="o_Activity_iconContainer" + t-att-class="{ + 'bg-success-full': activity.state === 'planned', + 'bg-warning-full': activity.state === 'today', + 'bg-danger-full': activity.state === 'overdue', + }" + > + <i class="o_Activity_icon fa" t-attf-class="{{ activity.icon }}"/> + </div> + </div> + </div> + <div class="o_Activity_core"> + <div class="o_Activity_info"> + <div class="o_Activity_dueDateText" + t-att-class="{ + 'o-default': activity.state === 'default', + 'o-overdue': activity.state === 'overdue', + 'o-planned': activity.state === 'planned', + 'o-today': activity.state === 'today', + }" + > + <t t-esc="delayLabel"/> + </div> + <t t-if="activity.summary"> + <div class="o_Activity_summary"> + <t t-esc="summary"/> + </div> + </t> + <t t-elif="activity.type"> + <div class="o_Activity_summary o_Activity_type"> + <t t-esc="activity.type.displayName"/> + </div> + </t> + <t t-if="activity.assignee"> + <div class="o_Activity_userName"> + <t t-esc="assignedUserText"/> + </div> + </t> + <a class="o_Activity_detailsButton btn btn-link" t-on-click="_onClickDetailsButton" role="button"> + <i class="fa fa-info-circle" role="img" title="Info"/> + </a> + </div> + + <div class="o_Activity_details" t-if="state.areDetailsVisible"> + <dl class="dl-horizontal"> + <t t-if="activity.type"> + <dt>Activity type</dt> + <dd class="o_Activity_type"> + <t t-esc="activity.type.displayName"/> + </dd> + </t> + <t t-if="activity.creator"> + <dt>Created</dt> + <dd class="o_Activity_detailsCreation"> + <t t-esc="formattedCreateDatetime"/> + <img class="o_Activity_detailsUserAvatar o_Activity_detailsCreatorAvatar" t-attf-src="/web/image/res.users/{{ activity.creator.id }}/image_128" t-att-title="activity.creator.nameOrDisplayName()" t-att-alt="activity.creator.nameOrDisplayName()"/> + <span class="o_Activity_detailsCreator"> + <t t-esc="activity.creator.nameOrDisplayName()"/> + </span> + </dd> + </t> + <t t-if="activity.assignee"> + <dt>Assigned to</dt> + <dd class="o_Activity_detailsAssignation"> + <img class="o_Activity_detailsUserAvatar o_Activity_detailsAssignationUserAvatar" t-attf-src="/web/image/res.users/{{ activity.assignee.id }}/image_128" t-att-title="activity.assignee.nameOrDisplayName()" t-att-alt="activity.assignee.nameOrDisplayName()"/> + <t t-esc="activity.assignee.nameOrDisplayName()"/> + </dd> + </t> + <dt>Due on</dt> + <dd class="o_Activity_detailsDueDate"> + <span class="o_Activity_deadlineDateText" + t-att-class="{ + 'o-default': activity.state === 'default', + 'o-overdue': activity.state === 'overdue', + 'o-planned': activity.state === 'planned', + 'o-today': activity.state === 'today', + }" + > + <t t-esc="formattedDeadlineDate"/> + </span> + </dd> + </dl> + </div> + + <div class="o_Activity_note" t-if="activity.note"> + <t t-raw="activity.note"/> + </div> + + <t t-if="activity.mailTemplates.length > 0"> + <div class="o_Activity_mailTemplates"> + <t t-foreach="activity.mailTemplates" t-as="mailTemplate" t-key="mailTemplate.localId"> + <MailTemplate + class="o_Activity_mailTemplate" + activityLocalId="activity.localId" + mailTemplateLocalId="mailTemplate.localId" + /> + </t> + </div> + </t> + + <t t-if="activity.canWrite"> + <div name="tools" class="o_Activity_tools"> + <t t-if="activity.category !== 'upload_file'"> + <Popover position="'right'" title="MARK_DONE"> + <button class="o_Activity_toolButton o_Activity_markDoneButton btn btn-link" t-att-title="MARK_DONE"> + <i class="fa fa-check"/> Mark Done + </button> + <t t-set="opened"> + <ActivityMarkDonePopover activityLocalId="props.activityLocalId"/> + </t> + </Popover> + </t> + <t t-else=""> + <button class="o_Activity_toolButton o_Activity_uploadButton btn btn-link" t-on-click="_onClickUploadDocument"> + <i class="fa fa-upload"/> Upload Document + </button> + <FileUploader + attachmentLocalIds="activity.attachments.map(attachment => attachment.localId)" + uploadId="activity.res_id" + uploadModel="activity.res_model" + t-on-o-attachment-created="_onAttachmentCreated" + t-ref="fileUploader" + /> + </t> + <button class="o_Activity_toolButton o_Activity_editButton btn btn-link" t-on-click="_onClickEdit"> + <i class="fa fa-pencil"/> Edit + </button> + <button class="o_Activity_toolButton o_Activity_cancelButton btn btn-link" t-on-click="_onClickCancel" > + <i class="fa fa-times"/> Cancel + </button> + </div> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity/activity_tests.js b/addons/mail/static/src/components/activity/activity_tests.js new file mode 100644 index 00000000000..714f0d593eb --- /dev/null +++ b/addons/mail/static/src/components/activity/activity_tests.js @@ -0,0 +1,981 @@ +odoo.define('mail/static/src/components/activity/activity_tests.js', function (require) { +'use strict'; + +const components = { + Activity: require('mail/static/src/components/activity/activity.js'), +}; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Bus = require('web.Bus'); +const { date_to_str } = require('web.time'); + +const { Component, tags: { xml } } = owl; + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('activity', {}, function () { +QUnit.module('activity_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createActivityComponent = async function (activity) { + const ActivityComponent = components.Activity; + ActivityComponent.env = this.env; + this.component = new ActivityComponent(null, { + activityLocalId: activity.localId, + }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + }, +}); + +QUnit.test('activity simplest layout', async function (assert) { + assert.expect(12); + + await this.start(); + const activity = this.env.models['mail.activity'].create(); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_sidebar').length, + 1, + "should have activity sidebar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_core').length, + 1, + "should have activity core" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_user').length, + 1, + "should have activity user" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_info').length, + 1, + "should have activity info" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_note').length, + 0, + "should not have activity note" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "should not have activity details" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplates').length, + 0, + "should not have activity mail templates" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_editButton').length, + 0, + "should not have activity Edit button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_cancelButton').length, + 0, + "should not have activity Cancel button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_markDoneButton').length, + 0, + "should not have activity Mark as Done button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_uploadButton').length, + 0, + "should not have activity Upload button" + ); +}); + +QUnit.test('activity with note layout', async function (assert) { + assert.expect(3); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + note: 'There is no good or bad note' + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_note').length, + 1, + "should have activity note" + ); + assert.strictEqual( + document.querySelector('.o_Activity_note').textContent, + "There is no good or bad note", + "activity note should be 'There is no good or bad note'" + ); +}); + +QUnit.test('activity info layout when planned after tomorrow', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const fiveDaysFromNow = new Date(); + fiveDaysFromNow.setDate(today.getDate() + 5); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(fiveDaysFromNow), + state: 'planned', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'), + "activity delay should have the right color modifier class (planned)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Due in 5 days:", + "activity delay should have 'Due in 5 days:' as label" + ); +}); + +QUnit.test('activity info layout when planned tomorrow', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(tomorrow), + state: 'planned', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-planned'), + "activity delay should have the right color modifier class (planned)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + 'Tomorrow:', + "activity delay should have 'Tomorrow:' as label" + ); +}); + +QUnit.test('activity info layout when planned today', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(today), + state: 'today', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-today'), + "activity delay should have the right color modifier class (today)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Today:", + "activity delay should have 'Today:' as label" + ); +}); + +QUnit.test('activity info layout when planned yesterday', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(yesterday), + state: 'overdue', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'), + "activity delay should have the right color modifier class (overdue)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "Yesterday:", + "activity delay should have 'Yesterday:' as label" + ); +}); + +QUnit.test('activity info layout when planned before yesterday', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const fiveDaysBeforeNow = new Date(); + fiveDaysBeforeNow.setDate(today.getDate() - 5); + const activity = this.env.models['mail.activity'].create({ + dateDeadline: date_to_str(fiveDaysBeforeNow), + state: 'overdue', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_dueDateText').length, + 1, + "should have activity delay" + ); + assert.ok( + document.querySelector('.o_Activity_dueDateText').classList.contains('o-overdue'), + "activity delay should have the right color modifier class (overdue)" + ); + assert.strictEqual( + document.querySelector('.o_Activity_dueDateText').textContent, + "5 days overdue:", + "activity delay should have '5 days overdue:' as label" + ); +}); + +QUnit.test('activity with a summary layout', async function (assert) { + assert.expect(4); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + summary: 'test summary', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary').length, + 1, + "should have activity summary" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_type').length, + 0, + "should not have the activity type as summary" + ); + assert.strictEqual( + document.querySelector('.o_Activity_summary').textContent.trim(), + "“test summary”", + "should have the specific activity summary in activity summary" + ); +}); + +QUnit.test('activity without summary layout', async function (assert) { + assert.expect(5); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_type').length, + 1, + "activity details should have an activity type section" + ); + assert.strictEqual( + document.querySelector('.o_Activity_type').textContent.trim(), + "Fake type", + "activity details should have the activity type display name in type section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary.o_Activity_type').length, + 1, + "should have activity type as summary" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_summary:not(.o_Activity_type)').length, + 0, + "should not have a specific summary" + ); +}); + +QUnit.test('activity details toggle', async function (assert) { + assert.expect(5); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + state: 'planned', + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "activity details should not be visible by default" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsButton').length, + 1, + "activity should have a details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 1, + "activity details should be visible after clicking on details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 0, + "activity details should no longer be visible after clicking again on details button" + ); +}); + +QUnit.test('activity details layout', async function (assert) { + assert.expect(11); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + assignee: [['insert', { id: 10, partnerDisplayName: "Pauvre pomme" }]], + creator: [['insert', { id: 1, partnerDisplayName: "Admin" }]], + dateCreate: date_to_str(today), + dateDeadline: date_to_str(tomorrow), + state: 'planned', + type: [['insert', { id: 1, displayName: "Fake type" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_userAvatar').length, + 1, + "should have activity user avatar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsButton').length, + 1, + "activity should have a details button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_detailsButton').click() + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details').length, + 1, + "activity details should be visible after clicking on details button" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_details .o_Activity_type').length, + 1, + "activity details should have type" + ); + assert.strictEqual( + document.querySelector('.o_Activity_details .o_Activity_type').textContent, + "Fake type", + "activity details type should be 'Fake type'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsCreation').length, + 1, + "activity details should have creation date " + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsCreator').length, + 1, + "activity details should have creator" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsAssignation').length, + 1, + "activity details should have assignation information" + ); + assert.strictEqual( + document.querySelector('.o_Activity_detailsAssignation').textContent.indexOf('Pauvre pomme'), + 0, + "activity details assignation information should contain creator display name" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_detailsAssignationUserAvatar').length, + 1, + "activity details should have user avatar" + ); +}); + +QUnit.test('activity with mail template layout', async function (assert) { + assert.expect(8); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_sidebar').length, + 1, + "should have activity sidebar" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplates').length, + 1, + "should have activity mail templates" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_mailTemplate').length, + 1, + "should have activity mail template" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_name').length, + 1, + "should have activity mail template name" + ); + assert.strictEqual( + document.querySelector('.o_MailTemplate_name').textContent, + "Dummy mail template", + "should have activity mail template name" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_preview').length, + 1, + "should have activity mail template name preview button" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_send').length, + 1, + "should have activity mail template name send button" + ); +}); + +QUnit.test('activity with mail template: preview mail', async function (assert) { + assert.expect(10); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_model, + 'res.partner', + 'Action should have the activity res model as default model in context' + ); + assert.ok( + payload.action.context.default_use_template, + 'Action should have true as default use_template in context' + ); + assert.strictEqual( + payload.action.context.default_template_id, + 1, + 'Action should have the selected mail template id as default template id in context' + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + "mail.compose.message", + 'Action should have "mail.compose.message" as res_model' + ); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + res_id: 42, + res_model: 'res.partner', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_preview').length, + 1, + "should have activity mail template name preview button" + ); + + document.querySelector('.o_MailTemplate_preview').click(); + assert.verifySteps( + ['do_action'], + "should have called 'compose email' action correctly" + ); +}); + +QUnit.test('activity with mail template: send mail', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'activity_send_mail') { + assert.step('activity_send_mail'); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 42); + assert.strictEqual(args.args[1], 1); + return; + } else { + return this._super(...arguments); + } + }, + }); + const activity = this.env.models['mail.activity'].create({ + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + res_id: 42, + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_MailTemplate_send').length, + 1, + "should have activity mail template name send button" + ); + + document.querySelector('.o_MailTemplate_send').click(); + assert.verifySteps( + ['activity_send_mail'], + "should have called activity_send_mail rpc" + ); +}); + +QUnit.test('activity upload document is available', async function (assert) { + assert.expect(3); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'upload_file', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_uploadButton').length, + 1, + "should have activity upload button" + ); + assert.strictEqual( + document.querySelectorAll('.o_FileUploader').length, + 1, + "should have a file uploader" + ); +}); + +QUnit.test('activity click on mark as done', async function (assert) { + assert.expect(4); + + await this.start(); + const today = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + }); + await this.createActivityComponent(activity); + + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_markDoneButton').length, + 1, + "should have activity Mark as Done button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ActivityMarkDonePopover').length, + 1, + "should have opened the mark done popover" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_markDoneButton').click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ActivityMarkDonePopover').length, + 0, + "should have closed the mark done popover" + ); +}); + +QUnit.test('activity click on edit', async function (assert) { + assert.expect(9); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + 'Action should have the activity res model as default res model in context' + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + "mail.activity", + 'Action should have "mail.activity" as res_model' + ); + assert.strictEqual( + payload.action.res_id, + 12, + 'Action should have activity id as res_id' + ); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + id: 12, + mailTemplates: [['insert', { id: 1, name: "Dummy mail template" }]], + res_id: 42, + res_model: 'res.partner', + }); + await this.createActivityComponent(activity); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_editButton').length, + 1, + "should have activity edit button" + ); + + document.querySelector('.o_Activity_editButton').click(); + assert.verifySteps( + ['do_action'], + "should have called 'schedule activity' action correctly" + ); +}); + +QUnit.test('activity edition', async function (assert) { + assert.expect(14); + + this.data['mail.activity'].records = [{ + can_write: true, + icon: 'fa-times', + id: 12, + res_id: 42, + res_model: 'res.partner', + }]; + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.context.default_res_id, + 42, + 'Action should have the activity res id as default res id in context' + ); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + 'Action should have the activity res model as default res model in context' + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + 'Action should be of type "ir.actions.act_window"' + ); + assert.strictEqual( + payload.action.res_model, + 'mail.activity', + 'Action should have "mail.activity" as res_model' + ); + assert.strictEqual( + payload.action.res_id, + 12, + 'Action should have activity id as res_id' + ); + this.data['mail.activity'].records[0].icon = 'fa-check'; + payload.options.on_close(); + }); + + await this.start({ env: { bus } }); + const activity = this.env.models['mail.activity'].create( + this.env.models['mail.activity'].convertData( + this.data['mail.activity'].records[0] + ) + ); + await this.createActivityComponent(activity); + + assert.containsOnce( + document.body, + '.o_Activity', + "should have activity component" + ); + assert.containsOnce( + document.body, + '.o_Activity_editButton', + "should have activity edit button" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon', + "should have activity icon" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon.fa-times', + "should have initial activity icon" + ); + assert.containsNone( + document.body, + '.o_Activity_icon.fa-check', + "should not have new activity icon when not edited yet" + ); + + await afterNextRender(() => { + document.querySelector('.o_Activity_editButton').click(); + }); + assert.verifySteps( + ['do_action'], + "should have called 'schedule activity' action correctly" + ); + assert.containsNone( + document.body, + '.o_Activity_icon.fa-times', + "should no more have initial activity icon once edited" + ); + assert.containsOnce( + document.body, + '.o_Activity_icon.fa-check', + "should now have new activity icon once edited" + ); +}); + +QUnit.test('activity click on cancel', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + assert.step('unlink'); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + return; + } else { + return this._super(...arguments); + } + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + id: 12, + mailTemplates: [['insert', { + id: 1, + name: "Dummy mail template", + }]], + res_id: 42, + res_model: 'res.partner', + }); + + // Create a parent component to surround the Activity component in order to be able + // to check that activity component has been destroyed + class ParentComponent extends Component { + constructor(...args) { + super(... args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + }; + }); + } + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + } + ParentComponent.env = this.env; + Object.assign(ParentComponent, { + components, + props: { activityLocalId: String }, + template: xml` + <div> + <p>parent</p> + <t t-if="activity"> + <Activity activityLocalId="activity.localId"/> + </t> + </div> + `, + }); + this.component = new ParentComponent(null, { activityLocalId: activity.localId }); + await this.component.mount(this.widget.el); + + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 1, + "should have activity component" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity_cancelButton').length, + 1, + "should have activity cancel button" + ); + + await afterNextRender(() => + document.querySelector('.o_Activity_cancelButton').click() + ); + assert.verifySteps( + ['unlink'], + "should have called unlink rpc after clicking on cancel" + ); + assert.strictEqual( + document.querySelectorAll('.o_Activity').length, + 0, + "should no longer display activity after clicking on cancel" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/activity_box/activity_box.js b/addons/mail/static/src/components/activity_box/activity_box.js new file mode 100644 index 00000000000..bfc624bea19 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.js @@ -0,0 +1,60 @@ +odoo.define('mail/static/src/components/activity_box/activity_box.js', function (require) { +'use strict'; + +const components = { + Activity: require('mail/static/src/components/activity/activity.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ActivityBox extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + return { + chatter: chatter ? chatter.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickTitle() { + this.chatter.toggleActivityBoxVisibility(); + } + +} + +Object.assign(ActivityBox, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.ActivityBox', +}); + +return ActivityBox; + +}); diff --git a/addons/mail/static/src/components/activity_box/activity_box.scss b/addons/mail/static/src/components/activity_box/activity_box.scss new file mode 100644 index 00000000000..056f624c04f --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.scss @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ActivityBox_title { + display: flex; + align-items: center; + flex: 0 0 auto; + margin-top: map-get($spacers, 4); + margin-bottom: map-get($spacers, 4); +} + +.o_ActivityBox_titleBadge { + padding: map-get($spacers, 0) map-get($spacers, 2); +} + +.o_ActivityBox_titleLine { + flex: 1 1 auto; + width: auto; +} + +.o_ActivityBox_titleText { + padding: map-get($spacers, 0) map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ActivityBox_title { + font-weight: bold; +} + +.o_ActivityBox_titleBadge { + font-size: 11px; +} + +.o_ActivityBox_titleLine { + border-color: gray('400'); + border-style: dashed; +} diff --git a/addons/mail/static/src/components/activity_box/activity_box.xml b/addons/mail/static/src/components/activity_box/activity_box.xml new file mode 100644 index 00000000000..2538ac9a462 --- /dev/null +++ b/addons/mail/static/src/components/activity_box/activity_box.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ActivityBox" owl="1"> + <div class="o_ActivityBox"> + <t t-if="chatter"> + <a role="button" class="o_ActivityBox_title btn" t-on-click="_onClickTitle"> + <hr class="o_ActivityBox_titleLine" /> + <span class="o_ActivityBox_titleText"> + <i class="fa fa-fw" t-att-class="chatter.isActivityBoxVisible ? 'fa-caret-right' : 'fa-caret-down'"/> + Planned activities + </span> + <t t-if="!chatter.isActivityBoxVisible"> + <span class="o_ActivityBox_titleBadges"> + <t t-if="chatter.overdueActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-danger"> + <t t-esc="chatter.overdueActivities.length"/> + </span> + </t> + <t t-if="chatter.todayActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-warning"> + <t t-esc="chatter.todayActivities.length"/> + </span> + </t> + <t t-if="chatter.futureActivities.length > 0"> + <span class="o_ActivityBox_titleBadge badge rounded-circle badge-success"> + <t t-esc="chatter.futureActivities.length"/> + </span> + </t> + </span> + </t> + <hr class="o_ActivityBox_titleLine" /> + </a> + <t t-if="chatter.isActivityBoxVisible"> + <div class="o_ActivityList"> + <t t-foreach="chatter.activities" t-as="activity" t-key="activity.localId"> + <Activity class="o_ActivityBox_activity" activityLocalId="activity.localId"/> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js new file mode 100644 index 00000000000..03e8f2ea54e --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js @@ -0,0 +1,83 @@ +odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ActivityMarkDonePopover extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + return { + activity: activity ? activity.__state : undefined, + }; + }); + this._feedbackTextareaRef = useRef('feedbackTextarea'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {string} + */ + get DONE_AND_SCHEDULE_NEXT() { + return this.env._t("Done & Schedule Next"); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickDiscard() { + this.trigger('o-popover-close'); + } + + /** + * @private + */ + _onClickDone() { + this.activity.markAsDone({ + feedback: this._feedbackTextareaRef.el.value, + }); + } + + /** + * @private + */ + _onClickDoneAndScheduleNext() { + this.activity.markAsDoneAndScheduleNext({ + feedback: this._feedbackTextareaRef.el.value, + }); + } + +} + +Object.assign(ActivityMarkDonePopover, { + props: { + activityLocalId: String, + }, + template: 'mail.ActivityMarkDonePopover', +}); + +return ActivityMarkDonePopover; + +}); diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss new file mode 100644 index 00000000000..1e15fa086c1 --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.scss @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ActivityMarkDonePopover { + align-content: space-between; + display: flex; + flex-flow: row wrap; + min-height: 100px; +} + +.o_ActivityMarkDonePopover_buttons { + display: flex; + flex: 1; + justify-content: space-between; + margin-top: map-get($spacers, 2); +} + +.o_ActivityMarkDonePopover_feedback { + min-height: 70px; +} + diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml new file mode 100644 index 00000000000..2cd22e993ec --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ActivityMarkDonePopover" owl="1"> + <div class="o_ActivityMarkDonePopover"> + <t t-if="activity"> + <textarea class="form-control o_ActivityMarkDonePopover_feedback" rows="3" placeholder="Write Feedback" t-ref="feedbackTextarea"/> + <div class="o_ActivityMarkDonePopover_buttons"> + <button type="button" class="o_ActivityMarkDonePopover_doneScheduleNextButton btn btn-sm btn-primary" t-on-click="_onClickDoneAndScheduleNext" t-esc="DONE_AND_SCHEDULE_NEXT"/> + <t t-if="!activity.force_next"> + <button type="button" class="o_ActivityMarkDonePopover_doneButton btn btn-sm btn-primary" t-on-click="_onClickDone"> + Done + </button> + <button type="button" class="o_ActivityMarkDonePopover_discardButton btn btn-sm btn-link" t-on-click="_onClickDiscard"> + Discard + </button> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js new file mode 100644 index 00000000000..0c04e492f24 --- /dev/null +++ b/addons/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js @@ -0,0 +1,282 @@ +odoo.define('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover_tests.js', function (require) { +'use strict'; + +const components = { + ActivityMarkDonePopover: require('mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js'), +}; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('activity_mark_done_popover', {}, function () { +QUnit.module('activity_mark_done_popover_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createActivityMarkDonePopoverComponent = async activity => { + const ActivityMarkDonePopoverComponent = components.ActivityMarkDonePopover; + ActivityMarkDonePopoverComponent.env = this.env; + this.component = new ActivityMarkDonePopoverComponent(null, { + activityLocalId: activity.localId, + }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + }, +}); + +QUnit.test('activity mark done popover simplest layout', async function (assert) { + assert.expect(6); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_feedback', + "Popover component should contain the feedback textarea" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_buttons', + "Popover component should contain the action buttons" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneScheduleNextButton', + "Popover component should contain the done & schedule next button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneButton', + "Popover component should contain the done button" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); +}); + +QUnit.test('activity with force next mark done popover simplest layout', async function (assert) { + assert.expect(6); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + force_next: true, + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_feedback', + "Popover component should contain the feedback textarea" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_buttons', + "Popover component should contain the action buttons" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_doneScheduleNextButton', + "Popover component should contain the done & schedule next button" + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover_doneButton', + "Popover component should NOT contain the done button" + ); + assert.containsNone( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should NOT contain the discard button" + ); +}); + +QUnit.test('activity mark done popover click on discard', async function (assert) { + assert.expect(4); + + await this.start(); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + function onPopoverClose(ev) { + assert.step('event_triggered'); + } + document.addEventListener('o-popover-close', onPopoverClose); + + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover', + "Popover component should be present" + ); + assert.containsOnce( + document.body, + '.o_ActivityMarkDonePopover_discardButton', + "Popover component should contain the discard button" + ); + document.querySelector('.o_ActivityMarkDonePopover_discardButton').click(); + assert.verifySteps(['event_triggered'], 'Discard clicked should trigger the right event'); + + document.removeEventListener('o-popover-close', onPopoverClose); +}); + +QUnit.test('activity mark done popover mark done without feedback', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + assert.step('action_feedback'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.attachment_ids.length, 0); + assert.notOk(args.kwargs.feedback); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + document.querySelector('.o_ActivityMarkDonePopover_doneButton').click(); + assert.verifySteps( + ['action_feedback'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +QUnit.test('activity mark done popover mark done with feedback', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { + assert.step('action_feedback'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.attachment_ids.length, 0); + assert.strictEqual(args.kwargs.feedback, 'This task is done'); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback'); + feedbackTextarea.focus(); + document.execCommand('insertText', false, 'This task is done'); + document.querySelector('.o_ActivityMarkDonePopover_doneButton').click(); + assert.verifySteps( + ['action_feedback'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +QUnit.test('activity mark done popover mark done and schedule next', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/dataset/call_kw/mail.activity/action_feedback_schedule_next') { + assert.step('action_feedback_schedule_next'); + assert.strictEqual(args.args.length, 1); + assert.strictEqual(args.args[0].length, 1); + assert.strictEqual(args.args[0][0], 12); + assert.strictEqual(args.kwargs.feedback, 'This task is done'); + return; + } + if (route === '/web/dataset/call_kw/mail.activity/unlink') { + // 'unlink' on non-existing record raises a server crash + throw new Error("'unlink' RPC on activity must not be called (already unlinked from mark as done)"); + } + return this._super(...arguments); + }, + }); + const activity = this.env.models['mail.activity'].create({ + canWrite: true, + category: 'not_upload_file', + id: 12, + }); + await this.createActivityMarkDonePopoverComponent(activity); + + let feedbackTextarea = document.querySelector('.o_ActivityMarkDonePopover_feedback'); + feedbackTextarea.focus(); + document.execCommand('insertText', false, 'This task is done'); + document.querySelector('.o_ActivityMarkDonePopover_doneScheduleNextButton').click(); + assert.verifySteps( + ['action_feedback_schedule_next'], + "Mark done and schedule next button should call the right rpc" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment/attachment.js b/addons/mail/static/src/components/attachment/attachment.js new file mode 100644 index 00000000000..76922ed7a3c --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.js @@ -0,0 +1,162 @@ +odoo.define('mail/static/src/components/attachment/attachment.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class Attachment extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const attachment = this.env.models['mail.attachment'].get(props.attachmentLocalId); + return { + attachment: attachment ? attachment.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment} + */ + get attachment() { + return this.env.models['mail.attachment'].get(this.props.attachmentLocalId); + } + + /** + * Return the url of the attachment. Temporary attachments, a.k.a. uploading + * attachments, do not have an url. + * + * @returns {string} + */ + get attachmentUrl() { + if (this.attachment.isTemporary) { + return ''; + } + return this.env.session.url('/web/content', { + id: this.attachment.id, + download: true, + }); + } + + /** + * Get the details mode after auto mode is computed + * + * @returns {string} 'card', 'hover' or 'none' + */ + get detailsMode() { + if (this.props.detailsMode !== 'auto') { + return this.props.detailsMode; + } + if (this.attachment.fileType !== 'image') { + return 'card'; + } + return 'hover'; + } + + /** + * Get the attachment representation style to be applied + * + * @returns {string} + */ + get imageStyle() { + if (this.attachment.fileType !== 'image') { + return ''; + } + let size; + if (this.detailsMode === 'card') { + size = '38x38'; + } else { + size = '160x160'; + } + return `background-image:url(/web/image/${this.attachment.id}/${size}/?crop=true);`; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Download the attachment when clicking on donwload icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDownload(ev) { + ev.stopPropagation(); + window.location = `/web/content/ir.attachment/${this.attachment.id}/datas?download=true`; + } + + /** + * Open the attachment viewer when clicking on viewable attachment. + * + * @private + * @param {MouseEvent} ev + */ + _onClickImage(ev) { + if (!this.attachment.isViewable) { + return; + } + this.env.models['mail.attachment'].view({ + attachment: this.attachment, + attachments: this.props.attachmentLocalIds.map( + attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId) + ), + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnlink(ev) { + ev.stopPropagation(); + this.attachment.remove(); + this.trigger('o-attachment-removed'); + } + +} + +Object.assign(Attachment, { + defaultProps: { + attachmentLocalIds: [], + detailsMode: 'auto', + imageSize: 'medium', + isDownloadable: false, + isEditable: true, + showExtension: true, + showFilename: true, + }, + props: { + attachmentLocalId: String, + attachmentLocalIds: { + type: Array, + element: String, + }, + detailsMode: { + type: String, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + imageSize: { + type: String, + validate: prop => ['small', 'medium', 'large'].includes(prop), + }, + isDownloadable: Boolean, + isEditable: Boolean, + showExtension: Boolean, + showFilename: Boolean, + }, + template: 'mail.Attachment', +}); + +return Attachment; + +}); diff --git a/addons/mail/static/src/components/attachment/attachment.scss b/addons/mail/static/src/components/attachment/attachment.scss new file mode 100644 index 00000000000..aebe44c82ba --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.scss @@ -0,0 +1,196 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Attachment { + display: flex; + + &:hover .o_Attachment_asideItemUnlink.o-pretty { + transform: translateX(0); + } +} + +.o_Attachment_action { + min-width: 20px; +} + +.o_Attachment_actions { + justify-content: space-between; + display: flex; + flex-direction: column; +} + +.o_Attachment_aside { + position: relative; + overflow: hidden; + + &:not(.o-has-multiple-action) { + min-width: 50px; + } + + &.o-has-multiple-action { + min-width: 30px; + display: flex; + flex-direction: column; + } +} + +.o_Attachment_asideItem { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} + +.o_Attachment_asideItemUnlink.o-pretty { + position: absolute; + top: 0; + transform: translateX(100%); +} + +.o_Attachment_details { + min-width: 0; /* This allows the text ellipsis in the flex element */ +} + +.o_Attachment_filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.o_Attachment_image { + margin: 3px; + + &.o-details-overlay { + position: relative; + + &.o-small { + min-width: 100px; + min-height: 100px; + } + &.o-medium { + min-width: 200px; + min-height: 200px; + } + &.o-large { + min-width: 400px; + min-height: 400px; + } + + &:hover { + .o_Attachment_imageOverlay { + opacity: 1; + } + } + } +} + +.o_Attachment_imageOverlay { + bottom: 0; + display:flex; + flex-direction: row; + justify-content: flex-end; + left: 0; + padding: 10px; + position: absolute; + right: 0; + top: 0; +} + +.o_Attachment_imageOverlayDetails { + display: flex; + flex-direction: column; + justify-content: flex-end; + margin: 3px; + width: 200px; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Attachment { + &.o-has-card-details { + background-color: gray('300'); + border-radius: 5px; + } +} + +.o_Attachment_action { + border-radius: 10px; + cursor: pointer; + text-align: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +.o_Attachment_aside { + border-radius: 0 5px 5px 0; +} + +.o_Attachment_asideItemDownload { + cursor: pointer; + + &:hover { + background-color: gray('400'); + } +} + +.o_Attachment_asideItemUnlink { + cursor: pointer; + + &:not(.o-pretty):hover { + background-color: gray('400'); + } + + &.o-pretty { + color: white; + background-color: $o-brand-primary; + + &:hover { + background-color: darken($o-brand-primary, 10%); + } + } + +} + +.o_Attachment_asideItemUploaded { + color: $o-brand-primary; +} + +.o_Attachment_extension { + text-transform: uppercase; + font-size: 80%; + font-weight: 400; +} + +.o_Attachment_image.o-attachment-viewable { + cursor: zoom-in; + + &:not(.o-details-overlay):hover { + opacity: 0.7; + } +} + +.o_Attachment_imageOverlay { + background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.9)); + border-radius: 5px; + color: white; + opacity: 0; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_Attachment_asideItemUnlink.o-pretty { + transition: transform 0.3s ease 0s; +} + +.o_Attachment_imageOverlay { + transition: all 0.3s ease 0s; +} diff --git a/addons/mail/static/src/components/attachment/attachment.xml b/addons/mail/static/src/components/attachment/attachment.xml new file mode 100644 index 00000000000..8af00288b8b --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment.xml @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Attachment" owl="1"> + <div class="o_Attachment" + t-att-class="{ + 'o-downloadable': props.isDownloadable, + 'o-editable': props.isEditable, + 'o-has-card-details': detailsMode === 'card', + 'o-temporary': attachment and attachment.isTemporary, + 'o-viewable': attachment and attachment.isViewable, + }" t-att-title="attachment ? attachment.displayName : undefined" t-att-data-attachment-local-id="attachment ? attachment.localId : undefined" + > + <t t-if="attachment"> + <!-- Image style--> + <!-- o_image from mimetype.scss --> + <div class="o_Attachment_image o_image" t-on-click="_onClickImage" + t-att-class="{ + 'o-attachment-viewable': attachment.isViewable, + 'o-details-overlay': detailsMode !== 'card', + 'o-large': props.imageSize === 'large', + 'o-medium': props.imageSize === 'medium', + 'o-small': props.imageSize === 'small', + }" t-att-href="attachmentUrl" t-att-style="imageStyle" t-att-data-mimetype="attachment.mimetype" + > + <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'hover'"> + <div class="o_Attachment_imageOverlay"> + <div class="o_Attachment_details o_Attachment_imageOverlayDetails"> + <t t-if="props.showFilename"> + <div class="o_Attachment_filename"> + <t t-esc="attachment.displayName"/> + </div> + </t> + <t t-if="props.showExtension"> + <div class="o_Attachment_extension"> + <t t-esc="attachment.extension"/> + </div> + </t> + </div> + <div class="o_Attachment_actions"> + <!-- Remove button --> + <t t-if="props.isEditable and !attachment.isTemporary" t-key="'unlink'"> + <div class="o_Attachment_action o_Attachment_actionUnlink" + t-att-class="{ + 'o-pretty': attachment.isLinkedToComposer, + }" t-on-click="_onClickUnlink" title="Remove" + > + <i class="fa fa-times"/> + </div> + </t> + <!-- Download button --> + <t t-if="props.isDownloadable and !attachment.isTemporary" t-key="'download'"> + <div class="o_Attachment_action o_Attachment_actionDownload" t-on-click="_onClickDownload" title="Download"> + <i class="fa fa-download"/> + </div> + </t> + </div> + </div> + </t> + </div> + <!-- Attachment details --> + <t t-if="(props.showFilename or props.showExtension) and detailsMode === 'card'"> + <div class="o_Attachment_details"> + <t t-if="props.showFilename"> + <div class="o_Attachment_filename"> + <t t-esc="attachment.displayName"/> + </div> + </t> + <t t-if="props.showExtension"> + <div class="o_Attachment_extension"> + <t t-esc="attachment.extension"/> + </div> + </t> + </div> + </t> + <!-- Attachment aside --> + <t t-if="detailsMode !== 'hover' and (props.isDownloadable or props.isEditable)"> + <div class="o_Attachment_aside" t-att-class="{ 'o-has-multiple-action': !attachment.isTemporary and props.isDownloadable and props.isEditable }"> + <!-- Uploading icon --> + <t t-if="attachment.isTemporary and attachment.isLinkedToComposer"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUploading" title="Uploading"> + <i class="fa fa-spin fa-spinner"/> + </div> + </t> + <!-- Uploaded icon --> + <t t-if="!attachment.isTemporary and attachment.isLinkedToComposer"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUploaded" title="Uploaded"> + <i class="fa fa-check"/> + </div> + </t> + <!-- Remove button --> + <t t-if="props.isEditable and !attachment.isTemporary"> + <div class="o_Attachment_asideItem o_Attachment_asideItemUnlink" t-att-class="{ 'o-pretty': attachment.isLinkedToComposer }" t-on-click="_onClickUnlink" title="Remove"> + <i class="fa fa-times"/> + </div> + </t> + <!-- Download button --> + <t t-if="props.isDownloadable and !attachment.isTemporary"> + <div class="o_Attachment_asideItem o_Attachment_asideItemDownload" t-on-click="_onClickDownload" title="Download"> + <i class="fa fa-download"/> + </div> + </t> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment/attachment_tests.js b/addons/mail/static/src/components/attachment/attachment_tests.js new file mode 100644 index 00000000000..224a80a30e7 --- /dev/null +++ b/addons/mail/static/src/components/attachment/attachment_tests.js @@ -0,0 +1,545 @@ +odoo.define('mail/static/src/components/attachment/attachment_tests.js', function (require) { +'use strict'; + +const components = { + Attachment: require('mail/static/src/components/attachment/attachment.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('attachment', {}, function () { +QUnit.module('attachment_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createAttachmentComponent = async (attachment, otherProps) => { + const AttachmentComponent = components.Attachment; + AttachmentComponent.env = this.env; + this.component = new AttachmentComponent(null, Object.assign({ + attachmentLocalId: attachment.localId, + }, otherProps)); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.Attachment.env; + }, +}); + +QUnit.test('simplest layout', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + const attachmentEl = document.querySelector('.o_Attachment'); + assert.strictEqual( + attachmentEl.dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 750).localId, + "attachment component should be linked to attachment store model" + ); + assert.strictEqual( + attachmentEl.title, + "test.txt", + "attachment should have filename as title attribute" + ); + assert.strictEqual( + attachmentEl.querySelectorAll(`:scope .o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + const attachmentImage = document.querySelector(`.o_Attachment_image`); + assert.ok( + attachmentImage.classList.contains('o_image'), + "attachment should have o_image classname (required for mimetype.scss style)" + ); + assert.strictEqual( + attachmentImage.dataset.mimetype, + 'text/plain', + "attachment should have data-mimetype set (required for mimetype.scss style)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside part" + ); +}); + +QUnit.test('simplest layout + deletable', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/750')) { + assert.ok( + route.includes('/160x160'), + "should fetch image with 160x160 pixels ratio"); + assert.step('fetch_image'); + return; + } + return this._super(...arguments); + }, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: false, + isEditable: true, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 1, + "attachment should have only one aside item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length, + 1, + "attachment should have a delete button" + ); +}); + +QUnit.test('simplest layout + downloadable', async function (assert) { + assert.expect(6); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: true, + isEditable: false, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 1, + "attachment should have only one aside item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemDownload`).length, + 1, + "attachment should have a download button" + ); +}); + +QUnit.test('simplest layout + deletable + downloadable', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'none', + isDownloadable: true, + isEditable: true, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll('.o_Attachment').length, + 1, + "should have attachment component in DOM" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 1, + "attachment should have an aside part" + ); + assert.ok( + document.querySelector(`.o_Attachment_aside`).classList.contains('o-has-multiple-action'), + "attachment aside should contain multiple actions" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItem`).length, + 2, + "attachment should have only two aside items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemDownload`).length, + 1, + "attachment should have a download button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_asideItemUnlink`).length, + 1, + "attachment should have a delete button" + ); +}); + +QUnit.test('layout with card details', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 0, + "attachment should not have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside part" + ); +}); + +QUnit.test('layout with card details and filename', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: false, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should not have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 0, + "attachment should have its extension shown" + ); +}); + +QUnit.test('layout with card details and extension', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: false + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 0, + "attachment should not have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); +}); + +QUnit.test('layout with card details and filename and extension', async function (assert) { + assert.expect(3); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'card', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_details`).length, + 1, + "attachment should have a details part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); +}); + +QUnit.test('simplest layout with hover details and filename and extension', async function (assert) { + assert.expect(8); + + await this.start(); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + await this.createAttachmentComponent(attachment, { + detailsMode: 'hover', + isDownloadable: true, + isEditable: true, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Attachment_details:not(.o_Attachment_imageOverlayDetails) + `).length, + 0, + "attachment should not have a details part directly" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length, + 1, + "attachment should have a details part in the overlay" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlay`).length, + 1, + "attachment should have an image overlay part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_actions`).length, + 1, + "attachment should have an actions part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside element" + ); +}); + +QUnit.test('auto layout with image', async function (assert) { + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/750')) { + return; + } + return this._super(...arguments); + }, + }); + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.png", + id: 750, + mimetype: 'image/png', + name: "test.png", + }); + + await this.createAttachmentComponent(attachment, { + detailsMode: 'auto', + isDownloadable: false, + isEditable: false, + showExtension: true, + showFilename: true + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Attachment_details:not(.o_Attachment_imageOverlayDetails) + `).length, + 0, + "attachment should not have a details part directly" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlayDetails`).length, + 1, + "attachment should have a details part in the overlay" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_image`).length, + 1, + "attachment should have an image part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_imageOverlay`).length, + 1, + "attachment should have an image overlay part" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_filename`).length, + 1, + "attachment should have its filename shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_extension`).length, + 1, + "attachment should have its extension shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Attachment_aside`).length, + 0, + "attachment should not have an aside element" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.js b/addons/mail/static/src/components/attachment_box/attachment_box.js new file mode 100644 index 00000000000..6e0c9d7c55e --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.js @@ -0,0 +1,118 @@ +odoo.define('mail/static/src/components/attachment_box/attachment_box.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class AttachmentBox extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + attachments: thread + ? thread.allAttachments.map(attachment => attachment.__state) + : [], + thread: thread ? thread.__state : undefined, + }; + }); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + originThread: [['link', this.thread]], + }; + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onAttachmentCreated(ev) { + // FIXME Could be changed by spying attachments count (task-2252858) + this.trigger('o-attachments-changed'); + } + + /** + * @private + * @param {Event} ev + */ + _onAttachmentRemoved(ev) { + // FIXME Could be changed by spying attachments count (task-2252858) + this.trigger('o-attachments-changed'); + } + + /** + * @private + * @param {Event} ev + */ + _onClickAdd(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this._fileUploaderRef.comp.openBrowserFileUploader(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + +} + +Object.assign(AttachmentBox, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.AttachmentBox', +}); + +return AttachmentBox; + +}); diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.scss b/addons/mail/static/src/components/attachment_box/attachment_box.scss new file mode 100644 index 00000000000..d51cca9c31c --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.scss @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentBox { + position: relative; +} + +.o_AttachmentBox_buttonAdd { + align-self: center; +} + +.o_AttachmentBox_content { + display: flex; + flex-direction: column; +} + +.o_AttachmentBox_dashedLine { + flex-grow: 1; +} + +.o_AttachmentBox_fileInput { + display: none; +} + +.o_AttachmentBox_title { + display: flex; + align-items: center; +} + +.o_AttachmentBox_titleText { + padding: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentBox_dashedLine { + border-style: dashed; + border-color: gray('300'); +} + +.o_AttachmentBox_title { + font-weight: bold; +} diff --git a/addons/mail/static/src/components/attachment_box/attachment_box.xml b/addons/mail/static/src/components/attachment_box/attachment_box.xml new file mode 100644 index 00000000000..9cd3e713401 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentBox" owl="1"> + <div class="o_AttachmentBox"> + <div class="o_AttachmentBox_title"> + <hr class="o_AttachmentBox_dashedLine"/> + <span class="o_AttachmentBox_titleText"> + Attachments + </span> + <hr class="o_AttachmentBox_dashedLine"/> + </div> + <div class="o_AttachmentBox_content"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_AttachmentBox_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <t t-if="thread and thread.allAttachments.length > 0"> + <AttachmentList + class="o_attachmentBox_attachmentList" + areAttachmentsDownloadable="true" + attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="'hover'" + attachmentsImageSize="'small'" + showAttachmentsFilenames="true" + t-on-o-attachment-removed="_onAttachmentRemoved" + /> + </t> + <button class="o_AttachmentBox_buttonAdd btn btn-link" type="button" t-on-click="_onClickAdd"> + <i class="fa fa-plus-square"/> + Add attachments + </button> + </div> + <t t-if="thread"> + <FileUploader + attachmentLocalIds="thread.allAttachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + uploadModel="thread.model" + uploadId="thread.id" + t-on-o-attachment-created="_onAttachmentCreated" + t-ref="fileUploader" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment_box/attachment_box_tests.js b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js new file mode 100644 index 00000000000..93690791d10 --- /dev/null +++ b/addons/mail/static/src/components/attachment_box/attachment_box_tests.js @@ -0,0 +1,228 @@ +odoo.define('mail/static/src/components/attachment_box/attachment_box_tests.js', function (require) { +"use strict"; + +const components = { + AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + dragenterFiles, + dropFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('attachment_box', {}, function () { +QUnit.module('attachment_box_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createAttachmentBoxComponent = async (thread, otherProps) => { + const AttachmentBoxComponent = components.AttachmentBox; + AttachmentBoxComponent.env = this.env; + this.component = new AttachmentBoxComponent(null, Object.assign({ + threadLocalId: thread.localId, + }, otherProps)); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.AttachmentBox.env; + this.env = undefined; + }, +}); + +QUnit.test('base empty rendering', async function (assert) { + assert.expect(4); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createAttachmentBoxComponent(thread); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox`).length, + 1, + "should have an attachment box" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length, + 1, + "should have a button add" + ); + assert.strictEqual( + document.querySelectorAll(`.o_FileUploader_input`).length, + 1, + "should have a file input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 0, + "should not have any attachment" + ); +}); + +QUnit.test('base non-empty rendering', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + assert.step('ir.attachment/search_read'); + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.fetchAttachments(); + await this.createAttachmentBoxComponent(thread); + assert.verifySteps( + ['ir.attachment/search_read'], + "should have fetched attachments" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox`).length, + 1, + "should have an attachment box" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox_buttonAdd`).length, + 1, + "should have a button add" + ); + assert.strictEqual( + document.querySelectorAll(`.o_FileUploader_input`).length, + 1, + "should have a file input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_attachmentBox_attachmentList`).length, + 1, + "should have an attachment list" + ); +}); + +QUnit.test('attachment box: drop attachments', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + return []; + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await thread.fetchAttachments(); + await this.createAttachmentBoxComponent(thread); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + ]; + assert.strictEqual( + document.querySelectorAll('.o_AttachmentBox').length, + 1, + "should have an attachment box" + ); + + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_AttachmentBox')) + ); + assert.ok( + document.querySelector('.o_AttachmentBox_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_AttachmentBox_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 1, + "should have 1 attachment in the box after files dropped" + ); + + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_AttachmentBox')) + ); + const file1 = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text2.txt', + }); + const file2 = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }); + await afterNextRender(() => + dropFiles( + document.querySelector('.o_AttachmentBox_dropZone'), + [file1, file2] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_AttachmentBox .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.js b/addons/mail/static/src/components/attachment_list/attachment_list.js new file mode 100644 index 00000000000..d097e5d705d --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.js @@ -0,0 +1,54 @@ +odoo.define('mail/static/src/components/attachment_list/attachment_list.js', function (require) { +'use strict'; + +const components = { + Attachment: require('mail/static/src/components/attachment/attachment.js'), +}; + +const { Component } = owl; + +class AttachmentList extends Component {} + +Object.assign(AttachmentList, { + components, + defaultProps: { + attachmentLocalIds: [], + }, + props: { + areAttachmentsDownloadable: { + type: Boolean, + optional: true, + }, + areAttachmentsEditable: { + type: Boolean, + optional: true, + }, + attachmentLocalIds: { + type: Array, + element: String, + }, + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + attachmentsImageSize: { + type: String, + optional: true, + validate: prop => ['small', 'medium', 'large'].includes(prop), + }, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + }, + template: 'mail.AttachmentList', +}); + +return AttachmentList; + +}); diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.scss b/addons/mail/static/src/components/attachment_list/attachment_list.scss new file mode 100644 index 00000000000..af4954818e0 --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.scss @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentList { + display: flex; + flex-flow: wrap; + justify-content: flex-start; +} + +.o_AttachmentList_attachment { + margin: map-get($spacers, 1); +} diff --git a/addons/mail/static/src/components/attachment_list/attachment_list.xml b/addons/mail/static/src/components/attachment_list/attachment_list.xml new file mode 100644 index 00000000000..452419105ff --- /dev/null +++ b/addons/mail/static/src/components/attachment_list/attachment_list.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentList" owl="1"> + <div class="o_AttachmentList"> + <t t-foreach="props.attachmentLocalIds" t-as="attachmentLocalId" t-key="attachmentLocalId"> + <Attachment + class="o_AttachmentList_attachment" + attachmentLocalId="attachmentLocalId" + attachmentLocalIds="props.attachmentLocalIds" + detailsMode="props.attachmentsDetailsMode" + imageSize="props.attachmentsImageSize" + isDownloadable="props.areAttachmentsDownloadable" + isEditable="props.areAttachmentsEditable" + showExtension="props.showAttachmentsExtensions" + showFilename="props.showAttachmentsFilenames" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js new file mode 100644 index 00000000000..54f5f383d3b --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.js @@ -0,0 +1,593 @@ +odoo.define('mail/static/src/components/attachment_viewer/attachment_viewer.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component, QWeb } = owl; +const { useRef } = owl.hooks; + +const MIN_SCALE = 0.5; +const SCROLL_ZOOM_STEP = 0.1; +const ZOOM_STEP = 0.5; + +class AttachmentViewer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.MIN_SCALE = MIN_SCALE; + useStore(props => { + const attachmentViewer = this.env.messaging.attachmentViewer; + return { + attachment: attachmentViewer && attachmentViewer.attachment + ? attachmentViewer.attachment.__state + : undefined, + attachments: attachmentViewer + ? attachmentViewer.attachments.map(attachment => attachment.__state) + : [], + attachmentViewer: attachmentViewer ? attachmentViewer.__state : undefined, + }; + }); + /** + * Determine whether the user is currently dragging the image. + * This is useful to determine whether a click outside of the image + * should close the attachment viewer or not. + */ + this._isDragging = false; + /** + * Reference to the image node. Useful in the computation of the zoomer + * style (based on user zoom in/out interactions). + */ + this._imageRef = useRef('image'); + /** + * Reference of the zoomer node. Useful to apply translate + * transformation on image visualisation. + */ + this._zoomerRef = useRef('zoomer'); + /** + * Tracked translate transformations on image visualisation. This is + * not observed with `useStore` because they are used to compute zoomer + * style, and this is changed directly on zoomer for performance + * reasons (overhead of making vdom is too significant for each mouse + * position changes while dragging) + */ + this._translate = { x: 0, y: 0, dx: 0, dy: 0 }; + /** + * Tracked last rendered attachment. Useful to detect a new image is + * loading, in order to display spinner until it is fully loaded. + */ + this._renderedAttachment = undefined; + this._onClickGlobal = this._onClickGlobal.bind(this); + } + + mounted() { + this.el.focus(); + this._handleImageLoad(); + this._renderedAttachment = this.attachmentViewer.attachment; + document.addEventListener('click', this._onClickGlobal); + } + + /** + * When a new image is displayed, show a spinner until it is loaded. + */ + patched() { + this._handleImageLoad(); + this._renderedAttachment = this.attachmentViewer.attachment; + } + + willUnmount() { + document.removeEventListener('click', this._onClickGlobal); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.attachment_viewer} + */ + get attachmentViewer() { + return this.env.messaging.attachmentViewer; + } + + /** + * Compute the style of the image (scale + rotation). + * + * @returns {string} + */ + get imageStyle() { + const attachmentViewer = this.attachmentViewer; + let style = `transform: ` + + `scale3d(${attachmentViewer.scale}, ${attachmentViewer.scale}, 1) ` + + `rotate(${attachmentViewer.angle}deg);`; + + if (attachmentViewer.angle % 180 !== 0) { + style += `` + + `max-height: ${window.innerWidth}px; ` + + `max-width: ${window.innerHeight}px;`; + } else { + style += `` + + `max-height: 100%; ` + + `max-width: 100%;`; + } + return style; + } + + /** + * Mandatory method for dialog components. + * Prevent closing the dialog when clicking on the mask when the user is + * currently dragging the image. + * + * @returns {boolean} + */ + isCloseable() { + return !this._isDragging; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Close the dialog with this attachment viewer. + * + * @private + */ + _close() { + this.attachmentViewer.delete(); + } + + /** + * Download the attachment. + * + * @private + */ + _download() { + const id = this.attachmentViewer.attachment.id; + this.env.services.navigate(`/web/content/ir.attachment/${id}/datas?download=true`); + } + + /** + * Determine whether the current image is rendered for the 1st time, and if + * that's the case, display a spinner until loaded. + * + * @private + */ + _handleImageLoad() { + if ( + this.attachmentViewer.attachment.fileType === 'image' && + this._renderedAttachment !== this.attachmentViewer.attachment + ) { + this.attachmentViewer.update({ isImageLoading: true }); + this._imageRef.el.addEventListener('load', ev => this._onLoadImage(ev)); + } + } + + /** + * Display the previous attachment in the list of attachments. + * + * @private + */ + _next() { + const attachmentViewer = this.attachmentViewer; + const index = attachmentViewer.attachments.findIndex(attachment => + attachment === attachmentViewer.attachment + ); + const nextIndex = (index + 1) % attachmentViewer.attachments.length; + attachmentViewer.update({ + attachment: [['link', attachmentViewer.attachments[nextIndex]]], + }); + } + + /** + * Display the previous attachment in the list of attachments. + * + * @private + */ + _previous() { + const attachmentViewer = this.attachmentViewer; + const index = attachmentViewer.attachments.findIndex(attachment => + attachment === attachmentViewer.attachment + ); + const nextIndex = index === 0 + ? attachmentViewer.attachments.length - 1 + : index - 1; + attachmentViewer.update({ + attachment: [['link', attachmentViewer.attachments[nextIndex]]], + }); + } + + /** + * Prompt the browser print of this attachment. + * + * @private + */ + _print() { + const printWindow = window.open('about:blank', '_new'); + printWindow.document.open(); + printWindow.document.write(` + <html> + <head> + <script> + function onloadImage() { + setTimeout('printImage()', 10); + } + function printImage() { + window.print(); + window.close(); + } + </script> + </head> + <body onload='onloadImage()'> + <img src="${this.attachmentViewer.attachment.defaultSource}" alt=""/> + </body> + </html>`); + printWindow.document.close(); + } + + /** + * Rotate the image by 90 degrees to the right. + * + * @private + */ + _rotate() { + this.attachmentViewer.update({ angle: this.attachmentViewer.angle + 90 }); + } + + /** + * Stop dragging interaction of the user. + * + * @private + */ + _stopDragging() { + this._isDragging = false; + this._translate.x += this._translate.dx; + this._translate.y += this._translate.dy; + this._translate.dx = 0; + this._translate.dy = 0; + this._updateZoomerStyle(); + } + + /** + * Update the style of the zoomer based on translate transformation. Changes + * are directly applied on zoomer, instead of triggering re-render and + * defining them in the template, for performance reasons. + * + * @private + * @returns {string} + */ + _updateZoomerStyle() { + const attachmentViewer = this.attachmentViewer; + const tx = this._imageRef.el.offsetWidth * attachmentViewer.scale > this._zoomerRef.el.offsetWidth + ? this._translate.x + this._translate.dx + : 0; + const ty = this._imageRef.el.offsetHeight * attachmentViewer.scale > this._zoomerRef.el.offsetHeight + ? this._translate.y + this._translate.dy + : 0; + if (tx === 0) { + this._translate.x = 0; + } + if (ty === 0) { + this._translate.y = 0; + } + this._zoomerRef.el.style = `transform: ` + + `translate(${tx}px, ${ty}px)`; + } + + /** + * Zoom in the image. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.scroll=false] + */ + _zoomIn({ scroll = false } = {}) { + this.attachmentViewer.update({ + scale: this.attachmentViewer.scale + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP), + }); + this._updateZoomerStyle(); + } + + /** + * Zoom out the image. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.scroll=false] + */ + _zoomOut({ scroll = false } = {}) { + if (this.attachmentViewer.scale === MIN_SCALE) { + return; + } + const unflooredAdaptedScale = ( + this.attachmentViewer.scale - + (scroll ? SCROLL_ZOOM_STEP : ZOOM_STEP) + ); + this.attachmentViewer.update({ + scale: Math.max(MIN_SCALE, unflooredAdaptedScale), + }); + this._updateZoomerStyle(); + } + + /** + * Reset the zoom scale of the image. + * + * @private + */ + _zoomReset() { + this.attachmentViewer.update({ scale: 1 }); + this._updateZoomerStyle(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on mask of attachment viewer. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (this._isDragging) { + return; + } + // TODO: clicking on the background should probably be handled by the dialog? + // task-2092965 + this._close(); + } + + /** + * Called when clicking on cross icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + this._close(); + } + + /** + * Called when clicking on download icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDownload(ev) { + ev.stopPropagation(); + this._download(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickGlobal(ev) { + if (!this._isDragging) { + return; + } + ev.stopPropagation(); + this._stopDragging(); + } + + /** + * Called when clicking on the header. Stop propagation of event to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickHeader(ev) { + ev.stopPropagation(); + } + + /** + * Called when clicking on image. Stop propagation of event to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickImage(ev) { + if (this._isDragging) { + return; + } + ev.stopPropagation(); + } + + /** + * Called when clicking on next icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickNext(ev) { + ev.stopPropagation(); + this._next(); + } + + /** + * Called when clicking on previous icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickPrevious(ev) { + ev.stopPropagation(); + this._previous(); + } + + /** + * Called when clicking on print icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickPrint(ev) { + ev.stopPropagation(); + this._print(); + } + + /** + * Called when clicking on rotate icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickRotate(ev) { + ev.stopPropagation(); + this._rotate(); + } + + /** + * Called when clicking on embed video player. Stop propagation to prevent + * closing the dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClickVideo(ev) { + ev.stopPropagation(); + } + + /** + * Called when clicking on zoom in icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomIn(ev) { + ev.stopPropagation(); + this._zoomIn(); + } + + /** + * Called when clicking on zoom out icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomOut(ev) { + ev.stopPropagation(); + this._zoomOut(); + } + + /** + * Called when clicking on reset zoom icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickZoomReset(ev) { + ev.stopPropagation(); + this._zoomReset(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + switch (ev.key) { + case 'ArrowRight': + this._next(); + break; + case 'ArrowLeft': + this._previous(); + break; + case 'Escape': + this._close(); + break; + case 'q': + this._close(); + break; + case 'r': + this._rotate(); + break; + case '+': + this._zoomIn(); + break; + case '-': + this._zoomOut(); + break; + case '0': + this._zoomReset(); + break; + default: + return; + } + ev.stopPropagation(); + } + + /** + * Called when new image has been loaded + * + * @private + * @param {Event} ev + */ + _onLoadImage(ev) { + ev.stopPropagation(); + this.attachmentViewer.update({ isImageLoading: false }); + } + + /** + * @private + * @param {DragEvent} ev + */ + _onMousedownImage(ev) { + if (this._isDragging) { + return; + } + if (ev.button !== 0) { + return; + } + ev.stopPropagation(); + this._isDragging = true; + this._dragstartX = ev.clientX; + this._dragstartY = ev.clientY; + } + + /** + * @private + * @param {DragEvent} + */ + _onMousemoveView(ev) { + if (!this._isDragging) { + return; + } + this._translate.dx = ev.clientX - this._dragstartX; + this._translate.dy = ev.clientY - this._dragstartY; + this._updateZoomerStyle(); + } + + /** + * @private + * @param {Event} ev + */ + _onWheelImage(ev) { + ev.stopPropagation(); + if (!this.el) { + return; + } + if (ev.deltaY > 0) { + this._zoomIn({ scroll: true }); + } else { + this._zoomOut({ scroll: true }); + } + } + +} + +Object.assign(AttachmentViewer, { + props: {}, + template: 'mail.AttachmentViewer', +}); + +QWeb.registerComponent('AttachmentViewer', AttachmentViewer); + +return AttachmentViewer; + +}); diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss new file mode 100644 index 00000000000..54f00c1ae5d --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.scss @@ -0,0 +1,198 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + display: flex; + width: 100%; + height: 100%; + flex-flow: column; + align-items: center; + z-index: -1; +} + +.o_AttachmentViewer_buttonNavigation { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + top: 50%; + transform: translateY(-50%); +} + +.o_AttachmentViewer_buttonNavigationNext { + right: 15px; + + > .fa { + margin: 1px 0 0 1px; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_buttonNavigationPrevious { + left: 15px; + + > .fa { + margin: 1px 1px 0 0; // not correctly centered for some reasons + } +} + +.o_AttachmentViewer_header { + display: flex; + height: $o-navbar-height; + align-items: center; + padding: 0 15px; + width: 100%; +} + +.o_AttachmentViewer_headerItem { + margin: 0 5px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } +} + +.o_AttachmentViewer_loading { + position: absolute; +} + +.o_AttachmentViewer_main { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: -1; + padding: 45px 0; + + &.o_with_img { + overflow: hidden; + } +} + +.o_AttachmentViewer_toolbar { + position: absolute; + bottom: 45px; + transform: translateY(100%); + display: flex; +} + +.o_AttachmentViewer_toolbarButton { + padding: 8px; +} + +.o_AttachmentViewer_viewImage { + max-height: 100%; + max-width: 100%; +} + +.o_AttachmentViewer_viewIframe { + width: 90%; + height: 100%; +} + +.o_AttachmentViewer_viewVideo { + width: 75%; + height: 75%; +} + +.o_AttachmentViewer_zoomer { + position: absolute; + padding: 45px 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_AttachmentViewer { + outline: none; +} + +.o_AttachmentViewer_buttonNavigation { + color: gray('400'); + background-color: lighten(black, 15%); + border-radius: 100%; + cursor: pointer; + + &:hover { + color: lighten(gray('400'), 15%); + background-color: black; + } +} + +.o_AttachmentViewer_header { + background-color: rgba(0, 0, 0, 0.7); + color: gray('400'); +} + +.o_AttachmentViewer_headerItemButton { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + color: lighten(gray('400'), 15%); + } +} + +.o_AttachmentViewer_headerItemButtonClose { + cursor: pointer; + font-size: 1.3rem; +} + +.o_AttachmentViewer_toolbar { + cursor: pointer; +} + +.o_AttachmentViewer_toolbarButton { + background-color: lighten(black, 15%); + + &.o_disabled { + cursor: not-allowed; + filter: brightness(1.3); + } + + &:not(.o_disabled) { + color: gray('400'); + cursor: pointer; + + &:hover { + background-color: black; + color: lighten(gray('400'), 15%); + } + } +} + +.o_AttachmentViewer_view { + background-color: black; + box-shadow: 0 0 40px black; + outline: none; + border: none; + + &.o_text { + background-color: white; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_AttachmentViewer_viewImage { + transition: transform 0.3s ease; +} + diff --git a/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml new file mode 100644 index 00000000000..af7967b164f --- /dev/null +++ b/addons/mail/static/src/components/attachment_viewer/attachment_viewer.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AttachmentViewer" owl="1"> + <div class="o_AttachmentViewer" t-on-click="_onClick" t-on-keydown="_onKeydown" tabindex="0" t-att-data-dialog="attachmentViewer.dialog.localId"> + <div class="o_AttachmentViewer_header" t-on-click="_onClickHeader"> + <t t-if="attachmentViewer.attachment.fileType"> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_icon"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <i class="fa fa-picture-o" role="img" title="Image"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <i class="fa fa-file-text" role="img" title="PDF file"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <i class="fa fa-file-text" role="img" title="Text file"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <i class="fa fa-video-camera" role="img" title="Video"/> + </t> + </div> + </t> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_name"> + <t t-esc="attachmentViewer.attachment.displayName"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton" t-on-click="_onClickDownload" role="button" title="Download"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + <div class="o-autogrow"/> + <div class="o_AttachmentViewer_headerItem o_AttachmentViewer_headerItemButton o_AttachmentViewer_headerItemButtonClose" t-on-click="_onClickClose" role="button" title="Close (Esc)" aria-label="Close"> + <i class="fa fa-fw fa-times" role="img"/> + </div> + </div> + <div class="o_AttachmentViewer_main" t-att-class="{ o_with_img: attachmentViewer.attachment.fileType === 'image' }" t-on-mousemove="_onMousemoveView"> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_zoomer" t-ref="zoomer"> + <t t-if="attachmentViewer.isImageLoading"> + <div class="o_AttachmentViewer_loading"> + <i class="fa fa-3x fa-circle-o-notch fa-fw fa-spin" role="img" title="Loading"/> + </div> + </t> + <img class="o_AttachmentViewer_view o_AttachmentViewer_viewImage" t-on-click="_onClickImage" t-on-mousedown="_onMousedownImage" t-on-wheel="_onWheelImage" t-att-src="attachmentViewer.attachment.defaultSource" t-att-style="imageStyle" draggable="false" alt="Viewer" t-ref="image"/> + </div> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'application/pdf'"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_viewPdf" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.isTextFile"> + <iframe class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_text" t-att-src="attachmentViewer.attachment.defaultSource"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'youtu'"> + <iframe allow="autoplay; encrypted-media" class="o_AttachmentViewer_view o_AttachmentViewer_viewIframe o_AttachmentViewer_youtube" t-att-src="attachmentViewer.attachment.defaultSource" height="315" width="560"/> + </t> + <t t-if="attachmentViewer.attachment.fileType === 'video'"> + <video class="o_AttachmentViewer_view o_AttachmentViewer_viewVideo" t-on-click="_onClickVideo" controls="controls"> + <source t-att-data-type="attachmentViewer.attachment.mimetype" t-att-src="attachmentViewer.attachment.defaultSource"/> + </video> + </t> + </div> + <t t-if="attachmentViewer.attachment.fileType === 'image'"> + <div class="o_AttachmentViewer_toolbar" role="toolbar"> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickZoomIn" title="Zoom In (+)" role="button"> + <i class="fa fa-fw fa-plus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === 1 }" t-on-click="_onClickZoomReset" role="button" title="Reset Zoom (0)"> + <i class="fa fa-fw fa-search" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-att-class="{ o_disabled: attachmentViewer.scale === MIN_SCALE }" t-on-click="_onClickZoomOut" title="Zoom Out (-)" role="button"> + <i class="fafa-fw fa-minus" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickRotate" title="Rotate (r)" role="button"> + <i class="fa fa-fw fa-repeat" role="img"/> + </div> + <div class="o_AttachmentViewer_toolbarButton" t-on-click="_onClickPrint" title="Print" role="button"> + <i class="fa fa-fw fa-print" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonDownload o_AttachmentViewer_toolbarButton" t-on-click="_onClickDownload" title="Download" role="button"> + <i class="fa fa-download fa-fw" role="img"/> + </div> + </div> + </t> + <t t-if="attachmentViewer.attachments.length > 1"> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationPrevious" t-on-click="_onClickPrevious" title="Previous (Left-Arrow)" role="button"> + <span class="fa fa-chevron-left" role="img"/> + </div> + <div class="o_AttachmentViewer_buttonNavigation o_AttachmentViewer_buttonNavigationNext" t-on-click="_onClickNext" title="Next (Right-Arrow)" role="button"> + <span class="fa fa-chevron-right" role="img"/> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js new file mode 100644 index 00000000000..1e383f7b307 --- /dev/null +++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.js @@ -0,0 +1,161 @@ +odoo.define('mail/static/src/components/autocomplete_input/autocomplete_input.js', function (require) { +'use strict'; + +const { Component } = owl; + +class AutocompleteInput extends Component { + + mounted() { + if (this.props.isFocusOnMount) { + this.el.focus(); + } + + let args = { + select: (ev, ui) => this._onAutocompleteSelect(ev, ui), + source: (req, res) => this._onAutocompleteSource(req, res), + focus: ev => this._onAutocompleteFocus(ev), + html: this.props.isHtml || false, + }; + + if (this.props.customClass) { + args.classes = { 'ui-autocomplete': this.props.customClass }; + } + + $(this.el).autocomplete(args); + } + + willUnmount() { + $(this.el).autocomplete('destroy'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the given node is self or a children of self, including + * the suggestion menu. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + if (this.el.contains(node)) { + return true; + } + if (!this.props.customClass) { + return false; + } + const element = document.querySelector(`.${this.props.customClass}`); + if (!element) { + return false; + } + return element.contains(node); + } + + focus() { + if (!this.el) { + return; + } + this.el.focus(); + } + + focusout() { + if (!this.el) { + return; + } + this.el.blur(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {FocusEvent} ev + */ + _onAutocompleteFocus(ev) { + if (this.props.focus) { + this.props.focus(ev); + } else { + ev.preventDefault(); + } + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + */ + _onAutocompleteSelect(ev, ui) { + ev.stopPropagation(); + if (this.props.select) { + this.props.select(ev, ui); + } + } + + /** + * @private + * @param {Object} req + * @param {function} res + */ + _onAutocompleteSource(req, res) { + if (this.props.source) { + this.props.source(req, res); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onBlur(ev) { + this.trigger('o-hide'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onKeydown(ev) { + if (ev.key === 'Escape') { + this.trigger('o-hide'); + } + } + +} + +Object.assign(AutocompleteInput, { + defaultProps: { + isFocusOnMount: false, + isHtml: false, + placeholder: '', + }, + props: { + customClass: { + type: String, + optional: true, + }, + focus: { + type: Function, + optional: true, + }, + isFocusOnMount: Boolean, + isHtml: Boolean, + placeholder: String, + select: { + type: Function, + optional: true, + }, + source: { + type: Function, + optional: true, + }, + }, + template: 'mail.AutocompleteInput', +}); + +return AutocompleteInput; + +}); diff --git a/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml new file mode 100644 index 00000000000..ffa1bc890a4 --- /dev/null +++ b/addons/mail/static/src/components/autocomplete_input/autocomplete_input.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.AutocompleteInput" owl="1"> + <input class="o_AutocompleteInput" t-on-blur="_onBlur" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js new file mode 100644 index 00000000000..8d1851c78fc --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.js @@ -0,0 +1,352 @@ +odoo.define('mail/static/src/components/chat_window/chat_window.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'), + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindow extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + return { + chatWindow: chatWindow ? chatWindow.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + localeTextDirection: this.env.messaging.locale.textDirection, + }; + }); + /** + * Reference of the header of the chat window. + * Useful to prevent click on header from wrongly focusing the window. + */ + this._chatWindowHeaderRef = useRef('header'); + /** + * Reference of the autocomplete input (new_message chat window only). + * Useful when focusing this chat window, which consists of focusing + * this input. + */ + this._inputRef = useRef('input'); + /** + * Reference of thread in the chat window (chat window with thread + * only). Useful when focusing this chat window, which consists of + * focusing this thread. Will likely focus the composer of thread, if + * it has one! + */ + this._threadRef = useRef('thread'); + // the following are passed as props to children + this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this); + this._onAutocompleteSource = this._onAutocompleteSource.bind(this); + } + + mounted() { + this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this)); + this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this)); + this._update(); + } + + patched() { + this._update(); + } + + willUnmount() { + this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this)); + this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this)); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chat_window} + */ + get chatWindow() { + return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId); + } + + /** + * Get the content of placeholder for the autocomplete input of + * 'new_message' chat window. + * + * @returns {string} + */ + get newMessageFormInputPlaceholder() { + return this.env._t("Search user..."); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply visual position of the chat window. + * + * @private + */ + _applyVisibleOffset() { + const textDirection = this.env.messaging.locale.textDirection; + const offsetFrom = textDirection === 'rtl' ? 'left' : 'right'; + const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right'; + this.el.style[offsetFrom] = this.chatWindow.visibleOffset + 'px'; + this.el.style[oppositeFrom] = 'auto'; + } + + /** + * Focus this chat window. + * + * @private + */ + _focus() { + this.chatWindow.update({ + isDoFocus: false, + isFocused: true, + }); + if (!this.chatWindow.thread) { + this._inputRef.comp.focus(); + } else { + this._threadRef.comp.focus(); + } + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + _saveThreadScrollTop() { + this.chatWindow.update({ + threadInitialScrollTop: this._threadRef.comp.getScrollTop(), + }); + } + + /** + * @private + */ + _update() { + if (!this.chatWindow) { + // chat window is being deleted + return; + } + if (this.chatWindow.isDoFocus) { + this._focus(); + } + this._applyVisibleOffset(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when selecting an item in the autocomplete input of the + * 'new_message' chat window. + * + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAutocompleteSelect(ev, ui) { + const partnerId = ui.item.id; + const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId); + const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); + if (chat) { + chat.open({ chatWindowMode: 'from_new_message' }); + } else { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + autoselectChatWindowMode: 'from_new_message', + partnerId, + type: 'chat', + }); + } + } + + /** + * Called when typing in the autocomplete input of the 'new_message' chat + * window. + * + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAutocompleteSource(req, res) { + this.env.models['mail.partner'].imSearch({ + callback: (partners) => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: _.escape(req.term), + limit: 10, + }); + } + + /** + * Handle focus of the chat window based on position of click. The click on + * chat window that folds it should NOT set focus on this chat window. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const chatWindowHeader = this._chatWindowHeaderRef.el; + if (chatWindowHeader && chatWindowHeader.contains(ev.target)) { + // handled in _onClickedHeader + return; + } + if (this.chatWindow.isFocused) { + return; + } + this.chatWindow.focus(); + } + + /** + * Called when clicking on header of chat window. Usually folds the chat + * window. + * + * @private + * @param {CustomEvent} ev + */ + _onClickedHeader(ev) { + ev.stopPropagation(); + if (this.env.messaging.device.isMobile) { + return; + } + if (this.chatWindow.isFolded) { + this.chatWindow.unfold(); + this.chatWindow.focus(); + } else { + this.chatWindow.fold(); + } + } + + /** + * Called when an element in the thread becomes focused. + * + * @private + * @param {FocusEvent} ev + */ + _onFocusinThread(ev) { + ev.stopPropagation(); + this.chatWindow.update({ isFocused: true }); + } + + /** + * Focus out the chat window. + * + * @private + */ + _onFocusout() { + if (this._inputRef.comp) { + this._inputRef.comp.focusout(); + } + if (this._threadRef.comp) { + this._threadRef.comp.focusout(); + } + if (!this.chatWindow) { + // ignore focus out due to record being deleted + return; + } + this.chatWindow.update({ isFocused: false }); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + /** + * Prevent auto-focus of fuzzy search in the home menu. + * Useful in order to allow copy/paste content inside chat window with + * CTRL-C & CTRL-V when on the home menu. + */ + ev.stopPropagation(); + switch (ev.key) { + case 'Tab': + ev.preventDefault(); + if (ev.shiftKey) { + this.chatWindow.focusPreviousVisibleUnfoldedChatWindow(); + } else { + this.chatWindow.focusNextVisibleUnfoldedChatWindow(); + } + break; + case 'Escape': + ev.preventDefault(); + this.chatWindow.close(); + break; + } + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + async _onWillHideHomeMenu() { + if (!this.chatWindow.thread) { + return; + } + this._saveThreadScrollTop(); + } + + /** + * Save the scroll positions of the chat window in the store. + * This is useful in order to remount chat windows and keep previous + * scroll positions. This is necessary because when toggling on/off + * home menu, the chat windows have to be remade from scratch. + * + * @private + */ + async _onWillShowHomeMenu() { + if (!this.chatWindow.thread) { + return; + } + this._saveThreadScrollTop(); + } + +} + +Object.assign(ChatWindow, { + components, + defaultProps: { + hasCloseAsBackButton: false, + isExpandable: false, + isFullscreen: false, + }, + props: { + chatWindowLocalId: String, + hasCloseAsBackButton: Boolean, + isExpandable: Boolean, + isFullscreen: Boolean, + }, + template: 'mail.ChatWindow', +}); + +return ChatWindow; + +}); diff --git a/addons/mail/static/src/components/chat_window/chat_window.scss b/addons/mail/static/src/components/chat_window/chat_window.scss new file mode 100644 index 00000000000..299da88575e --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.scss @@ -0,0 +1,101 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindow { + position: absolute; + bottom: 0; + display: flex; + flex-flow: column; + + &:not(.o-mobile) { + max-width: 100%; + max-height: 100%; + width: 325px; + + &.o-folded { + height: $o-mail-chat-window-header-height; + } + + &:not(.o-folded) { + height: 400px; + } + } + + &.o-mobile { + position: fixed; + } + + &.o-fullscreen { + height: 100%; + width: 100%; + } +} + +.o_ChatWindow_header { + flex: 0 0 auto; +} + +.o_ChatWindow_newMessageForm { + padding: 3px; + margin-top: 3px; + display: flex; + align-items: center; +} + +.o_ChatWindow_newMessageFormInput { + flex: 1 1 auto; +} + +.o_ChatWindow_newMessageFormLabel { + margin-right: 5px; + flex: 0 0 auto; +} + +.o_ChatWindow_thread { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindow { + background-color: $o-mail-thread-window-bg; + border-radius: 6px 6px 0 0; + box-shadow: -5px -5px 10px rgba(black, 0.09); + outline: none; + + &:not(.o-mobile) { + + &.o-focused { + box-shadow: -5px -5px 10px rgba(black, 0.18); + } + } + + + .o_Composer { + border: 0; + } +} + +.o_ChatWindow_header { + border-radius: 3px 3px 0 0; +} + +.o_ChatWindow_newMessageFormInput { + outline: none; + border: 1px solid gray('300'); // cancel firefox border on input focus +} + +.o_ChatWindow_thread .o_ThreadViewer_messageList { + font-size: 1rem; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_ChatWindow:not(.o-mobile) { + transition: height 0.3s; +} diff --git a/addons/mail/static/src/components/chat_window/chat_window.xml b/addons/mail/static/src/components/chat_window/chat_window.xml new file mode 100644 index 00000000000..c4f6117a25d --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindow" owl="1"> + <div class="o_ChatWindow" tabindex="0" + t-att-class="{ + 'o-focused': chatWindow and chatWindow.isFocused, + 'o-folded': chatWindow and chatWindow.isFolded, + 'o-fullscreen': props.isFullscreen, + 'o-mobile': env.messaging.device.isMobile, + 'o-new-message': chatWindow and !chatWindow.thread, + }" t-on-click="_onClick" t-on-keydown="_onKeydown" t-on-focusout="_onFocusout" t-att-data-chat-window-local-id="chatWindow ? chatWindow.localId : undefined" t-att-data-thread-local-id="chatWindow ? (chatWindow.thread ? chatWindow.thread.localId : '') : undefined" + > + <t t-if="chatWindow"> + <ChatWindowHeader + class="o_ChatWindow_header" + chatWindowLocalId="chatWindow.localId" + hasCloseAsBackButton="props.hasCloseAsBackButton" + isExpandable="props.isExpandable" + t-on-o-clicked="_onClickedHeader" + t-ref="header" + /> + <t t-if="chatWindow.thread"> + <ThreadViewer + class="o_ChatWindow_thread" + composerAttachmentsDetailsMode="'card'" + hasComposer="chatWindow.thread.model !== 'mail.box'" + hasComposerCurrentPartnerAvatar="false" + hasComposerSendButton="env.messaging.device.isMobile" + hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'" + haveMessagesAuthorRedirect="chatWindow.thread.channel_type !== 'chat'" + threadViewerLocalId="chatWindow.threadViewer.localId" + t-on-focusin="_onFocusinThread" + t-ref="thread" + /> + </t> + <t t-else=""> + <div class="o_ChatWindow_newMessageForm"> + <span class="o_ChatWindow_newMessageFormLabel"> + To: + </span> + <AutocompleteInput + class="o_ChatWindow_newMessageFormInput" + isHtml="true" + placeholder="newMessageFormInputPlaceholder" + select="_onAutocompleteSelect" + source="_onAutocompleteSource" + t-ref="input" + /> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.js b/addons/mail/static/src/components/chat_window_header/chat_window_header.js new file mode 100644 index 00000000000..60294ffc213 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.js @@ -0,0 +1,107 @@ +odoo.define('mail/static/src/components/chat_window_header/chat_window_header.js', function (require) { +'use strict'; + +const components = { + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatWindowHeader extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + const thread = chatWindow && chatWindow.thread; + return { + chatWindow: chatWindow ? chatWindow.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chat_window} + */ + get chatWindow() { + return this.env.models['mail.chat_window'].get(this.props.chatWindowLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const chatWindow = this.chatWindow; + this.trigger('o-clicked', { chatWindow }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + ev.stopPropagation(); + this.chatWindow.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickExpand(ev) { + ev.stopPropagation(); + this.chatWindow.expand(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickShiftLeft(ev) { + ev.stopPropagation(); + this.chatWindow.shiftLeft(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickShiftRight(ev) { + ev.stopPropagation(); + this.chatWindow.shiftRight(); + } + +} + +Object.assign(ChatWindowHeader, { + components, + defaultProps: { + hasCloseAsBackButton: false, + isExpandable: false, + }, + props: { + chatWindowLocalId: String, + hasCloseAsBackButton: Boolean, + isExpandable: Boolean, + }, + template: 'mail.ChatWindowHeader', +}); + +return ChatWindowHeader; + +}); diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.scss b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss new file mode 100644 index 00000000000..c5c23634d20 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.scss @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowHeader { + display: flex; + align-items: center; + height: $o-mail-chat-window-header-height; + + &.o-mobile { + height: $o-mail-chat-window-header-height-mobile; + } +} + +.o_ChatWindowHeader_command { + padding: 0 8px; + display: flex; + height: 100%; + align-items: center; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } +} + +.o_ChatWindowHeader_commandBack { + margin-right: 5px; +} + +.o_ChatWindowHeader_item { + margin: 0 3px; + + &.o_ChatWindowHeader_rightArea { + margin-right: 0; + } + + &:first-child { + margin-left: 10px; + + &.o_ChatWindowHeader_command { + margin-left: 0px; // no margin for commands + } + } + + &.o_ChatWindowHeader_rightArea:last-child .o_ChatWindowHeader_command { + margin-right: 0px; // no margin for commands + } +} + +.o_ChatWindowHeader_name { + max-height: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.o_ChatWindowHeader_rightArea { + display: flex; + height: 100%; + align-items: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindowHeader { + background-color: $o-brand-odoo; + color: white; + cursor: pointer; + + &:not(.o-mobile) { + + &:hover .o_ChatWindowHeader_command { + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + &:not(:hover) .o_ChatWindowHeader_command { + opacity: 0.5; + } + } + +} + +.o_ChatWindowHeader_command.o-mobile { + font-size: 1.3rem; +} + +.o_ChatWindowHeader_name { + user-select: none; +} diff --git a/addons/mail/static/src/components/chat_window_header/chat_window_header.xml b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml new file mode 100644 index 00000000000..0c3261282cc --- /dev/null +++ b/addons/mail/static/src/components/chat_window_header/chat_window_header.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowHeader" owl="1"> + <div class="o_ChatWindowHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClick"> + <t t-if="chatWindow"> + <t t-if="props.hasCloseAsBackButton"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandBack o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close conversation"> + <i class="fa fa-arrow-left"/> + </div> + </t> + <t t-if="chatWindow.thread"> + <ThreadIcon + class="o_ChatWindowHeader_icon o_ChatWindowHeader_item" + threadLocalId="chatWindow.thread.localId" + /> + </t> + <div class="o_ChatWindowHeader_item o_ChatWindowHeader_name" t-att-title="chatWindow.name"> + <t t-esc="chatWindow.name"/> + </div> + <t t-if="chatWindow.thread and chatWindow.thread.message_unread_counter > 0"> + <div class="o_ChatWindowHeader_counter o_ChatWindowHeader_item"> + (<t t-esc="chatWindow.thread.message_unread_counter"/>) + </div> + </t> + <div class="o-autogrow"/> + <div class="o_ChatWindowHeader_item o_ChatWindowHeader_rightArea"> + <t t-if="chatWindow.hasShiftLeft"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftLeft" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftLeft" title="Shift left"> + <i class="fa fa-angle-left"/> + </div> + </t> + <t t-if="chatWindow.hasShiftRight"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandShiftRight" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickShiftRight" title="Shift right"> + <i class="fa fa-angle-right"/> + </div> + </t> + <t t-if="props.isExpandable"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandExpand" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickExpand" title="Open in Discuss"> + <i class="fa fa-expand"/> + </div> + </t> + <t t-if="!props.hasCloseAsBackButton"> + <div class="o_ChatWindowHeader_command o_ChatWindowHeader_commandClose" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" t-on-click="_onClickClose" title="Close chat window"> + <i class="fa fa-close"/> + </div> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js new file mode 100644 index 00000000000..b598eeee4b4 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js @@ -0,0 +1,142 @@ +odoo.define('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js', function (require) { +'use strict'; + +const components = { + ChatWindowHeader: require('mail/static/src/components/chat_window_header/chat_window_header.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindowHiddenMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatWindowManager = this.env.messaging.chatWindowManager; + const device = this.env.messaging.device; + const locale = this.env.messaging.locale; + return { + chatWindowManager: chatWindowManager ? chatWindowManager.__state : undefined, + device: device ? device.__state : undefined, + localeTextDirection: locale ? locale.textDirection : undefined, + }; + }); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + /** + * Reference of the dropup list. Useful to auto-set max height based on + * browser screen height. + */ + this._listRef = useRef('list'); + /** + * The intent of the toggle button depends on the last rendered state. + */ + this._wasMenuOpen; + } + + mounted() { + this._apply(); + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + patched() { + this._apply(); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _apply() { + this._applyListHeight(); + this._applyOffset(); + this._wasMenuOpen = this.env.messaging.chatWindowManager.isHiddenMenuOpen; + } + + /** + * @private + */ + _applyListHeight() { + const device = this.env.messaging.device; + const height = device.globalWindowInnerHeight / 2; + this._listRef.el.style['max-height'] = `${height}px`; + } + + /** + * @private + */ + _applyOffset() { + const textDirection = this.env.messaging.locale.textDirection; + const offsetFrom = textDirection === 'rtl' ? 'left' : 'right'; + const oppositeFrom = offsetFrom === 'right' ? 'left' : 'right'; + const offset = this.env.messaging.chatWindowManager.visual.hidden.offset; + this.el.style[offsetFrom] = `${offset}px`; + this.el.style[oppositeFrom] = 'auto'; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Closes the menu when clicking outside. + * Must be done as capture to avoid stop propagation. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + if (this.el.contains(ev.target)) { + return; + } + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggle(ev) { + if (this._wasMenuOpen) { + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } else { + this.env.messaging.chatWindowManager.openHiddenMenu(); + } + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.chat_window} ev.detail.chatWindow + */ + _onClickedChatWindow(ev) { + const chatWindow = ev.detail.chatWindow; + chatWindow.makeVisible(); + chatWindow.unfold(); + chatWindow.focus(); + this.env.messaging.chatWindowManager.closeHiddenMenu(); + } + +} + +Object.assign(ChatWindowHiddenMenu, { + components, + props: {}, + template: 'mail.ChatWindowHiddenMenu', +}); + +return ChatWindowHiddenMenu; + +}); diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss new file mode 100644 index 00000000000..119d61848ab --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.scss @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowHiddenMenu { + position: fixed; + bottom: 0; + display: flex; + width: 50px; + height: 28px; + align-items: stretch; +} + +.o_ChatWindowHiddenMenu_chatWindowHeader { + max-width: 200px; +} + +.o_ChatWindowHiddenMenu_dropdownToggle { + display: flex; + align-items: center; + justify-content: center; + flex: 1 1 auto; + max-width: 100%; +} + +.o_ChatWindowHiddenMenu_dropdownToggleIcon { + margin-right: 1px; +} + +.o_ChatWindowHiddenMenu_dropdownToggleItem { + margin: 0 3px; +} + +.o_ChatWindowHiddenMenu_list { + overflow: auto; + margin: 0; + padding: 0; +} + +.o_ChatWindowHiddenMenu_listItem { + + &:not(:last-child) { + margin-bottom: 1px; + } +} + +.o_ChatWindowHiddenMenu_unreadCounter { + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + z-index: 1001; // on top of bootstrap dropup menu +} + +.o_ChatWindowHiddenMenu_windowCounter { + overflow: hidden; + text-overflow: ellipsis; + margin-left: 1px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatWindowHiddenMenu { + background-color: gray('900'); + border-radius: 6px 6px 0 0; + color: white; + cursor: pointer; +} + +.o_ChatWindowHiddenMenu_chatWindowHeader { + opacity: 0.95; + + &:hover { + opacity: 1; + } +} + +.o_ChatWindowHiddenMenu_dropdownToggle.show { + opacity: 0.5; +} + +.o_ChatWindowHiddenMenu_unreadCounter { + background-color: $o-brand-primary; +} + +.o_ChatWindowHiddenMenu_windowCounter { + user-select: none; +} diff --git a/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml new file mode 100644 index 00000000000..943fc71d09d --- /dev/null +++ b/addons/mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowHiddenMenu" owl="1"> + <div class="dropup o_ChatWindowHiddenMenu"> + <div class="dropdown-toggle o_ChatWindowHiddenMenu_dropdownToggle" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" t-on-click="_onClickToggle"> + <div class="fa fa-comments-o o_ChatWindowHiddenMenu_dropdownToggleIcon o_ChatWindowHiddenMenu_dropdownToggleItem"/> + <div class="o_ChatWindowHiddenMenu_dropdownToggleItem o_ChatWindowHiddenMenu_windowCounter"> + <t t-esc="env.messaging.chatWindowManager.allOrderedHidden.length"/> + </div> + </div> + <ul class="dropdown-menu dropdown-menu-right o_ChatWindowHiddenMenu_list" t-att-class="{ show: env.messaging.chatWindowManager.isHiddenMenuOpen }" role="menu" t-ref="list"> + <t t-foreach="env.messaging.chatWindowManager.allOrderedHidden" t-as="chatWindow" t-key="chatWindow.localId"> + <li class="o_ChatWindowHiddenMenu_listItem" role="menuitem"> + <ChatWindowHeader + class="o_ChatWindowHiddenMenu_chatWindowHeader" + chatWindowLocalId="chatWindow.localId" + t-on-o-clicked="_onClickedChatWindow" + /> + </li> + </t> + </ul> + <t t-if="env.messaging.chatWindowManager.unreadHiddenConversationAmount > 0"> + <div class="badge badge-pill o_ChatWindowHiddenMenu_unreadCounter"> + <t t-esc="env.messaging.chatWindowManager.unreadHiddenConversationAmount"/> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js new file mode 100644 index 00000000000..956c6811e68 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.js @@ -0,0 +1,37 @@ +odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager.js', function (require) { +'use strict'; + +const components = { + ChatWindow: require('mail/static/src/components/chat_window/chat_window.js'), + ChatWindowHiddenMenu: require('mail/static/src/components/chat_window_hidden_menu/chat_window_hidden_menu.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatWindowManager extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + return { + chatWindowManagerVisual: this.env.messaging.chatWindowManager.visual, + device: this.env.messaging.device, + }; + }); + } + +} + +Object.assign(ChatWindowManager, { + components, + props: {}, + template: 'mail.ChatWindowManager', +}); + +return ChatWindowManager; + +}); diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss new file mode 100644 index 00000000000..1e8abf54184 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.scss @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatWindowManager { + bottom: 0; + right: 0; + display: flex; + flex-direction: row-reverse; + z-index: 1000; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml new file mode 100644 index 00000000000..3ced7210da1 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatWindowManager" owl="1"> + <div class="o_ChatWindowManager"> + <!-- Note: DOM elements are ordered from left to right --> + <t t-if="env.messaging.chatWindowManager.hasHiddenChatWindows"> + <ChatWindowHiddenMenu class="o_ChatWindowManager_hiddenMenu"/> + </t> + <t t-foreach="env.messaging.chatWindowManager.allOrderedVisible" t-as="chatWindow" t-key="chatWindow.localId"> + <ChatWindow + chatWindowLocalId="chatWindow.localId" + hasCloseAsBackButton="env.messaging.device.isMobile" + isExpandable="!env.messaging.device.isMobile and !!chatWindow.thread" + isFullscreen="env.messaging.device.isMobile" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js new file mode 100644 index 00000000000..f987a40db46 --- /dev/null +++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js @@ -0,0 +1,1243 @@ +odoo.define('mail/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + inputFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { + file: { createFile }, + dom: { triggerEvent }, +} = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chat_window_manager', {}, function () { +QUnit.module('chat_window_manager_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({ + hasChatWindow: true, + hasMessagingMenu: true, + }, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.widget) { + this.widget.destroy(); + } + }, +}); + +QUnit.test('initial mount', async function (assert) { + assert.expect(1); + + await this.start(); + assert.strictEqual(document.querySelectorAll('.o_ChatWindowManager').length, + 1, + "should have chat window manager"); +}); + +QUnit.test('chat window new message: basic rendering', async function (assert) { + assert.expect(10); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header`).length, + 1, + "should have a header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_name`).length, + 1, + "should have name part in header" + ); + assert.strictEqual( + document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_name`).textContent, + "New message", + "should display 'new message' in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_command`).length, + 1, + "should have 1 command in header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).length, + 1, + "should have command to close chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageForm`).length, + 1, + "should have a new message chat window container" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageFormLabel`).length, + 1, + "should have a part in selection with label" + ); + assert.strictEqual( + document.querySelector(`.o_ChatWindow_newMessageFormLabel`).textContent.trim(), + "To:", + "should have label 'To:' in selection" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow_newMessageFormInput`).length, + 1, + "should have an input in selection" + ); +}); + +QUnit.test('chat window new message: focused on open', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); + assert.ok( + document.activeElement, + document.querySelector(`.o_ChatWindow_newMessageFormInput`), + "chat window focused = selection input focused" + ); +}); + +QUnit.test('chat window new message: close', async function (assert) { + assert.expect(1); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_ChatWindow_header .o_ChatWindowHeader_commandClose`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 0, + "chat window should be closed" + ); +}); + +QUnit.test('chat window new message: fold', async function (assert) { + assert.expect(3); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + assert.notOk( + document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'), + "chat window should not be folded by default" + ); + + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'), + "chat window should become folded" + ); + + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.notOk( + document.querySelector(`.o_ChatWindow`).classList.contains('o-folded'), + "chat window should become unfolded" + ); +}); + +QUnit.test('chat window: basic rendering', async function (assert) { + assert.expect(11); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_NotificationList_preview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + const chatWindow = document.querySelector(`.o_ChatWindow`); + assert.strictEqual( + chatWindow.dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId, + "should have open a chat window of channel" + ); + assert.strictEqual( + chatWindow.querySelectorAll(`:scope .o_ChatWindow_header`).length, + 1, + "should have header part" + ); + const chatWindowHeader = chatWindow.querySelector(`:scope .o_ChatWindow_header`); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have thread icon in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_name`).length, + 1, + "should have thread name in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelector(`:scope .o_ChatWindowHeader_name`).textContent, + "General", + "should have correct thread name in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_command`).length, + 2, + "should have 2 commands in header part" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandExpand`).length, + 1, + "should have command to expand thread in discuss" + ); + assert.strictEqual( + chatWindowHeader.querySelectorAll(`:scope .o_ChatWindowHeader_commandClose`).length, + 1, + "should have command to close chat window" + ); + assert.strictEqual( + chatWindow.querySelectorAll(`:scope .o_ChatWindow_thread`).length, + 1, + "should have part to display thread content inside chat window" + ); + assert.ok( + chatWindow.querySelector(`:scope .o_ChatWindow_thread`).classList.contains('o_ThreadViewer'), + "thread viewer part should use component ThreadViewer" + ); +}); + +QUnit.test('chat window: fold', async function (assert) { + assert.expect(24); + + let foldCall = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + assert.step(`rpc:${args.method}/${args.kwargs.state}`); + foldCall++; + const kwargsKeys = Object.keys(args.kwargs); + assert.strictEqual( + args.args.length, + 0, + "channel_fold call have no args" + ); + assert.strictEqual( + kwargsKeys.length, + 2, + "channel_fold call have exactly 2 kwargs" + ); + assert.ok( + kwargsKeys.includes('state'), + "channel_fold call have 'state' kwargs" + ); + assert.ok( + kwargsKeys.includes('uuid'), + "channel_fold call have 'uuid' kwargs" + ); + assert.strictEqual( + args.kwargs.uuid, + 'channel-20-uuid', + "channel_fold call uuid is from channel 20" + ); + if (foldCall % 2 === 0) { + assert.strictEqual( + args.kwargs.state, + 'folded', + "channel_fold call state is 'folded'" + ); + } else { + assert.strictEqual( + args.kwargs.state, + 'open', + "channel_fold call state is 'open'" + ); + } + return []; + } + return this._super(...arguments); + }, + }); + // Open Thread + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); + // Fold chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.verifySteps(['rpc:channel_fold/folded']); + // Unfold chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindow_header`).click()); + assert.verifySteps(['rpc:channel_fold/open']); +}); + +QUnit.test('chat window: open / close', async function (assert) { + assert.expect(24); + + let foldCall = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + assert.step(`rpc:channel_fold/${args.kwargs.state}`); + foldCall++; + const kwargsKeys = Object.keys(args.kwargs); + assert.strictEqual( + args.args.length, + 0, + "channel_fold call have no args" + ); + assert.strictEqual( + kwargsKeys.length, + 2, + "channel_fold call have exactly 2 kwargs" + ); + assert.ok( + kwargsKeys.includes('state'), + "channel_fold call have 'state' kwargs" + ); + assert.ok( + kwargsKeys.includes('uuid'), + "channel_fold call have 'uuid' kwargs" + ); + assert.strictEqual( + args.kwargs.uuid, + 'channel-20-uuid', + "channel_fold call uuid should be correct" + ); + if (foldCall % 2 === 0) { + assert.strictEqual( + args.kwargs.state, + 'closed', + "channel_fold call state is 'closed'" + ); + } else { + assert.strictEqual( + args.kwargs.state, + 'open', + "channel_fold call state is 'open'" + ); + } + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); + + // Close chat window + await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click()); + assert.verifySteps(['rpc:channel_fold/closed']); + + // Reopen chat window + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); +}); + +QUnit.test('chat window: close on ESCAPE', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + assert.step(`rpc:channel_fold/${args.kwargs.state}`); + return []; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + assert.verifySteps(['rpc:channel_fold/open']); + + await afterNextRender(() => { + document.querySelector(`.o_ChatWindow`).focus(); + const kevt = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" }); + document.querySelector(`.o_ChatWindow`).dispatchEvent(kevt); + }); + assert.verifySteps(['rpc:channel_fold/closed']); +}); + +QUnit.test('chat window: state conservation on toggle home menu', async function (assert) { + assert.expect(9); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + is_minimized: false, + name: "General", + state: 'open', + uuid: 'channel-20-uuid', + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }]; + } else if (args.method === 'channel_fold') { + return; + } else if (args.method === 'message_fetch') { + return [...Array(20).keys()].map(i => { + return { + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: i + 10, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: i + 1, + }; + }); + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() + ); + // Set a scroll position to chat window + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 142; + // Set html content of the composer of the chat window + let composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + composerTextInputTextArea.focus(); + document.execCommand('insertText', false, 'XDU for the win !'); + // Set attachments of the composer + const files = [ + await createFile({ + name: 'text state conservation on toggle home menu.txt', + content: 'hello, world', + contentType: 'text/plain', + }), + await createFile({ + name: 'text2 state conservation on toggle home menu.txt', + content: 'hello, xdu is da best man', + contentType: 'text/plain', + }) + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + assert.strictEqual( + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop, + 142, + "verify chat window initial scrollTop" + ); + composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + assert.strictEqual( + composerTextInputTextArea.value, + "XDU for the win !", + "verifif chat window composer initial html input" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "verify chat window composer initial attachment count" + ); + // Hide home menu + await this.env.bus.trigger('will_hide_home_menu'); + await this.env.bus.trigger('hide_home_menu'); + assert.strictEqual( + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same (1)" + ); + composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + assert.strictEqual( + composerTextInputTextArea.value, + "XDU for the win !", + "Chat window composer should still have the same html input (1)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "Chat window composer should have 2 attachments (1)" + ); + + // Show home menu + await this.env.bus.trigger('will_show_home_menu'); + await this.env.bus.trigger('show_home_menu'); + assert.strictEqual( + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop, + 142, + "chat window scrollTop should still be the same (2)" + ); + composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + assert.strictEqual( + composerTextInputTextArea.value, + "XDU for the win !", + "chat window composer should still have the same html input (2)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "Chat window composer should have 2 attachments (2)" + ); +}); + +QUnit.test('open 2 different chat windows: enough screen width [REQUIRE FOCUS]', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: @see `mail/static/src/utils/test_utils.js:start()` method) + * (others: @see mail/static/src/models/chat_window_manager/chat_window_manager.js:visual) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1920px + * + * Enough space for 2 visible chat windows: + * 10 + 325 + 5 + 325 + 10 = 670 < 1920 + */ + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + env: { + browser: { + innerHeight: 1080, + innerWidth: 1920, + }, + }, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }, { + id: 10, + last_message: { + author_id: [7, "Demo"], + body: "<p>test2</p>", + channel_ids: [10], + id: 101, + message_type: 'comment', + model: 'mail.channel', + res_id: 10, + }, + }]; + } + return this._super(...arguments); + }, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of chat should be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of chat should have focus" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open a new chat window" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of channel should be open" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of chat should still be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of channel should have focus" + ); + assert.notOk( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of chat should no longer have focus" + ); +}); + +QUnit.test('open 2 chat windows: check shift operations are available', async function (assert) { + assert.expect(9); + + const channel = { + channel_type: "channel", + id: 20, + name: "General", + }; + const chat = { + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }; + this.data['mail.channel'].records = [channel, chat]; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [channel], + channel_direct_message: [chat], + }, + }); + await this.start(); + await afterNextRender(() => { + document.querySelector('.o_MessagingMenu_toggler').click(); + }); + await afterNextRender(() => { + document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[0].click(); + }); + await afterNextRender(() => { + document.querySelector('.o_MessagingMenu_toggler').click(); + }); + await afterNextRender(() => { + document.querySelectorAll('.o_MessagingMenu_dropdownMenu .o_NotificationList_preview')[1].click(); + }); + + assert.containsN( + document.body, + '.o_ChatWindow', + 2, + "should have opened 2 chat windows" + ); + assert.containsOnce( + document.querySelectorAll('.o_ChatWindow')[0], + '.o_ChatWindowHeader_commandShiftLeft', + "first chat window should be allowed to shift left" + ); + assert.containsNone( + document.querySelectorAll('.o_ChatWindow')[0], + '.o_ChatWindowHeader_commandShiftRight', + "first chat window should not be allowed to shift right" + ); + assert.containsNone( + document.querySelectorAll('.o_ChatWindow')[1], + '.o_ChatWindowHeader_commandShiftLeft', + "second chat window should not be allowed to shift left" + ); + assert.containsOnce( + document.querySelectorAll('.o_ChatWindow')[1], + '.o_ChatWindowHeader_commandShiftRight', + "second chat window should be allowed to shift right" + ); + + const initialFirstChatWindowThreadLocalId = + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId; + const initialSecondChatWindowThreadLocalId = + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId; + await afterNextRender(() => { + document.querySelectorAll('.o_ChatWindow')[0] + .querySelector(':scope .o_ChatWindowHeader_commandShiftLeft') + .click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "First chat window should be second after it has been shift left" + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "Second chat window should be first after the first has been shifted left" + ); + + await afterNextRender(() => { + document.querySelectorAll('.o_ChatWindow')[1] + .querySelector(':scope .o_ChatWindowHeader_commandShiftRight') + .click(); + }); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[0].dataset.threadLocalId, + initialFirstChatWindowThreadLocalId, + "First chat window should be back at first place after being shifted left then right" + ); + assert.strictEqual( + document.querySelectorAll('.o_ChatWindow')[1].dataset.threadLocalId, + initialSecondChatWindowThreadLocalId, + "Second chat window should be back at second place after first one has been shifted left then right" + ); +}); + +QUnit.test('open 3 different chat windows: not enough screen width', async function (assert) { + /** + * computation uses following info: + * ([mocked] global window width: 900px) + * (others: @see `mail/static/src/models/chat_window_manager/chat_window_manager.js:visual`) + * + * - chat window width: 325px + * - start/end/between gap width: 10px/10px/5px + * - hidden menu width: 200px + * - global width: 1080px + * + * Enough space for 2 visible chat windows, and one hidden chat window: + * 3 visible chat windows: + * 10 + 325 + 5 + 325 + 5 + 325 + 10 = 1000 < 900 + * 2 visible chat windows + hidden menu: + * 10 + 325 + 5 + 325 + 10 + 200 + 5 = 875 < 900 + */ + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "channel1", + }, { + channel_type: "channel", + id: 2, + name: "channel2", + }, { + channel_type: "channel", + id: 3, + name: "channel3", + }], + }, + }); + await this.start({ + env: { + browser: { + innerHeight: 900, + innerWidth: 900, + }, + }, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + // open, from systray menu, chat windows of channels with Id 1, 2, then 3 + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open 1 visible chat window" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 0, + "should not have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open 2 visible chat windows" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 0, + "should not have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 3 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 2, + "should have open 2 visible chat windows" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindowManager_hiddenMenu`).length, + 1, + "should have hidden menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "messaging menu should be hidden" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of channel 1 should be open" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 3 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "chat window of channel 3 should be open" + ); + assert.ok( + document.querySelector(` + .o_ChatWindow[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 3 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-focused'), + "chat window of channel 3 should have focus" + ); +}); + +QUnit.test('chat window: switch on TAB', async function (assert) { + assert.expect(10); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "channel1", + }, { + channel_type: "channel", + id: 2, + name: "channel2", + }], + }, + }); + await this.start(); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"]` + ).click() + ); + + assert.containsOnce(document.body, '.o_ChatWindow', "Only 1 chatWindow must be opened"); + const chatWindow = document.querySelector('.o_ChatWindow'); + assert.strictEqual( + chatWindow.querySelector('.o_ChatWindowHeader_name').textContent, + 'channel1', + "The name of the only chatWindow should be 'channel1' (channel with ID 1)" + ); + assert.strictEqual( + chatWindow.querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The chatWindow composer must have focus" + ); + + await afterNextRender(() => + triggerEvent( + chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.strictEqual( + chatWindow.querySelector('.o_ChatWindow .o_ComposerTextInput_textarea'), + document.activeElement, + "The chatWindow composer still has focus" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_preview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"]` + ).click() + ); + + assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows must be opened"); + const chatWindows = document.querySelectorAll('.o_ChatWindow'); + assert.strictEqual( + chatWindows[0].querySelector('.o_ChatWindowHeader_name').textContent, + 'channel1', + "The name of the 1st chatWindow should be 'channel1' (channel with ID 1)" + ); + assert.strictEqual( + chatWindows[1].querySelector('.o_ChatWindowHeader_name').textContent, + 'channel2', + "The name of the 2nd chatWindow should be 'channel2' (channel with ID 2)" + ); + assert.strictEqual( + chatWindows[1].querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The 2nd chatWindow composer must have focus (channel with ID 2)" + ); + + await afterNextRender(() => + triggerEvent( + chatWindows[1].querySelector('.o_ComposerTextInput_textarea'), + 'keydown', + { key: 'Tab' }, + ) + ); + assert.containsN(document.body, '.o_ChatWindow', 2, "2 chatWindows should still be opened"); + assert.strictEqual( + chatWindows[0].querySelector('.o_ComposerTextInput_textarea'), + document.activeElement, + "The 1st chatWindow composer must have focus (channel with ID 1)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js new file mode 100644 index 00000000000..5503cc90d8b --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.js @@ -0,0 +1,103 @@ +odoo.define('mail/static/src/components/chatter/chatter.js', function (require) { +'use strict'; + +const components = { + ActivityBox: require('mail/static/src/components/activity_box/activity_box.js'), + AttachmentBox: require('mail/static/src/components/attachment_box/attachment_box.js'), + ChatterTopbar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'), + Composer: require('mail/static/src/components/composer/composer.js'), + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Chatter extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter ? chatter.thread : undefined; + let attachments = []; + if (thread) { + attachments = thread.allAttachments; + } + return { + attachments: attachments.map(attachment => attachment.__state), + chatter: chatter ? chatter.__state : undefined, + thread: thread ? thread.__state : undefined, + }; + }, { + compareDepth: { + attachments: 1, + }, + }); + this._threadRef = useRef('thread'); + } + + mounted() { + if (this.chatter.thread) { + this._notifyRendered(); + } + } + + patched() { + if (this.chatter.thread) { + this._notifyRendered(); + } + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _notifyRendered() { + this.trigger('o-chatter-rendered', { + attachments: this.chatter.thread.allAttachments, + thread: this.chatter.thread.localId, + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onComposerMessagePosted() { + this.chatter.update({ isComposerVisible: false }); + } + +} + +Object.assign(Chatter, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.Chatter', +}); + +return Chatter; + +}); diff --git a/addons/mail/static/src/components/chatter/chatter.scss b/addons/mail/static/src/components/chatter/chatter.scss new file mode 100644 index 00000000000..ef33f676de2 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.scss @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Chatter { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: auto; +} + +.o_Chatter_composer { + border-bottom: $border-width solid; + + &.o-bordered { + border-left: $border-width solid; + border-right: $border-width solid; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Chatter { + background-color: white; + border-color: $border-color; +} + +.o_Chatter_composer { + border-bottom-color: $border-color; + + &.o-bordered { + border-left-color: $border-color; + border-right-color: $border-color; + } +} diff --git a/addons/mail/static/src/components/chatter/chatter.xml b/addons/mail/static/src/components/chatter/chatter.xml new file mode 100644 index 00000000000..f630eaf7634 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Chatter" owl="1"> + <div class="o_Chatter"> + <t t-if="chatter"> + <ChatterTopbar + class="o_Chatter_topbar" + chatterLocalId="chatter.localId" + /> + <t t-if="chatter.hasThread and chatter.thread and chatter.isComposerVisible"> + <Composer + class="o_Chatter_composer" + t-att-class="{ 'o-bordered': chatter.hasExternalBorder }" + composerLocalId="chatter.thread.composer.localId" + hasMentionSuggestionsBelowPosition="true" + hasTextInputSendOnEnterEnabled="false" + isCompact="false" + isExpandable="true" + isLog="chatter.isComposerLog" + t-on-o-message-posted="_onComposerMessagePosted" + t-ref="composer" + /> + </t> + <t t-if="chatter.isAttachmentBoxVisible"> + <AttachmentBox + class="o_Chatter_attachmentBox" + threadLocalId="chatter.thread.localId" + /> + </t> + <t t-if="chatter.thread"> + <t t-if="chatter.hasActivities and chatter.activities.length > 0"> + <ActivityBox + class="o_Chatter_activityBox" + chatterLocalId="chatter.localId" + /> + </t> + <t t-if="chatter.hasThread"> + <ThreadViewer + class="o_Chatter_thread" + hasComposer="false" + haveMessagesAuthorRedirect="true" + order="'desc'" + threadViewerLocalId="chatter.threadViewer.localId" + t-ref="thread" + /> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter/chatter_tests.js b/addons/mail/static/src/components/chatter/chatter_tests.js new file mode 100644 index 00000000000..a27a4036105 --- /dev/null +++ b/addons/mail/static/src/components/chatter/chatter_tests.js @@ -0,0 +1,337 @@ +odoo.define('mail/static/src/components/chatter/chatter_tests', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter', {}, function () { +QUnit.module('chatter_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createChatterComponent = async ({ chatter }, otherProps) => { + const ChatterComponent = components.Chatter; + ChatterComponent.env = this.env; + this.component = new ChatterComponent( + null, + Object.assign({ chatterLocalId: chatter.localId }, otherProps) + ); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.Chatter.env; + this.env = undefined; + }, +}); + +QUnit.test('base rendering when chatter has no attachment', async function (assert) { + assert.expect(6); + + const messages = [...Array(60).keys()].map(id => { + return { + author_id: [10, "Demo User"], + body: `<p>Message ${id + 1}</p>`, + date: "2019-04-20 10:00:00", + id: id + 1, + message_type: 'comment', + model: 'res.partner', + record_name: 'General', + res_id: 100, + }; + }); + this.data['mail.message'].records = messages; + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_thread`).length, + 1, + "should have a thread in the chatter" + ); + assert.strictEqual( + document.querySelector(`.o_Chatter_thread`).dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'res.partner' + ).localId, + "thread should have the right thread local id" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "the first 30 messages of thread should be loaded" + ); +}); + +QUnit.test('base rendering when chatter has no record', async function (assert) { + assert.expect(7); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_thread`).length, + 1, + "should have a thread in the chatter" + ); + assert.ok( + chatter.thread.isTemporary, + "thread should have a temporary thread linked to chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 1, + "should have a message" + ); + assert.strictEqual( + document.querySelector(`.o_Message_content`).textContent, + "Creating a new record...", + "should have the 'Creating a new record ...' message" + ); +}); + +QUnit.test('base rendering when chatter has attachments', async function (assert) { + assert.expect(3); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); +}); + +QUnit.test('show attachment box', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('ir.attachment/search_read')) { + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "should have a chatter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 0, + "should not have an attachment box in the chatter" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_attachmentBox`).length, + 1, + "should have an attachment box in the chatter" + ); +}); + +QUnit.test('composer show/hide on log note/send message', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterComponent({ chatter }); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length, + 1, + "should have a send message button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length, + 1, + "should have a log note button" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should not have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should still have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should have no composer anymore" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 1, + "should have a composer" + ); + + await afterNextRender(() => + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_Chatter_composer`).length, + 0, + "should have no composer anymore" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js new file mode 100644 index 00000000000..0c5de5208fc --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.js @@ -0,0 +1,80 @@ +odoo.define('mail/static/src/components/chatter_container/chatter_container.js', function (require) { +'use strict'; + +const components = { + Chatter: require('mail/static/src/components/chatter/chatter.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +/** + * This component abstracts chatter component to its parent, so that it can be + * mounted and receive chatter data even when a chatter component cannot be + * created. Indeed, in order to create a chatter component, we must create + * a chatter record, the latter requiring messaging to be initialized. The view + * may attempt to create a chatter before messaging has been initialized, so + * this component delays the mounting of chatter until it becomes initialized. + */ +class ChatterContainer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.chatter = undefined; + this._wasMessagingInitialized = false; + useStore(props => { + const isMessagingInitialized = this.env.isMessagingInitialized(); + if (!this._wasMessagingInitialized && isMessagingInitialized) { + this._wasMessagingInitialized = true; + this.chatter = this.env.models['mail.chatter'].create(props); + } + return { isMessagingInitialized }; + }); + } + + mounted() { + this._update(); + } + + patched() { + this._update(); + } + + destroy() { + super.destroy(); + if (this.chatter) { + this.chatter.delete(); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if (this.chatter) { + this.chatter.update(this.props); + } + } + +} + +Object.assign(ChatterContainer, { + components, + /** + * No props validation because this component simply forwards props to + * chatter record as its data. + */ + template: 'mail.ChatterContainer', +}); + + +return ChatterContainer; + +}); diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.scss b/addons/mail/static/src/components/chatter_container/chatter_container.scss new file mode 100644 index 00000000000..99e8d530fb3 --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.scss @@ -0,0 +1,13 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatterContainer { + display: flex; + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.xml b/addons/mail/static/src/components/chatter_container/chatter_container.xml new file mode 100644 index 00000000000..f9ee262e69b --- /dev/null +++ b/addons/mail/static/src/components/chatter_container/chatter_container.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatterContainer" owl="1"> + <div class="o_ChatterContainer"> + <t t-if="chatter"> + <Chatter chatterLocalId="chatter.localId"/> + </t> + <t t-else=""> + Please wait... + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js new file mode 100644 index 00000000000..c4b088b7709 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.js @@ -0,0 +1,128 @@ +odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar.js', function (require) { +'use strict'; + +const components = { + FollowButton: require('mail/static/src/components/follow_button/follow_button.js'), + FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ChatterTopbar extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const chatter = this.env.models['mail.chatter'].get(props.chatterLocalId); + const thread = chatter ? chatter.thread : undefined; + const threadAttachments = thread ? thread.allAttachments : []; + return { + areThreadAttachmentsLoaded: thread && thread.areAttachmentsLoaded, + chatter: chatter ? chatter.__state : undefined, + threadAttachmentsAmount: threadAttachments.length, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.chatter} + */ + get chatter() { + return this.env.models['mail.chatter'].get(this.props.chatterLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAttachments(ev) { + this.chatter.update({ + isAttachmentBoxVisible: !this.chatter.isAttachmentBoxVisible, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickClose(ev) { + this.trigger('o-close-chatter'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickLogNote(ev) { + if (this.chatter.isComposerVisible && this.chatter.isComposerLog) { + this.chatter.update({ isComposerVisible: false }); + } else { + this.chatter.showLogNote(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickScheduleActivity(ev) { + const action = { + type: 'ir.actions.act_window', + name: this.env._t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.chatter.threadId, + default_res_model: this.chatter.threadModel, + }, + res_id: false, + }; + return this.env.bus.trigger('do-action', { + action, + options: { + // A bit "extreme", could be improved: + // normally only an activity is created (no update nor delete) + on_close: () => this.chatter.refreshActivities(), + }, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSendMessage(ev) { + if (this.chatter.isComposerVisible && !this.chatter.isComposerLog) { + this.chatter.update({ isComposerVisible: false }); + } else { + this.chatter.showSendMessage(); + } + } + +} + +Object.assign(ChatterTopbar, { + components, + props: { + chatterLocalId: String, + }, + template: 'mail.ChatterTopbar', +}); + +return ChatterTopbar; + +}); diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss new file mode 100644 index 00000000000..17c5fa5d856 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.scss @@ -0,0 +1,100 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ChatterTopbar { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.o_ChatterTopbar_actions { + display: flex; + flex: 1; + flex-direction: row; + flex-wrap: wrap; + + &.o-has-active-button { + border-bottom: $border-width solid; + } +} + +.o_ChatterTopbar_button { + margin-bottom: -$border-width; /* Needed to allow "overriding" of the bottom border */ +} + +.o_ChatterTopbar_buttonAttachmentsCountLoader { + margin-left: 2px; +} + +.o_ChatterTopbar_buttonCount { + padding-left: 0.25rem; +} + +.o_ChatterTopbar_buttonClose { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 34px; + height: 34px; +} + +.o_ChatterTopbar_followerListMenu { + display: flex; +} + +.o_ChatterTopbar_rightSection { + display: flex; + flex: 1 0 auto; + justify-content: flex-end; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ChatterTopbar_actions.o-has-active-button { + border-color: $border-color; +} + +.o_ChatterTopbar_button { + border-radius: 0; + + &:hover { + background-color: gray('300'); + } + + &.o-active { + color: $o-brand-odoo; + background-color: lighten(gray('300'), 7%); + border-right-color: $border-color; + + &:not(:first-of-type), + &:first-of-type.o-bordered { + border-left-color: $border-color; + } + + &.o-bordered { + border-top-color: $border-color; + } + + &:hover { + background-color: gray('300'); + color: $link-hover-color; + } + } +} + +.o_ChatterTopbar_buttonClose { + border-radius: 0 0 10px 10px; + font-size: $font-size-lg; + background-color: gray('700'); + color: gray('100'); + cursor: pointer; + + &:hover { + background-color: gray('600'); + color: $white; + } +} diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml new file mode 100644 index 00000000000..67dfe4ea844 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar.xml @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ChatterTopbar" owl="1"> + <div class="o_ChatterTopbar"> + <t t-if="chatter"> + <div class="o_ChatterTopbar_actions" t-att-class="{'o-has-active-button': chatter.isComposerVisible }"> + <t t-if="chatter.hasThread"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonSendMessage" + type="button" + t-att-class="{ + 'o-active': chatter.isComposerVisible and !chatter.isComposerLog, + 'o-bordered': chatter.hasExternalBorder, + }" + t-att-disabled="chatter.isDisabled" + t-on-click="_onClickSendMessage" + > + Send message + </button> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonLogNote" + type="button" + t-att-class="{ + 'o-active': chatter.isComposerVisible and chatter.isComposerLog, + 'o-bordered': chatter.hasExternalBorder, + }" + t-att-disabled="chatter.isDisabled" + t-on-click="_onClickLogNote" + > + Log note + </button> + </t> + <t t-if="chatter.hasActivities"> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonScheduleActivity" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickScheduleActivity"> + <i class="fa fa-clock-o"/> + Schedule activity + </button> + </t> + <button class="btn btn-link o_ChatterTopbar_button o_ChatterTopbar_buttonAttachments" type="button" t-att-disabled="chatter.isDisabled" t-on-click="_onClickAttachments"> + <i class="fa fa-paperclip"/> + <t t-if="chatter.isDisabled or !chatter.isShowingAttachmentsLoading"> + <span class="o_ChatterTopbar_buttonCount o_ChatterTopbar_buttonAttachmentsCount" t-esc="chatter.thread ? chatter.thread.allAttachments.length : 0"/> + </t> + <t t-else=""> + <i class="o_ChatterTopbar_buttonAttachmentsCountLoader fa fa-spinner fa-spin" aria-label="Attachment counter loading..."/> + </t> + </button> + <div class="o-autogrow"/> + <div class="o_ChatterTopbar_rightSection"> + <t t-if="chatter.hasFollowers and chatter.thread"> + <FollowButton + class="o_ChatterTopbar_button o_ChatterTopbar_followButton" + isDisabled="chatter.isDisabled" + threadLocalId="chatter.thread.localId" + /> + <FollowerListMenu + class="o_ChatterTopbar_button o_ChatterTopbar_followerListMenu" + isDisabled="chatter.isDisabled" + threadLocalId="chatter.thread.localId" + /> + </t> + </div> + </div> + <t t-if="chatter.hasTopbarCloseButton"> + <div class="o_ChatterTopbar_buttonClose" title="Close" t-on-click="_onClickClose"> + <i class="fa fa-times"/> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js new file mode 100644 index 00000000000..c6c38c081b6 --- /dev/null +++ b/addons/mail/static/src/components/chatter_topbar/chatter_topbar_tests.js @@ -0,0 +1,590 @@ +odoo.define('mail/static/src/components/chatter_topbar/chatter_topbar_tests.js', function (require) { +'use strict'; + +const components = { + ChatterTopBar: require('mail/static/src/components/chatter_topbar/chatter_topbar.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('chatter_topbar', {}, function () { +QUnit.module('chatter_topbar_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createChatterTopbarComponent = async (chatter, otherProps) => { + const ChatterTopBarComponent = components.ChatterTopBar; + ChatterTopBarComponent.env = this.env; + this.component = new ChatterTopBarComponent( + null, + Object.assign({ chatterLocalId: chatter.localId }, otherProps) + ); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.ChatterTopBar.env; + this.env = undefined; + }, +}); + +QUnit.test('base rendering', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonSendMessage`).length, + 1, + "should have a send message button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonLogNote`).length, + 1, + "should have a log note button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonScheduleActivity`).length, + 1, + "should have a schedule activity button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_followerListMenu`).length, + 1, + "should have a follower menu" + ); +}); + +QUnit.test('base disabled rendering', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonScheduleActivity`).disabled, + "schedule activity should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should be disabled" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '0', + "attachments button counter should be 0" + ); +}); + +QUnit.test('attachment loading is delayed', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + loadingBaseDelayDuration: 100, + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + return new Promise(() => {}); // simulate long loading + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader yet" + ); + + await afterNextRender(async () => this.env.testUtils.advanceTime(100)); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should now have a loader" + ); +}); + +QUnit.test('attachment counter while loading attachments', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + return new Promise(() => {}); // simulate long loading + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 0, + "attachments button should not have a counter" + ); +}); + +QUnit.test('attachment counter transition when attachments become loaded)', async function (assert) { + assert.expect(7); + + const attachmentPromise = makeTestPromise(); + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + await attachmentPromise; + return []; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 1, + "attachments button should have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 0, + "attachments button should not have a counter" + ); + + await afterNextRender(() => attachmentPromise.resolve()); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCountLoader`).length, + 0, + "attachments button should not have a loader" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); +}); + +QUnit.test('attachment counter without attachments', async function (assert) { + assert.expect(4); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '0', + 'attachment counter should contain "0"' + ); +}); + +QUnit.test('attachment counter with attachments', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route) { + if (route.includes('ir.attachment/search_read')) { + return [{ + id: 143, + filename: 'Blah.txt', + mimetype: 'text/plain', + name: 'Blah.txt' + }, { + id: 144, + filename: 'Blu.txt', + mimetype: 'text/plain', + name: 'Blu.txt' + }]; + } + return this._super(...arguments); + } + }); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar`).length, + 1, + "should have a chatter topbar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachments`).length, + 1, + "should have an attachments button in chatter menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatterTopbar_buttonAttachmentsCount`).length, + 1, + "attachments button should have a counter" + ); + assert.strictEqual( + document.querySelector(`.o_ChatterTopbar_buttonAttachmentsCount`).textContent, + '2', + 'attachment counter should contain "2"' + ); +}); + +QUnit.test('composer state conserved when clicking on another topbar button', async function (assert) { + assert.expect(8); + + await this.start(); + const chatter = this.env.models['mail.chatter'].create({ + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + `.o_ChatterTopbar`, + "should have a chatter topbar" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonSendMessage`, + "should have a send message button in chatter menu" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote`, + "should have a log note button in chatter menu" + ); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonAttachments`, + "should have an attachments button in chatter menu" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).click(); + }); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote.o-active`, + "log button should now be active" + ); + assert.containsNone( + document.body, + `.o_ChatterTopbar_buttonSendMessage.o-active`, + "send message button should not be active" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).click(); + }); + assert.containsOnce( + document.body, + `.o_ChatterTopbar_buttonLogNote.o-active`, + "log button should still be active" + ); + assert.containsNone( + document.body, + `.o_ChatterTopbar_buttonSendMessage.o-active`, + "send message button should still be not active" + ); +}); + +QUnit.test('rendering with multiple partner followers', async function (assert) { + assert.expect(7); + + await this.start(); + this.data['res.partner'].records = [{ + id: 100, + message_follower_ids: [1, 2], + }]; + this.data['mail.followers'].records = [ + { + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + channel_id: false, + id: 1, + name: "Jean Michang", + partner_id: 12, + }, { + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + channel_id: false, + id: 2, + name: "Eden Hazard", + partner_id: 11, + }, + ]; + const chatter = this.env.models['mail.chatter'].create({ + followerIds: [1, 2], + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsN( + document.body, + '.o_Follower', + 2, + "exactly two followers should be listed" + ); + assert.containsN( + document.body, + '.o_Follower_name', + 2, + "exactly two follower names should be listed" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[0].textContent.trim(), + "Jean Michang", + "first follower is 'Jean Michang'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[1].textContent.trim(), + "Eden Hazard", + "second follower is 'Eden Hazard'" + ); +}); + +QUnit.test('rendering with multiple channel followers', async function (assert) { + assert.expect(7); + + this.data['res.partner'].records = [{ + id: 100, + message_follower_ids: [1, 2], + }]; + await this.start(); + this.data['mail.followers'].records = [ + { + channel_id: 11, + id: 1, + name: "channel numero 5", + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + partner_id: false, + }, { + channel_id: 12, + id: 2, + name: "channel armstrong", + // simulate real return from RPC + // (the presence of the key and the falsy value need to be handled correctly) + partner_id: false, + }, + ]; + const chatter = this.env.models['mail.chatter'].create({ + followerIds: [1, 2], + threadId: 100, + threadModel: 'res.partner', + }); + await this.createChatterTopbarComponent(chatter); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsN( + document.body, + '.o_Follower', + 2, + "exactly two followers should be listed" + ); + assert.containsN( + document.body, + '.o_Follower_name', + 2, + "exactly two follower names should be listed" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[0].textContent.trim(), + "channel numero 5", + "first follower is 'channel numero 5'" + ); + assert.strictEqual( + document.querySelectorAll('.o_Follower_name')[1].textContent.trim(), + "channel armstrong", + "second follower is 'channel armstrong'" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js new file mode 100644 index 00000000000..f2135835a4d --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.js @@ -0,0 +1,393 @@ +odoo.define('mail/static/src/components/composer/composer.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + DropZone: require('mail/static/src/components/drop_zone/drop_zone.js'), + EmojisPopover: require('mail/static/src/components/emojis_popover/emojis_popover.js'), + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), + TextInput: require('mail/static/src/components/composer_text_input/composer_text_input.js'), + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Composer extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.isDropZoneVisible = useDragVisibleDropZone(); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + return { + composer: composer ? composer.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + thread: composer && composer.thread + ? composer.thread.__state + : undefined, + }; + }); + /** + * Reference of the emoji button. Useful to include emoji popover as + * click "inside" the composer for the prop `isDiscardOnClickAway`. + */ + this._emojisPopoverRef = useRef('emojisPopover'); + /** + * Reference of the file uploader. + * Useful to programmatically prompts the browser file uploader. + */ + this._fileUploaderRef = useRef('fileUploader'); + /** + * Reference of the text input component. + */ + this._textInputRef = useRef('textInput'); + /** + * Tracked focus counter from props. Useful to determine whether it + * should auto focus this composer when patched. + */ + this._focusCount = 0; + // to focus if the prop changes + this._lastComposer = undefined; + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + this._lastComposer = this.composer; + if (this.props.isFocusOnMount) { + this.focus(); + } + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + patched() { + // focus when changing composer + if ( + this.props.isFocusOnMount && + this._lastComposer !== this.composer + ) { + this.focus(); + } + this._lastComposer = this.composer; + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + /** + * Get the current partner image URL. + * + * @returns {string} + */ + get currentPartnerAvatar() { + const avatar = this.env.session.uid > 0 + ? this.env.session.url('/web/image', { + field: 'image_128', + id: this.env.session.uid, + model: 'res.users', + }) + : '/web/static/src/img/user_menu_avatar.png'; + return avatar; + } + + /** + * Focus the composer. + */ + focus() { + if (this.env.messaging.device.isMobile) { + this.el.scrollIntoView(); + } + this.composer.focus(); + } + + /** + * Focusout the composer. + */ + focusout() { + this._textInputRef.comp.focusout(); + } + + /** + * Determine whether composer should display a footer. + * + * @returns {boolean} + */ + get hasFooter() { + return ( + this.props.hasThreadTyping || + this.composer.attachments.length > 0 || + !this.props.isCompact + ); + } + + /** + * Determine whether the composer should display a header. + * + * @returns {boolean} + */ + get hasHeader() { + return ( + (this.props.hasThreadName && this.composer.thread) || + (this.props.hasFollowers && !this.props.isLog) + ); + } + + /** + * Get an object which is passed to FileUploader component to be used when + * creating attachment. + * + * @returns {Object} + */ + get newAttachmentExtraData() { + return { + composers: [['replace', this.composer]], + }; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Post a message in the composer on related thread. + * + * @private + */ + async _postMessage() { + // TODO: take suggested recipients into account + await this.composer.postMessage({ isLog: this.props.isLog }); + // TODO: we might need to remove trigger and use the store to wait for the post rpc to be done + this.trigger('o-message-posted'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on attachment button. + * + * @private + */ + _onClickAddAttachment() { + this._fileUploaderRef.comp.openBrowserFileUploader(); + this.focus(); + } + + /** + * Discards the composer when clicking away from the Inbox reply in discuss. + * TODO maybe move this in discuss. task-2092950 + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // in discuss app: prevents discarding the composer when clicking away + if (!this.props.isDiscardOnClickAway) { + return; + } + // prevents discarding when clicking on self (on discuss and not on discuss) + if (this.el.contains(ev.target)) { + return; + } + // emoji popover is outside but should be considered inside + if (this._emojisPopoverRef.comp.isInsideEventTarget(ev.target)) { + return; + } + this.composer.discard(); + } + + /** + * Called when clicking on "expand" button. + * + * @private + */ + async _onClickFullComposer() { + const attachmentIds = this.composer.attachments.map(attachment => attachment.res_id); + + const context = { + // default_parent_id: this.id, + default_body: mailUtils.escapeAndCompactTextContent(this.composer.textInputContent), + default_attachment_ids: attachmentIds, + // default_partner_ids: partnerIds, + default_is_log: this.props.isLog, + mail_post_autofollow: true, + }; + + // if (this.context.default_model && this.context.default_res_id) { + // context.default_model = this.context.default_model; + // context.default_res_id = this.context.default_res_id; + // } + + const action = { + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }; + await this.env.bus.trigger('do-action', { action }); + this.trigger('o-full-composer-opened'); + } + + /** + * Called when clicking on "discard" button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickDiscard(ev) { + this.composer.discard(); + } + + /** + * Called when clicking on "send" button. + * + * @private + */ + _onClickSend() { + if (!this.composer.canPostMessage) { + return; + } + this._postMessage(); + } + + /** + * Called when some files have been dropped in the dropzone. + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {FileList} ev.detail.files + */ + async _onDropZoneFilesDropped(ev) { + ev.stopPropagation(); + await this._fileUploaderRef.comp.uploadFiles(ev.detail.files); + this.isDropZoneVisible.value = false; + } + + /** + * Called when selection an emoji from the emoji popover (from the emoji + * button). + * + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.unicode + */ + _onEmojiSelection(ev) { + ev.stopPropagation(); + this._textInputRef.comp.saveStateInStore(); + this.composer.insertIntoTextInput(ev.detail.unicode); + this.composer.focus(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + async _onPasteTextInput(ev) { + if (!ev.clipboardData || !ev.clipboardData.files) { + return; + } + await this._fileUploaderRef.comp.uploadFiles(ev.clipboardData.files); + } + + /** + * @private + */ + _onTextInputKeydownEnter() { + if (!this.composer.canPostMessage) { + return; + } + this._postMessage(); + } + +} + +Object.assign(Composer, { + components, + defaultProps: { + attachmentLocalIds: [], + hasCurrentPartnerAvatar: true, + hasDiscardButton: false, + hasFollowers: false, + hasSendButton: true, + hasTextInputSendOnEnterEnabled: true, + hasThreadName: false, + hasThreadTyping: false, + isCompact: true, + isDiscardOnClickAway: false, + isExpandable: false, + isFocusOnMount: false, + isLog: false, + }, + props: { + attachmentLocalIds: { + type: Array, + element: String, + }, + attachmentsDetailsMode: { + type: String, + optional: true, + }, + composerLocalId: String, + hasCurrentPartnerAvatar: Boolean, + hasDiscardButton: Boolean, + hasFollowers: Boolean, + hasMentionSuggestionsBelowPosition: { + type: Boolean, + optional: true, + }, + hasSendButton: Boolean, + hasTextInputSendOnEnterEnabled: Boolean, + hasThreadName: Boolean, + hasThreadTyping: Boolean, + showAttachmentsExtensions: { + type: Boolean, + optional: true, + }, + showAttachmentsFilenames: { + type: Boolean, + optional: true, + }, + initialAttachmentLocalIds: { + type: Array, + element: String, + optional: true, + }, + initialTextInputHtmlContent: { + type: String, + optional: true, + }, + isCompact: Boolean, + isDiscardOnClickAway: Boolean, + isExpandable: Boolean, + isFocusOnMount: Boolean, + isLog: Boolean, + }, + template: 'mail.Composer', +}); + +return Composer; + +}); diff --git a/addons/mail/static/src/components/composer/composer.scss b/addons/mail/static/src/components/composer/composer.scss new file mode 100644 index 00000000000..98557ec1ce9 --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.scss @@ -0,0 +1,278 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Composer { + display: grid; + grid-template-areas: + "sidebar-header core-header" + "sidebar-main core-main" + "sidebar-footer core-footer"; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr auto; + + &.o-has-current-partner-avatar { + grid-template-columns: 50px 1fr; + padding: map-get($spacers, 0) map-get($spacers, 3) map-get($spacers, 1) map-get($spacers, 1); + + &:not(.o-has-footer) { + padding-bottom: 20px; + } + + &:not(.o-has-header) { + padding-top: 20px; + } + } +} + +.o_Composer_actionButtons { + &.o-composer-is-compact { + display: flex; + } + &:not(.o-composer-is-compact) { + margin-top: 10px; + } +} + +.o_Composer_attachmentList { + flex: 1 1 auto; +} + +.o_Composer_buttons { + display: flex; + align-items: stretch; + flex: 0 0 auto; + height: 41px; // match minimal-height of input, including border width + + &:not(.o-composer-is-compact) { + border: 0; + height: auto; + padding: 0 10px; + width: 100%; + } + + &.o-mobile { + height: 51px; // mobile padding + } +} + +.o_Composer_coreFooter { + grid-area: core-footer; + + &:not(.o-composer-is-compact) { + margin-left: 0; + } +} + +.o_Composer_coreHeader { + grid-area: core-header; +} + +.o_Composer_coreMain { + grid-area: core-main; + min-width: 0; + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + flex: 1 1 auto; + + &:not(.o-composer-is-compact) { + flex-direction: column; + } +} + +.o_Composer_currentPartnerAvatar { + width: 36px; + height: 36px; +} + +.o_Composer_followers, +.o_Composer_suggestedPartners { + flex: 0 0 100%; + margin-bottom: $o-mail-chatter-gap * 0.5; +} + +.o_Composer_primaryToolButtons { + display: flex; + align-items: center; +} + +.o_Composer_sidebarMain { + grid-area: sidebar-main; + justify-self: center; +} + +.o_Composer_textInput { + flex: 1 1 auto; + align-self: stretch; + + &:not(.o-composer-is-compact) { + border: 0; + min-height: 40px; + + .note-editable { + min-height: 40px; + } + } +} + +.o_Composer_threadTextualTypingStatus { + font-size: $font-size-sm; + overflow: hidden; + text-overflow: ellipsis; + + &:before { + // invisible character so that typing status bar has constant height, regardless of text content. + content: "\200b"; /* unicode zero width space character */ + } +} + +.o_Composer_toolButton { + margin: 0 2px; + + &:not(.o-mobile) { + margin-top: 2px; + margin-bottom: 2px; + } +} + +.o_Composer_toolButtons { + display: flex; + + &:not(.o-composer-is-compact) { + flex-direction: row; + justify-content: space-between; + flex: 100%; + } + + &:not(:last-child) .o_Composer_toolButton:last-child { + margin-right: 5px; + } +} + +.o_Composer_toolButtonSeparator { + flex: 0 0 auto; + margin-top: 5px; + margin-bottom: 5px; + margin-right: 5px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Composer { + background-color: lighten(gray('300'), 7%); + + &.o-has-current-partner-avatar { + + .o_Composer_actionButtons.o-composer-is-compact > *:last-child { + border-radius: 0 10px 10px 0; + } + + .o_Composer_textInput { + border-radius: 10px 0 0 10px; + + > .note-editor { + border-radius: 10px 0 0 10px; + + > .note-editing-area { + border-radius: 10px 0 0 10px; + } + } + } + } +} + +.o_Composer_button { + color: gray('600'); + + &.o-open{ + background-color: gray('200'); + } + + &.o-composer-is-compact { + border-left: none; + border-radius: 0 3px 3px 0; + } +} + +// TODO FIXME o-open on the button should be enough, https://github.com/odoo/owl/issues/693 +.o_is_open { + .o_Composer_button { + background-color: gray('200'); + } +} + +.o_Composer_buttonDiscard { + border: 1px solid lighten(gray('400'), 5%); +} + +.o_Composer_buttons { + border: 0; +} + +.o_Composer_buttonSend { + border: 1px solid darken($o-brand-primary, 5%); + + &:disabled { + // enforce this style (override jQuery style) + border: 1px solid darken($o-brand-primary, 5%); + + &.o-composer-is-compact { + border-left: none; + } + } + + &:not(:last-child) { + border-right: none; + } +} + +.o_Composer_coreMain:not(.o-composer-is-compact) { + background: white; + border-radius: 5px; + border: 1px solid lighten(gray('400'), 5%); +} + +.o_Composer_textInput { + appearance: none; + outline: none; + background-color: white; + border: 0; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + border-left: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border: 0; + } +} + +.o_Composer_toolButton { + border: 0; + + &:hover { + // enforce this style (webclient btn-secondary) + border: 0; + } +} + +.o_Composer_toolButtons { + background-color: white; + border-top: 1px solid lighten(gray('400'), 5%); + border-bottom: 1px solid lighten(gray('400'), 5%); + + &:not(.o-composer-is-compact) { + border-bottom: 0; + border-radius: initial; + } + + &:last-child:not(.o-composer-has-current-partner-avatar) { + border-right: 1px solid lighten(gray('400'), 5%); + } +} + +.o_Composer_toolButtonSeparator { + border-left: 1px solid lighten(gray('400'), 5%); +} diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml new file mode 100644 index 00000000000..055c3837ccf --- /dev/null +++ b/addons/mail/static/src/components/composer/composer.xml @@ -0,0 +1,154 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Composer" owl="1"> + <div class="o_Composer" + t-att-class="{ + 'o-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-has-footer': hasFooter, + 'o-has-header': hasHeader, + 'o-is-compact': props.isCompact, + }" + > + <t t-if="composer"> + <t t-if="isDropZoneVisible.value"> + <DropZone + class="o_Composer_dropZone" + t-on-o-dropzone-files-dropped="_onDropZoneFilesDropped" + t-ref="dropzone" + /> + </t> + <FileUploader + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + newAttachmentExtraData="newAttachmentExtraData" + t-ref="fileUploader" + /> + <t t-if="hasHeader"> + <div class="o_Composer_coreHeader"> + <t t-if="props.hasThreadName and composer.thread"> + <span class="o_Composer_threadName"> + on: <b><t t-esc="composer.thread.displayName"/></b> + </span> + </t> + <t t-if="props.hasFollowers and !props.isLog"> + <!-- Text for followers --> + <small class="o_Composer_followers"> + <b class="text-muted">To: </b> + <em class="text-muted">Followers of </em> + <b> + <t t-if="composer.thread"> +  "<t t-esc="composer.thread.name"/>" + </t> + <t t-else=""> + this document + </t> + </b> + </small> + </t> + </div> + </t> + <t t-if="props.hasCurrentPartnerAvatar"> + <div class="o_Composer_sidebarMain"> + <img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt=""/> + </div> + </t> + <div class="o_Composer_coreMain" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <TextInput + class="o_Composer_textInput" + t-att-class="{ + 'o-composer-is-compact': props.isCompact, + 'o_Composer_textInput-mobile': env.messaging.device.isMobile, + }" + composerLocalId="composer.localId" + hasMentionSuggestionsBelowPosition="props.hasMentionSuggestionsBelowPosition" + hasSendOnEnterEnabled="props.hasTextInputSendOnEnterEnabled" + t-on-o-keydown-enter="_onTextInputKeydownEnter" + t-on-paste="_onPasteTextInput" + t-ref="textInput" + /> + <div class="o_Composer_buttons" t-att-class="{ 'o-composer-is-compact': props.isCompact, 'o-mobile': env.messaging.device.isMobile }"> + <div class="o_Composer_toolButtons" + t-att-class="{ + 'o-composer-has-current-partner-avatar': props.hasCurrentPartnerAvatar, + 'o-composer-is-compact': props.isCompact, + }"> + <t t-if="props.isCompact"> + <div class="o_Composer_toolButtonSeparator"/> + </t> + <div class="o_Composer_primaryToolButtons"> + <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection" t-ref="emojisPopover"> + <!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 --> + <button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-secondary" + t-att-class="{ + 'o-open': false and state.displayed, + 'o-mobile': env.messaging.device.isMobile, + }" + > + <i class="fa fa-smile-o"/> + </button> + <t t-set="opened"> + <EmojisPopover/> + </t> + </Popover> + <button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-secondary fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/> + </div> + <t t-if="props.isExpandable"> + <div class="o_Composer_secondaryToolButtons"> + <button class="btn btn-secondary fa fa-expand o_Composer_button o_Composer_buttonFullComposer o_Composer_toolButton" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Full composer" type="button" t-on-click="_onClickFullComposer"/> + </div> + </t> + </div> + <t t-if="props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </div> + <t t-if="hasFooter"> + <div class="o_Composer_coreFooter" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasThreadTyping"> + <ThreadTextualTypingStatus class="o_Composer_threadTextualTypingStatus" threadLocalId="composer.thread.localId"/> + </t> + <t t-if="composer.attachments.length > 0"> + <AttachmentList + class="o_Composer_attachmentList" + areAttachmentsEditable="true" + attachmentsDetailsMode="props.attachmentsDetailsMode" + attachmentLocalIds="composer.attachments.map(attachment => attachment.localId)" + showAttachmentsExtensions="props.showAttachmentsExtensions" + showAttachmentsFilenames="props.showAttachmentsFilenames" + /> + </t> + <t t-if="!props.isCompact"> + <t t-call="mail.Composer.actionButtons"/> + </t> + </div> + </t> + </t> + </div> + </t> + + <t t-name="mail.Composer.actionButtons" owl="1"> + <div class="o_Composer_actionButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }"> + <t t-if="props.hasSendButton"> + <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonSend btn btn-primary" + t-att-class="{ + 'fa': env.messaging.device.isMobile, + 'fa-paper-plane-o': env.messaging.device.isMobile, + 'o-composer-is-compact': props.isCompact, + }" + t-att-disabled="(!composer.textInputContent and composer.attachments.length === 0) ? 'disabled' : ''" + type="button" + t-on-click="_onClickSend" + > + <t t-if="!env.messaging.device.isMobile"><t t-if="props.isLog">Log</t><t t-else="">Send</t></t> + </button> + </t> + <t t-if="!env.messaging.device.isMobile and props.hasDiscardButton"> + <button class="o_Composer_actionButton o_Composer_button o_Composer_buttonDiscard btn btn-secondary" t-att-class="{ 'o-composer-is-compact': props.isCompact }" type="button" t-on-click="_onClickDiscard"> + Discard + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js new file mode 100644 index 00000000000..e506e99a48e --- /dev/null +++ b/addons/mail/static/src/components/composer/composer_tests.js @@ -0,0 +1,866 @@ +odoo.define('mail/static/src/components/composer/composer_tests.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + dragenterFiles, + dropFiles, + inputFiles, + nextAnimationFrame, + pasteFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('composer', {}, function () { +QUnit.module('composer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createComposerComponent = async (composer, otherProps) => { + const ComposerComponent = components.Composer; + ComposerComponent.env = this.env; + this.component = new ComposerComponent(null, Object.assign({ + composerLocalId: composer.localId, + }, otherProps)); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + async afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.Composer.env; + }, +}); + +QUnit.test('composer text input: basic rendering', async function (assert) { + assert.expect(5); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + assert.strictEqual( + document.querySelectorAll('.o_Composer').length, + 1, + "should have composer in discuss thread" + ); + assert.strictEqual( + document.querySelectorAll('.o_Composer_textInput').length, + 1, + "should have text input inside discuss thread composer" + ); + assert.ok( + document.querySelector('.o_Composer_textInput').classList.contains('o_ComposerTextInput'), + "composer text input of composer should be a ComposerTextIput component" + ); + assert.strictEqual( + document.querySelectorAll(`.o_ComposerTextInput_textarea`).length, + 1, + "should have editable part inside composer text input" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).placeholder, + "Write something...", + "should have placeholder in note editable of composer text input" + ); +}); + +QUnit.test('add an emoji', async function (assert) { + assert.expect(1); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "emoji should be inserted in the composer text input" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add an emoji after some text', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla", + "composer text input should have text only initially" + ); + + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Blabla😊", + "emoji should be inserted after the text" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const composerTextInputTextArea = document.querySelector(`.o_ComposerTextInput_textarea`); + await afterNextRender(() => { + composerTextInputTextArea.focus(); + document.execCommand('insertText', false, "Blabla"); + }); + assert.strictEqual( + composerTextInputTextArea.value, + "Blabla", + "composer text input should have text only initially" + ); + + // simulate selection of all the content by keyboard + composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length); + + // select emoji + await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "😊", + "whole text selection should have been replaced by emoji" + ); + // ensure popover is closed + await nextAnimationFrame(); + await nextAnimationFrame(); + await nextAnimationFrame(); +}); + +QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.hasClass( + document.querySelector('.o_ComposerTextInput_mentionDropdownPropositionList'), + 'show', + "should display mention suggestions on typing '@'" + ); +}); + +QUnit.test('mention a partner', async function (assert) { + assert.expect(4); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_PartnerMentionSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('mention a partner after some text', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + await afterNextRender(() => + document.execCommand('insertText', false, "bluhbluh ") + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "bluhbluh ", + "text content of composer should have content" + ); + await afterNextRender(() => + document.execCommand('insertText', false, "@") + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_PartnerMentionSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "bluhbluh @TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); +}); + +QUnit.test('add an emoji after a partner mention', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'get_mention_suggestions') { + return [ + [{ + email: "testpartnert@odoo.com", + id: 11, + name: "TestPartner", + }], + [], + ]; + } + return this._super(...arguments); + }, + }); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + + assert.containsNone( + document.body, + '.o_ComposerTextInput_mentionDropdownPropositionList', + "mention suggestions list should not be present" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "text content of composer should be empty initially" + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "@"); + }); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown')); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keyup')); + }); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "should have a mention suggestion" + ); + await afterNextRender(() => + document.querySelector('.o_PartnerMentionSuggestion').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner ", + "text content of composer should have previous content + mentioned partner + additional whitespace afterwards" + ); + + // select emoji + await afterNextRender(() => + document.querySelector('.o_Composer_buttonEmojis').click() + ); + await afterNextRender(() => + document.querySelector('.o_EmojisPopover_emoji[data-unicode="😊"]').click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value.replace(/\s/, " "), + "@TestPartner 😊", + "text content of composer should have previous mention and selected emoji just after" + ); + // ensure popover is closed + await nextAnimationFrame(); +}); + +QUnit.test('composer: add an attachment', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer, { attachmentsDetailsMode: 'card' }); + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ) + ); + assert.ok( + document.querySelector('.o_Composer_attachmentList'), + "should have an attachment list" + ); + assert.ok( + document.querySelector(`.o_Composer .o_Attachment`), + "should have an attachment" + ); +}); + +QUnit.test('composer: drop attachments', async function (assert) { + assert.expect(4); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }), + await createFile({ + content: 'hello, worlduh', + contentType: 'text/plain', + name: 'text2.txt', + }), + ]; + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have a drop zone" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should have no attachment before files are dropped" + ); + + await afterNextRender(() => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + files + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 2, + "should have 2 attachments in the composer after files dropped" + ); + + await afterNextRender(() => dragenterFiles(document.querySelector('.o_Composer'))); + await afterNextRender(async () => + dropFiles( + document.querySelector('.o_Composer_dropZone'), + [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text3.txt', + }) + ] + ) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the box after files dropped" + ); +}); + +QUnit.test('composer: paste attachments', async function (assert) { + assert.expect(2); + + await this.start(); + const composer = this.env.models['mail.composer'].create(); + await this.createComposerComponent(composer); + const files = [ + await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }) + ]; + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 0, + "should not have any attachment in the composer before paste" + ); + + await afterNextRender(() => + pasteFiles(document.querySelector('.o_ComposerTextInput'), files) + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer after paste" + ); +}); + +QUnit.test('composer text input cleared on message post', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + assert.step('message_post'); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer); + // Type message + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "test message"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "test message", + "should have inserted text content in editable" + ); + + // Send message + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); +}); + +QUnit.test('composer with thread typing notification status', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }], + }, + }); + await this.start(); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + assert.containsOnce( + document.body, + '.o_Composer_threadTextualTypingStatus', + "Composer should have a thread textual typing status bar" + ); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "By default, thread textual typing status bar should be empty" + ); +}); + +QUnit.test('current partner notify is typing to other thread members', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); +}); + +QUnit.test('current partner is typing should not translate on textual typing status', async function (assert) { + assert.expect(3); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner typing status" + ); + + await nextAnimationFrame(); + assert.strictEqual( + document.body.querySelector('.o_Composer_threadTextualTypingStatus').textContent, + "", + "Thread textual typing status bar should not display current partner is typing" + ); +}); + +QUnit.test('current partner notify no longer is typing to thread members after 5 seconds inactivity', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + await this.env.testUtils.advanceTime(5 * 1000); + assert.verifySteps( + ['notify_typing:false'], + "should have notified current partner no longer is typing (inactive for 5 seconds)" + ); +}); + +QUnit.test('current partner notify is typing again to other members every 50s of long continuous typing', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + async mockRPC(route, args) { + if (args.method === 'notify_typing') { + assert.step(`notify_typing:${args.kwargs.is_typing}`); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createComposerComponent(thread.composer, { hasThreadTyping: true }); + + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is typing" + ); + + // simulate current partner typing a character every 2.5 seconds for 50 seconds straight. + let totalTimeElapsed = 0; + const elapseTickTime = 2.5 * 1000; + while (totalTimeElapsed < 50 * 1000) { + document.execCommand('insertText', false, "a"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' })); + totalTimeElapsed += elapseTickTime; + await this.env.testUtils.advanceTime(elapseTickTime); + } + + assert.verifySteps( + ['notify_typing:true'], + "should have notified current partner is still typing after 50s of straight typing" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js new file mode 100644 index 00000000000..198f563d1c7 --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -0,0 +1,334 @@ +odoo.define('mail/static/src/components/composer_text_input/composer_text_input.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'), +}; + +const { Component } = owl; +const { useRef } = owl.hooks; + +/** + * ComposerInput relies on a minimal HTML editor in order to support mentions. + */ +class ComposerTextInput extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const composer = this.env.models['mail.composer'].get(props.composerLocalId); + return { + composer: composer ? composer.__state : undefined, + isDeviceMobile: this.env.messaging.device.isMobile, + }; + }); + /** + * Last content of textarea from input event. Useful to determine + * whether the current partner is typing something. + */ + this._textareaLastInputValue = ""; + /** + * Reference of the textarea. Useful to set height, selection and content. + */ + this._textareaRef = useRef('textarea'); + } + + /** + * Updates the composer text input content when composer is mounted + * as textarea content can't be changed from the DOM. + */ + mounted() { + this._update(); + } + + /** + * Updates the composer text input content when composer has changed + * as textarea content can't be changed from the DOM. + */ + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.composer} + */ + get composer() { + return this.env.models['mail.composer'].get(this.props.composerLocalId); + } + + focusout() { + this.saveStateInStore(); + this._textareaRef.el.blur(); + } + + /** + * Saves the composer text input state in store + */ + saveStateInStore() { + this.composer.update({ + textInputContent: this._getContent(), + textInputCursorStart: this._getSelectionStart(), + textInputCursorEnd: this._getSelectionEnd(), + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns textarea current content. + * + * @private + * @returns {string} + */ + _getContent() { + return this._textareaRef.el.value; + } + + /** + * Returns selection end position. + * + * @private + * @returns {integer} + */ + _getSelectionEnd() { + return this._textareaRef.el.selectionEnd; + } + + /** + * Returns selection start position. + * + * @private + * @returns {integer} + * + */ + _getSelectionStart() { + return this._textareaRef.el.selectionStart; + } + + /** + * Determines whether the textarea is empty or not. + * + * @private + * @returns {boolean} + */ + _isEmpty() { + return this._getContent() === ""; + } + + /** + * Updates the content and height of a textarea + * + * @private + */ + _update() { + this._textareaRef.el.value = this.composer.textInputContent; + this._textareaRef.el.setSelectionRange( + this.composer.textInputCursorStart, + this.composer.textInputCursorEnd + ); + this._updateHeight(); + if (this.composer.isDoFocus) { + this._textareaRef.el.focus(); + this.composer.update({ isDoFocus: false }); + } + } + + /** + * Updates the textarea height. + * + * @private + */ + _updateHeight() { + this._textareaRef.el.style.height = "0px"; + this._textareaRef.el.style.height = (this._textareaRef.el.scrollHeight) + "px"; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputTextarea() { + if (this._textareaLastInputValue !== this._textareaRef.el.value) { + this.composer.handleCurrentPartnerIsTyping(); + } + this._textareaLastInputValue = this._textareaRef.el.value; + this._updateHeight(); + this.saveStateInStore(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextarea(ev) { + switch (ev.key) { + // UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions + case 'ArrowUp': + case 'PageUp': + case 'ArrowDown': + case 'PageDown': + case 'Home': + case 'End': + case 'Tab': + if (this.composer.hasSuggestedPartners) { + // We use preventDefault here to avoid keys native actions but actions are handled in keyUp + ev.preventDefault(); + } + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case 'Enter': + this._onKeydownTextareaEnter(ev); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownTextareaEnter(ev) { + if (this.composer.hasSuggestedPartners) { + ev.preventDefault(); + } else { + if (!this.props.hasSendOnEnterEnabled) { + return; + } + if (ev.shiftKey) { + return; + } + if (this.env.messaging.device.isMobile) { + return; + } + this.trigger('o-keydown-enter'); + ev.preventDefault(); + } + } + + /** + * Key events management is performed in a Keyup to avoid intempestive RPC calls + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyupTextarea(ev) { + switch (ev.key) { + // ESCAPE: close mention suggestions + case 'Escape': + this._onKeyupTextareaEscape(ev); + break; + // ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions + case 'Enter': + if (this.composer.hasSuggestedPartners) { + if (this.composer.activeSuggestedPartner) { + this.composer.insertMentionedPartner(this.composer.activeSuggestedPartner); + this.composer.closeMentionSuggestions(); + this.composer.focus(); + } + } + break; + case 'ArrowUp': + case 'PageUp': + if (this.composer.hasSuggestedPartners) { + this.composer.setPreviousSuggestedPartnerActive(); + } + break; + case 'ArrowDown': + case 'PageDown': + if (this.composer.hasSuggestedPartners) { + this.composer.setNextSuggestedPartnerActive(); + } + break; + case 'Home': + if (this.composer.hasSuggestedPartners) { + this.composer.setFirstSuggestedPartnerActive(); + } + break; + case 'End': + if (this.composer.hasSuggestedPartners) { + this.composer.setLastSuggestedPartnerActive(); + } + break; + case 'Tab': + if (this.composer.hasSuggestedPartners) { + if (ev.shiftKey) { + this.composer.setPreviousSuggestedPartnerActive(); + } else { + this.composer.setNextSuggestedPartnerActive(); + } + } + break; + // Otherwise, check if a mention is typed + default: + this.saveStateInStore(); + this.composer._detectDelimiter(); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeyupTextareaEscape(ev) { + if (this.composer.hasSuggestedPartners) { + this.composer.closeMentionSuggestions(); + } else { + if (!this._isEmpty()) { + return; + } + this.composer.discard(); + } + } + + /** + * @private + * @param {Event} ev + */ + _onPartnerMentionSuggestionClicked(ev) { + this.composer.insertMentionedPartner(ev.detail.partner); + this.composer.closeMentionSuggestions(); + this.composer.focus(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onPartnerMentionSuggestionMouseOver(ev) { + this.composer.update({ + activeSuggestedPartner: [['link', ev.detail.partner]], + }); + } + +} + +Object.assign(ComposerTextInput, { + components, + defaultProps: { + hasMentionSuggestionsBelowPosition: false, + hasSendOnEnterEnabled: true, + }, + props: { + hasMentionSuggestionsBelowPosition: Boolean, + hasSendOnEnterEnabled: Boolean, + composerLocalId: String, + }, + template: 'mail.ComposerTextInput', +}); + +return ComposerTextInput; + +}); diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.scss b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss new file mode 100644 index 00000000000..fe3ad16788d --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.scss @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ComposerTextInput { + min-width: 0; + position: relative; +} + +.o_ComposerTextInput_mentionDropdown { + position: absolute; + + &.o-lowPosition { + bottom: 0; + } +} + +.o_ComposerTextInput_textarea { + padding: 10px; + max-height: 200px; + resize: none; + border-radius: 10px 0 0 10px; + border: none; + overflow: auto +} diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml new file mode 100644 index 00000000000..f68b295cb8d --- /dev/null +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ComposerTextInput" owl="1"> + <div class="o_ComposerTextInput"> + <t t-if="composer"> + <t t-if="composer.hasSuggestedPartners"> + <div class="o_ComposerTextInput_mentionDropdown" t-att-class="{ 'o-lowPosition': props.hasMentionSuggestionsBelowPosition }"> + <div + t-att-class="{ + 'dropdown': props.hasMentionSuggestionsBelowPosition, + 'dropup': !props.hasMentionSuggestionsBelowPosition, + }" + > + <div class="o_ComposerTextInput_mentionDropdownPropositionList dropdown-menu show"> + <t t-foreach="composer.mainSuggestedPartners" t-as="mainSuggestedPartner" t-key="mainSuggestedPartner.localId"> + <PartnerMentionSuggestion + isActive="mainSuggestedPartner === composer.activeSuggestedPartner" + partnerLocalId="mainSuggestedPartner.localId" + t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked" + t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver" + /> + </t> + <t t-if="composer.mainSuggestedPartners.length > 0 and composer.extraSuggestedPartners.length > 0"> + <div role="separator" class="dropdown-divider"/> + </t> + <t t-foreach="composer.extraSuggestedPartners" t-as="extraSuggestedPartner" t-key="extraSuggestedPartner.localId"> + <PartnerMentionSuggestion + isActive="extraSuggestedPartner === composer.activeSuggestedPartner" + partnerLocalId="extraSuggestedPartner.localId" + t-on-o-partner-mention-suggestion-clicked="_onPartnerMentionSuggestionClicked" + t-on-o-partner-mention-suggestion-mouse-over="_onPartnerMentionSuggestionMouseOver" + /> + </t> + </div> + </div> + </div> + </t> + <textarea class="o_ComposerTextInput_textarea" t-esc="composer.textInputContent" placeholder="Write something..." t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog/dialog.js b/addons/mail/static/src/components/dialog/dialog.js new file mode 100644 index 00000000000..dc7ddf88e0a --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.js @@ -0,0 +1,105 @@ +odoo.define('mail/static/src/components/dialog/dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Dialog extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + /** + * Reference to the component used inside this dialog. + */ + this._componentRef = useRef('component'); + this._onClickGlobal = this._onClickGlobal.bind(this); + this._onKeydownDocument = this._onKeydownDocument.bind(this); + useStore(props => { + const dialog = this.env.models['mail.dialog'].get(props.dialogLocalId); + return { + dialog: dialog ? dialog.__state : undefined, + }; + }); + } + + mounted() { + document.addEventListener('click', this._onClickGlobal, true); + document.addEventListener('keydown', this._onKeydownDocument); + } + + willUnmount() { + document.removeEventListener('click', this._onClickGlobal, true); + document.removeEventListener('keydown', this._onKeydownDocument); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.dialog} + */ + get dialog() { + return this.env.models['mail.dialog'].get(this.props.dialogLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on this dialog. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + ev.stopPropagation(); + } + + /** + * Closes the dialog when clicking outside. + * Does not work with attachment viewer because it takes the whole space. + * + * @private + * @param {MouseEvent} ev + */ + _onClickGlobal(ev) { + if (this._componentRef.el.contains(ev.target)) { + return; + } + // TODO: this should be child logic (will crash if child doesn't have isCloseable!!) + // task-2092965 + if (this._componentRef.comp.isCloseable && !this._componentRef.comp.isCloseable()) { + return; + } + this.dialog.close(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownDocument(ev) { + if (ev.key === 'Escape') { + this.dialog.close(); + } + } + +} + +Object.assign(Dialog, { + props: { + dialogLocalId: String, + }, + template: 'mail.Dialog', +}); + +return Dialog; + +}); diff --git a/addons/mail/static/src/components/dialog/dialog.scss b/addons/mail/static/src/components/dialog/dialog.scss new file mode 100644 index 00000000000..00d6e24ff48 --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.scss @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Dialog { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Dialog { + background-color: rgba(0, 0, 0, 0.7); +} diff --git a/addons/mail/static/src/components/dialog/dialog.xml b/addons/mail/static/src/components/dialog/dialog.xml new file mode 100644 index 00000000000..3c953decbbc --- /dev/null +++ b/addons/mail/static/src/components/dialog/dialog.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Dialog" owl="1"> + <div class="o_Dialog"> + <t t-if="dialog"> + <t t-if="dialog.record"> + <t + t-component="{{ dialog.record['constructor'].name }}" + class="o_Dialog_component" + t-props="{ localId: dialog.record.localId }" + t-ref="component" + /> + </t> + <t t-else=""> + <span>Only dialog linked to a record is currently supported!</span> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.js b/addons/mail/static/src/components/dialog_manager/dialog_manager.js new file mode 100644 index 00000000000..810b7247fe2 --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.js @@ -0,0 +1,58 @@ +odoo.define('mail/static/src/components/dialog_manager/dialog_manager.js', function (require) { +'use strict'; + +const components = { + Dialog: require('mail/static/src/components/dialog/dialog.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class DialogManager extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + return { + dialogManager: this.env.messaging.dialogManager.__state, + }; + }); + } + + mounted() { + this._checkDialogOpen(); + } + + patched() { + this._checkDialogOpen(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _checkDialogOpen() { + if (this.env.messaging.dialogManager.dialogs.length > 0) { + document.body.classList.add('modal-open'); + } else { + document.body.classList.remove('modal-open'); + } + } + +} + +Object.assign(DialogManager, { + components, + props: {}, + template: 'mail.DialogManager', +}); + +return DialogManager; + +}); diff --git a/addons/mail/static/src/components/dialog_manager/dialog_manager.xml b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml new file mode 100644 index 00000000000..994d7d9c9d7 --- /dev/null +++ b/addons/mail/static/src/components/dialog_manager/dialog_manager.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DialogManager" owl="1"> + <div class="o_DialogManager"> + <t t-foreach="env.messaging.dialogManager.allOrdered" t-as="dialog" t-key="dialog.localId"> + <Dialog + class="o_DialogManager_dialog" + dialogLocalId="dialog.localId" + /> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js new file mode 100644 index 00000000000..271235bccb0 --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.js @@ -0,0 +1,277 @@ +odoo.define('mail/static/src/components/discuss/discuss.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + Composer: require('mail/static/src/components/composer/composer.js'), + DiscussMobileMailboxSelection: require('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js'), + DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class Discuss extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const discuss = this.env.messaging.discuss; + return { + checkedMessages: discuss.threadViewer.checkedMessages.map(message => message.__state), + discuss: discuss.__state, + isDeviceMobile: this.env.messaging.device.isMobile, + isMessagingInitialized: this.env.messaging.isInitialized, + thread: discuss.thread ? discuss.thread.__state : undefined, + threadCache: discuss.threadViewer.threadCache + ? discuss.threadViewer.threadCache.__state + : undefined, + uncheckedMessages: discuss.threadViewer.uncheckedMessages.map(message => message.__state), + }; + }, { + compareDepth: { + checkedMessages: 1, + uncheckedMessages: 1, + }, + }); + this._updateLocalStoreProps(); + /** + * Reference of the thread. Useful to update scroll position correctly + * on patch. AKU TODO: this made sense when composer was outside of + * thread, but this may no longer be necessary?? + */ + this._threadRef = useRef('thread'); + + // bind since passed as props + this._onMobileAddItemHeaderInputSelect = this._onMobileAddItemHeaderInputSelect.bind(this); + this._onMobileAddItemHeaderInputSource = this._onMobileAddItemHeaderInputSource.bind(this); + } + + mounted() { + this.discuss.update({ isOpen: true }); + if (this.discuss.thread) { + this.trigger('o-push-state-action-manager'); + } else if (this.env.messaging.isInitialized) { + this.discuss.openInitThread(); + } + this._updateLocalStoreProps(); + } + + patched() { + this.trigger('o-update-control-panel'); + if (this.discuss.thread) { + this.trigger('o-push-state-action-manager'); + } + if ( + this.discuss.thread && + this.discuss.thread === this.env.messaging.inbox && + this._lastThreadCache === this.discuss.threadViewer.threadCache.localId && + this._lastThreadCounter > 0 && this.discuss.thread.counter === 0 + ) { + this.trigger('o-show-rainbow-man'); + } + this._activeThreadCache = this.discuss.threadViewer.threadCache; + this._updateLocalStoreProps(); + } + + willUnmount() { + if (this.discuss) { + this.discuss.close(); + } + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get addChannelInputPlaceholder() { + return this.env._t("Create or search channel..."); + } + + /** + * @returns {string} + */ + get addChatInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {Object[]} + */ + mobileNavbarTabs() { + return [{ + icon: 'fa fa-inbox', + id: 'mailbox', + label: this.env._t("Mailboxes"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _updateLocalStoreProps() { + /** + * Locally tracked store props `activeThreadCache`. + * Useful to set scroll position from last stored one and to display + * rainbox man on inbox. + */ + this._lastThreadCache = ( + this.discuss.threadViewer.threadCache && + this.discuss.threadViewer.threadCache.localId + ); + /** + * Locally tracked store props `threadCounter`. + * Useful to display the rainbow man on inbox. + */ + this._lastThreadCounter = ( + this.discuss.thread && + this.discuss.thread.counter + ); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.discuss.update({ hasModerationDiscardDialog: false }); + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.discuss.update({ hasModerationRejectDialog: false }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileAddItemHeader(ev) { + ev.stopPropagation(); + this.discuss.clearIsAddingItem(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileAddItemHeaderInputSelect(ev, ui) { + const discuss = this.discuss; + if (discuss.isAddingChannel) { + discuss.handleAddChannelAutocompleteSelect(ev, ui); + } else { + discuss.handleAddChatAutocompleteSelect(ev, ui); + } + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileAddItemHeaderInputSource(req, res) { + if (this.discuss.isAddingChannel) { + this.discuss.handleAddChannelAutocompleteSource(req, res); + } else { + this.discuss.handleAddChatAutocompleteSource(req, res); + } + } + + /** + * @private + */ + _onReplyingToMessageMessagePosted() { + this.env.services['notification'].notify({ + message: _.str.sprintf( + this.env._t(`Message posted on "%s"`), + owl.utils.escape(this.discuss.replyingToMessage.originThread.displayName) + ), + type: 'warning', + }); + this.discuss.clearReplyingToMessage(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + if (this.discuss.activeMobileNavbarTabId === ev.detail.tabId) { + return; + } + this.discuss.clearReplyingToMessage(); + this.discuss.update({ activeMobileNavbarTabId: ev.detail.tabId }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.thread} ev.detail.thread + */ + _onSelectThread(ev) { + this.discuss.threadViewer.update({ thread: [['link', ev.detail.thread]] }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onThreadRendered(ev) { + this.trigger('o-update-control-panel'); + } + +} + +Object.assign(Discuss, { + components, + props: {}, + template: 'mail.Discuss', +}); + +return Discuss; + +}); diff --git a/addons/mail/static/src/components/discuss/discuss.scss b/addons/mail/static/src/components/discuss/discuss.scss new file mode 100644 index 00000000000..5cddf1017ba --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.scss @@ -0,0 +1,114 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_action_manager { + // bug with scrollable inside discuss mobile without this... + min-height: 0; +} + +.o-autogrow { + flex: 1 1 auto; +} + +.o_Discuss { + display: flex; + height: 100%; + min-height: 0; + + &.o-mobile { + flex-flow: column; + align-items: center; + } +} + +.o_Discuss_chatWindowHeader { + width: 100%; + flex: 0 0 auto; +} + +.o_Discuss_content { + height: 100%; + overflow: auto; + flex: 1 1 auto; + display: flex; + flex-flow: column; +} + +.o_Discuss_messagingNotInitialized { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.o_Discuss_messagingNotInitializedIcon { + margin-right: 3px; +} + +.o_Discuss_mobileAddItemHeader { + display: flex; + justify-content: center; + width: 100%; + padding: 0 10px; +} + +.o_Discuss_mobileAddItemHeaderInput { + flex: 1 1 auto; + margin-bottom: 8px; + padding: 8px; +} + +.o_Discuss_mobileMailboxSelection { + width: 100%; +} + +.o_Discuss_mobileNavbar { + width: 100%; +} + +.o_Discuss_noThread { + display: flex; + flex: 1 1 auto; + width: 100%; + align-items: center; + justify-content: center; +} + +.o_Discuss_replyingToMessageComposer { + width: 100%; +} + +.o_Discuss_sidebar { + height: 100%; + overflow: auto; + padding-top: 10px; + flex: 0 0 auto; +} + +.o_Discuss_thread { + flex: 1 1 auto; + + &.o-mobile { + width: 100%; + } +} + +.o_Discuss_notificationList { + width: 100%; + flex: 1 1 auto; +} +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Discuss.o-mobile { + background-color: white; +} + +.o_Discuss_mobileAddItemHeaderInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml new file mode 100644 index 00000000000..789c7a465c8 --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss.xml @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Discuss" owl="1"> + <div class="o_Discuss" + t-att-class="{ + 'o-adding-item': discuss.isAddingChannel or discuss.isAddingChat, + 'o-mobile': env.messaging.device.isMobile, + }" + > + <t t-if="!env.messaging.isInitialized"> + <div class="o_Discuss_messagingNotInitialized"><i class="o_Discuss_messagingNotInitializedIcon fa fa-spinner fa-spin"/>Please wait...</div> + </t> + <t t-else=""> + <t t-if="!env.messaging.device.isMobile"> + <DiscussSidebar class="o_Discuss_sidebar"/> + </t> + <t t-if="env.messaging.device.isMobile" t-call="mail.Discuss.content"/> + <t t-else=""> + <div class="o_Discuss_content"> + <t t-call="mail.Discuss.content"/> + </div> + </t> + </t> + + <t t-if="discuss.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="discuss.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="discuss.threadViewer.checkedMessages.map(message => message.localId)" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </div> + </t> + + <t t-name="mail.Discuss.content" owl="1"> + <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox'"> + <DiscussMobileMailboxSelection class="o_Discuss_mobileMailboxSelection"/> + </t> + <t t-if="env.messaging.device.isMobile and (discuss.isAddingChannel or discuss.isAddingChat)"> + <div class="o_Discuss_mobileAddItemHeader"> + <AutocompleteInput + class="o_Discuss_mobileAddItemHeaderInput" + isFocusOnMount="true" + isHtml="discuss.isAddingChannel" + placeholder="discuss.isAddingChannel ? addChannelInputPlaceholder : addChatInputPlaceholder" + select="_onMobileAddItemHeaderInputSelect" + source="_onMobileAddItemHeaderInputSource" + t-on-o-hide="_onHideMobileAddItemHeader" + /> + </div> + </t> + <t t-if="(env.messaging.device.isMobile and discuss.activeMobileNavbarTabId === 'mailbox' and discuss.thread and discuss.thread.model === 'mail.box') or (!env.messaging.device.isMobile and discuss.thread)"> + <ThreadViewer + class="o_Discuss_thread" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + composerAttachmentsDetailsMode="'card'" + hasComposer="discuss.thread.model !== 'mail.box'" + hasComposerCurrentPartnerAvatar="!env.messaging.device.isMobile" + hasComposerThreadTyping="true" + hasMessageCheckbox="true" + hasSquashCloseMessages="discuss.thread.model !== 'mail.box'" + haveMessagesAuthorRedirect="discuss.thread.channel_type !== 'chat'" + haveMessagesMarkAsReadIcon="discuss.thread === env.messaging.inbox" + haveMessagesReplyIcon="discuss.thread === env.messaging.inbox" + selectedMessageLocalId=" + discuss.replyingToMessage and + discuss.replyingToMessage.localId" + threadViewerLocalId="discuss.threadViewer.localId" + t-on-o-rendered="_onThreadRendered" + /> + </t> + <t t-if="!discuss.thread and (!env.messaging.device.isMobile or discuss.activeMobileNavbarTabId === 'mailbox')"> + <div class="o_Discuss_noThread"> + No conversation selected. + </div> + </t> + <t t-if="env.messaging.device.isMobile and discuss.activeMobileNavbarTabId !== 'mailbox'"> + <NotificationList + class="o_Discuss_notificationList" + filter="discuss.activeMobileNavbarTabId" + t-on-o-select-thread="_onSelectThread" + /> + </t> + <t t-if="env.messaging.device.isMobile and !discuss.isReplyingToMessage"> + <MobileMessagingNavbar + class="o_Discuss_mobileNavbar" + activeTabId="discuss.activeMobileNavbarTabId" + tabs="mobileNavbarTabs()" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + <t t-if="discuss.isReplyingToMessage"> + <Composer + class="o_Discuss_replyingToMessageComposer" + composerLocalId="discuss.replyingToMessage.originThread.composer.localId" + hasCurrentPartnerAvatar="!env.messaging.device.isMobile" + hasDiscardButton="true" + hasThreadName="true" + isDiscardOnClickAway="true" + isFocusOnMount="true" + t-on-o-message-posted="_onReplyingToMessageMessagePosted" + /> + </t> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss/discuss_tests.js b/addons/mail/static/src/components/discuss/discuss_tests.js new file mode 100644 index 00000000000..7a37349041f --- /dev/null +++ b/addons/mail/static/src/components/discuss/discuss_tests.js @@ -0,0 +1,5523 @@ +odoo.define('mail/static/src/components/discuss/discuss_tests.js', function (require) { +'use strict'; + +const BusService = require('bus.BusService'); + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + inputFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); +const { makeTestPromise, file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('discuss', {}, function () { +QUnit.module('discuss_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + autoOpenDiscuss: true, + data: this.data, + hasDiscuss: true, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.widget) { + this.widget.destroy(); + } + }, +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(1); + + await this.start({ + async mockRPC(route) { + if (route === '/mail/init_messaging') { + // simulate messaging never initialized + return new Promise(resolve => {}); + } + return this._super(...arguments); + }, + waitUntilMessagingInitialized: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 1, + "should display messaging not initialized" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingInitialized: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 1, + "should display messaging not initialized" + ); + + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_messagingNotInitialized').length, + 0, + "should no longer display messaging not initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(4); + + await this.start(); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_sidebar').length, + 1, + "should have a sidebar section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_content').length, + 1, + "should have content section" + ); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_thread').length, + 1, + "should have thread section inside content" + ); + assert.ok( + document.querySelector('.o_Discuss_thread').classList.contains('o_ThreadViewer'), + "thread section should use ThreadViewer component" + ); +}); + +QUnit.test('basic rendering: sidebar', async function (assert) { + assert.expect(20); + + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_group`).length, + 3, + "should have 3 groups in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupMailbox`).length, + 1, + "should have group 'Mailbox' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_groupHeader + `).length, + 0, + "mailbox category should not have any header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox .o_DiscussSidebar_item + `).length, + 3, + "should have 3 mailbox items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).length, + 1, + "should have inbox mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).length, + 1, + "should have starred mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).length, + 1, + "should have history mailbox item" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_sidebar .o_DiscussSidebar_separator`).length, + 1, + "should have separator (between mailboxes and channels, but that's not tested)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel`).length, + 1, + "should have group 'Channel' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeader + `).length, + 1, + "channel category should have a header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle + `).length, + 1, + "should have title in channel header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupTitle + `).textContent.trim(), + "Channels" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_list`).length, + 1, + "channel category should list items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 0, + "channel category should have no item by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat`).length, + 1, + "should have group 'Chat' in sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeader`).length, + 1, + "channel category should have a header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle`).length, + 1, + "should have title in chat header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChat .o_DiscussSidebar_groupTitle + `).textContent.trim(), + "Direct Messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_list`).length, + 1, + "chat category should list items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 0, + "chat category should have no item by default" + ); +}); + +QUnit.test('sidebar: basic mailbox rendering', async function (assert) { + assert.expect(6); + + await this.start(); + const inbox = document.querySelector(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "mailbox should have active indicator" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "mailbox should have an icon" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_ThreadIcon_mailboxInbox`).length, + 1, + "inbox should have 'inbox' icon" + ); + assert.strictEqual( + inbox.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "mailbox should have a name" + ); + assert.strictEqual( + inbox.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Inbox", + "inbox should have name 'Inbox'" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "should have no counter when equal to 0 (default value)" + ); +}); + +QUnit.test('sidebar: default active inbox', async function (assert) { + assert.expect(1); + + await this.start(); + const inbox = document.querySelector(` + .o_DiscussSidebar_groupMailbox + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `); + assert.ok( + inbox.querySelector(` + :scope .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox should be active by default" + ); +}); + +QUnit.test('sidebar: change item', async function (assert) { + assert.expect(4); + + await this.start(); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox should be active by default" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred should be inactive by default" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "inbox mailbox should become inactive" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active"); +}); + +QUnit.test('sidebar: inbox with counter', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + needaction_inbox_counter: 100, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 1, + "should display a counter (= have a counter when different from 0)" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "100", + "should have counter value" + ); +}); + +QUnit.test('sidebar: add channel', async function (assert) { + assert.expect(3); + + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_groupHeaderItemAdd + `).length, + 1, + "should be able to add channel from header" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_groupHeaderItemAdd + `).title, + "Add or join a channel"); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd + `).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_itemNew`).length, + 1, + "should have item to add a new channel" + ); +}); + +QUnit.test('sidebar: basic channel rendering', async function (assert) { + assert.expect(14); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 1, + "should have one channel item"); + let channel = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item + `); + assert.strictEqual( + channel.dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId, + "should have channel with Id 20" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "should have active indicator" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length, + 0, + "should not be active by default" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have an icon" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "should have a name" + ); + assert.strictEqual( + channel.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "General", + "should have name value" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length, + 1, + "should have commands" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 2, + "should have 2 commands" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length, + 1, + "should have 'settings' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length, + 1, + "should have 'leave' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 0, + "should have a counter when equals 0 (default value)" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).click() + ); + channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator.o-item-active`).length, + 1, + "channel should become active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_composer`).length, + 1, + "should have composer section inside thread content (can post message in channel)" + ); +}); + +QUnit.test('sidebar: channel rendering with needaction counter', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + message_needaction_counter: 10, + }], + }, + }); + await this.start(); + const channel = document.querySelector(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 1, + "should have a counter when different from 0" + ); + assert.strictEqual( + channel.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent, + "10", + "should have counter value" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 1, + "should have single command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandSettings`).length, + 1, + "should have 'settings' command" + ); + assert.strictEqual( + channel.querySelectorAll(`:scope .o_DiscussSidebarItem_commandLeave`).length, + 0, + "should not have 'leave' command" + ); +}); + +QUnit.test('sidebar: public/private channel rendering', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 100, + name: "channel1", + public: 'public', + }], + channel_private_group: [{ + channel_type: "channel", + id: 101, + name: "channel2", + public: 'private', + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 2, + "should have 2 channel items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have channel1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 101 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have channel2 (Id 101)" + ); + const channel1 = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + ).localId + }"] + `); + const channel2 = document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 101 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.strictEqual( + channel1.querySelectorAll(`:scope .o_ThreadIcon_channelPublic`).length, + 1, + "channel1 (public) has hashtag icon" + ); + assert.strictEqual( + channel2.querySelectorAll(`:scope .o_ThreadIcon_channelPrivate`).length, + 1, + "channel2 (private) has lock icon" + ); +}); + +QUnit.test('sidebar: basic chat rendering', async function (assert) { + assert.expect(11); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 1, + "should have one chat item" + ); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId, + "should have chat with Id 20" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_activeIndicator`).length, + 1, + "should have active indicator" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_ThreadIcon`).length, + 1, + "should have an icon" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_name`).length, + 1, + "should have a name" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Demo", + "should have correspondent name as name" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commands`).length, + 1, + "should have commands" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 2, + "should have 2 commands" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length, + 1, + "should have 'rename' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length, + 1, + "should have 'unpin' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 0, + "should have a counter when equals 0 (default value)" + ); +}); + +QUnit.test('sidebar: chat rendering with unread counter', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + message_unread_counter: 100, + }], + }, + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_counter`).length, + 1, + "should have a counter when different from 0" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_counter`).textContent, + "100", + "should have counter value" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_command`).length, + 1, + "should have single command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandRename`).length, + 1, + "should have 'rename' command" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_commandUnpin`).length, + 0, + "should not have 'unpin' command" + ); +}); + +QUnit.test('sidebar: chat im_status rendering', async function (assert) { + assert.expect(7); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 101, + im_status: 'offline', + name: "Partner1", + }], + id: 11, + }, { + channel_type: "chat", + direct_partner: [{ + id: 102, + im_status: 'online', + name: "Partner2", + }], + id: 12, + }, { + channel_type: "chat", + direct_partner: [{ + id: 103, + im_status: 'away', + name: "Partner3", + }], + id: 13, + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`).length, + 3, + "should have 3 chat items" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 11 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have Partner1 (Id 11)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have Partner2 (Id 12)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 13 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have Partner3 (Id 13)" + ); + const chat1 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 11 && + thread.model === 'mail.channel' + ).localId + }"] + `); + const chat2 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId + }"] + `); + const chat3 = document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 13 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.strictEqual( + chat1.querySelectorAll(`:scope .o_ThreadIcon_offline`).length, + 1, + "chat1 should have offline icon" + ); + assert.strictEqual( + chat2.querySelectorAll(`:scope .o_ThreadIcon_online`).length, + 1, + "chat2 should have online icon" + ); + assert.strictEqual( + chat3.querySelectorAll(`:scope .o_ThreadIcon_away`).length, + 1, + "chat3 should have away icon" + ); +}); + +QUnit.test('sidebar: chat custom name', async function (assert) { + assert.expect(1); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + custom_channel_name: "Marc", + direct_partner: [{ + id: 7, + name: "Marc Demo", + }], + id: 10, + }], + }, + }); + await this.start(); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Marc", + "chat should have custom name as name" + ); +}); + +QUnit.test('sidebar: rename chat', async function (assert) { + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + custom_channel_name: "Marc", + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Marc Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_set_custom_name') { + return; + } + return this._super(...arguments); + }, + }); + const chat = document.querySelector(`.o_DiscussSidebar_groupChat .o_DiscussSidebar_item`); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Marc", + "chat should have custom name as name" + ); + assert.notOk( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat name should not be editable" + ); + + await afterNextRender(() => + chat.querySelector(`:scope .o_DiscussSidebarItem_commandRename`).click() + ); + assert.ok( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat should have editable name" + ); + assert.strictEqual( + chat.querySelectorAll(`:scope .o_DiscussSidebarItem_nameInput`).length, + 1, + "chat should have editable name input" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value, + "Marc", + "editable name input should have custom chat name as value by default" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).placeholder, + "Marc Demo", + "editable name input should have partner name as placeholder" + ); + + await afterNextRender(() => { + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).value = "Demo"; + const kevt = new window.KeyboardEvent('keydown', { key: "Enter" }); + chat.querySelector(`:scope .o_DiscussSidebarItem_nameInput`).dispatchEvent(kevt); + }); + assert.notOk( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).classList.contains('o-editable'), + "chat should no longer show editable name" + ); + assert.strictEqual( + chat.querySelector(`:scope .o_DiscussSidebarItem_name`).textContent, + "Demo", + "chat should have renamed name as name" + ); +}); + +QUnit.test('default thread rendering', async function (assert) { + assert.expect(16); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).length, + 1, + "should have inbox mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).length, + 1, + "should have starred mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).length, + 1, + "should have history mailbox in the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have 'general' channel in the sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in inbox" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).textContent.trim(), + "Congratulations, your inbox is empty New messages appear here." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).classList.contains('o-active'), + "starred mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).textContent.trim(), + "No starred messages You can mark any message as 'starred', and it shows up in this mailbox." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "history mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_MessageList_empty`).textContent.trim(), + "No history messages Messages marked as read will appear in the history." + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-active'), + "channel 'general' should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).length, + 1, + "should have empty thread in starred" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_empty + `).textContent.trim(), + "There are no messages in this conversation." + ); +}); + +QUnit.test('initially load messages from inbox', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.step('message_fetch'); + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + assert.strictEqual( + args.args.length, + 1, + "should have a single item in args" + ); + assert.deepEqual( + args.args[0], + [["needaction", "=", true]], + "should fetch needaction messages" + ); + } + return this._super(...arguments); + }, + }); + assert.verifySteps(['message_fetch']); +}); + +QUnit.test('default select thread in discuss params', async function (assert) { + assert.expect(1); + + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_starred', + }, + } + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active" + ); +}); + +QUnit.test('auto-select thread in discuss context', async function (assert) { + assert.expect(1); + + await this.start({ + discuss: { + context: { + active_id: 'mail.box_starred', + }, + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "starred mailbox should become active" + ); +}); + +QUnit.test('load single message from channel initially', async function (assert) { + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + assert.strictEqual( + args.args.length, + 1, + "should have a single item in args" + ); + assert.deepEqual( + args.args[0], + [["channel_ids", "in", [20]]], + "should fetch messages from channel" + ); + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_ThreadViewer_messageList`).length, + 1, + "should have list of messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_separatorDate`).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_MessageList_separatorLabelDate`).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 1, + "should have a single message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should have message with Id 100" + ); +}); + +QUnit.test('basic rendering of message', async function (assert) { + // AKU TODO: should be in message-only tests + assert.expect(13); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + const message = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "should have message sidebar of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "should have author avatar in sidebar of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + "/web/image/res.partner/11/image_128", + "should have url of message in author avatar sidebar" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_core`).length, + 1, + "should have core part of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header`).length, + 1, + "should have header in core part of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "should have author name in header of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo", + "should have textually author name in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_date`).length, + 1, + "should have date in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_commands`).length, + 1, + "should have commands in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_header .o_Message_command`).length, + 1, + "should have a single command in header of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "should have command to star message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "should have content in core part of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_content`).textContent.trim(), + "body", + "should have body of message in content part of message" + ); +}); + +QUnit.test('basic rendering of squashed message', async function (assert) { + // messages are squashed when "close", e.g. less than 1 minute has elapsed + // from messages of same author and same thread. Note that this should + // be working in non-mailboxes + // AKU TODO: should be message and/or message list-only tests + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [11, "Demo"], + body: "<p>body1</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }, { + author_id: [11, "Demo"], + body: "<p>body2</p>", + channel_ids: [20], + date: "2019-04-20 10:00:30", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 2, + "should have 2 messages" + ); + const message1 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + const message2 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `); + assert.notOk( + message1.classList.contains('o-squashed'), + "message 1 should not be squashed" + ); + assert.notOk( + message1.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), + "message 1 should not have squashed sidebar" + ); + assert.ok( + message2.classList.contains('o-squashed'), + "message 2 should be squashed" + ); + assert.ok( + message2.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), + "message 2 should have squashed sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_date`).length, + 1, + "message 2 should have date in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commands`).length, + 1, + "message 2 should have some commands in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commandStar`).length, + 1, + "message 2 should have star command in sidebar" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_core`).length, + 1, + "message 2 should have core part" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_header`).length, + 0, + "message 2 should have a header in core part" + ); + assert.strictEqual( + message2.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message 2 should have some content in core part" + ); + assert.strictEqual( + message2.querySelector(`:scope .o_Message_content`).textContent.trim(), + "body2", + "message 2 should have body in content part" + ); +}); + +QUnit.test('inbox messages are never squashed', async function (assert) { + assert.expect(3); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + // fetching messages from inbox + return [{ + author_id: [11, "Demo"], + body: "<p>body1</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: 'General', + res_id: 20, + }, { + author_id: [11, "Demo"], + body: "<p>body2</p>", + channel_ids: [20], + date: "2019-04-20 10:00:30", + id: 101, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: 'General', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 2, + "should have 2 messages" + ); + const message1 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + const message2 = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + .o_MessageList_message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `); + assert.notOk( + message1.classList.contains('o-squashed'), + "message 1 should not be squashed" + ); + assert.notOk( + message2.classList.contains('o-squashed'), + "message 2 should not be squashed" + ); +}); + +QUnit.test('load all messages from channel initially, less than fetch limit (29 < 30)', async function (assert) { + // AKU TODO: thread specific test + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + assert.strictEqual(args.kwargs.limit, 30, "should fetch up to 30 messages"); + let messagesData = []; + // 29 messages + for (let i = 28; i >= 0; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate + `).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate + `).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 29, + "should have 29 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 0, + "should not have load more link" + ); +}); + +QUnit.test('load more messages from channel', async function (assert) { + // AKU: thread specific test + assert.expect(8); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel (initial load) + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + let messagesData = []; + // 30 messages + for (let i = 39; i >= 10; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + if (step === 2) { + // fetching more messages from channel (load more) + assert.strictEqual( + args.kwargs.limit, + 30, + "should fetch up to 30 messages" + ); + let messagesData = []; + // 10 messages + for (let i = 9; i >= 0; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorDate + `).length, + 1, + "should have a single date separator" // to check: may be client timezone dependent + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorLabelDate + `).textContent, + "April 20, 2019", + "should display date day of messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 30, + "should have 30 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 1, + "should have load more link" + ); + + await afterNextRender(() => + document.querySelector(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 40, + "should have 40 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 0, + "should not longer have load more link (all messages loaded)" + ); +}); + +QUnit.test('auto-scroll to bottom of thread', async function (assert) { + // AKU TODO: thread specific test + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`); + assert.strictEqual( + messageList.scrollTop + messageList.clientHeight, + messageList.scrollHeight, + "should have scrolled to bottom of thread" + ); +}); + +QUnit.test('load more messages from channel (auto-load on scroll)', async function (assert) { + // AKU TODO: thread specific test + assert.expect(3); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel (initial load) + let messagesData = []; + // 30 messages + for (let i = 39; i >= 10; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + if (step === 2) { + // fetching more messages from channel (load more) + let messagesData = []; + // 10 messages + for (let i = 9; i >= 0; i--) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 30, + "should have 30 messages" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 40, + "should have 40 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Dsiscuss_thread .o_ThreadViewer_messageList .o_MessageList_loadMore + `).length, + 0, + "should not longer have load more link (all messages loaded)" + ); +}); + +QUnit.test('new messages separator', async function (assert) { + // this test requires several messages so that the last message is not + // visible. This is necessary in order to display 'new messages' and not + // remove from DOM right away from seeing last message. + // AKU TODO: thread specific test + assert.expect(5); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + seen_message_id: 125, + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel (initial load) + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + } + return messagesData; + } + if (step === 2) { + throw new Error("should not fetch more messages"); + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages + `).length, + 0, + "should not display 'new messages' separator" + ); + + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + // simulate receiving a new message + const data = { + author_id: [36, "User26"], + body: "<p>boddy26</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }; + await afterNextRender(() => { + const notifications = [[['my-db', 'mail.channel', 20], data]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 26, + "should have 26 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages + `).length, + 1, + "should display 'new messages' separator" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_separatorNewMessages + `).length, + 0, + "should no longer display 'new messages' separator (message seen)" + ); +}); + +QUnit.test('restore thread scroll position', async function (assert) { + assert.expect(4); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "channel1", + }, { + channel_type: "channel", + id: 2, + name: "channel2", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel1 (initial load) + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [1], + date: "2019-04-20 10:00:00", + id: 100 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'channel1', + res_id: 1, + }); + } + return messagesData; + } + if (step === 2) { + // fetching messages from channel2 (initial load) + let messagesData = []; + // 25 messages + for (let i = 1; i <= 25; i++) { + messagesData.push({ + author_id: [10 + i, `User${i}`], + body: `<p>body${i}</p>`, + channel_ids: [2], + date: "2019-04-20 10:00:00", + id: 200 + i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'channel2', + res_id: 2, + }); + } + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread .o_ThreadViewer_messageList .o_MessageList_message + `).length, + 25, + "should have 25 messages" + ); + + // scroll to top of channel1 + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop, + 0, + "should have scrolled to top of thread" + ); + + // select channel2 + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + // select channel1 + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop, + 0, + "should have recovered scroll position of channel1 (scroll to top)" + ); + + // select channel2 + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + const messageList = document.querySelector(` + .o_Discuss_thread + .o_ThreadViewer_messageList + `); + assert.strictEqual( + messageList.scrollTop + messageList.clientHeight, + messageList.scrollHeight, + "should have recovered scroll position of channel2 (scroll to bottom)" + ); +}); + +QUnit.test('message origin redirect to channel', async function (assert) { + assert.expect(15); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 1, + name: "channel1", + }, { + channel_type: 'channel', + id: 2, + name: "channel2", + }], + }, + }); + let messagesData = [{ + author_id: [10, "User1"], + body: `<p>message1</p>`, + channel_ids: [1, 2], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "channel1", + res_id: 1, + }, { + author_id: [11, "User2"], + body: `<p>message2</p>`, + channel_ids: [1, 2], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: "channel2", + res_id: 2, + }]; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from channel1 (initial load) + return messagesData; + } + if (step === 2) { + // fetching messages from channel2 (initial load) + return messagesData; + } + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Discuss_thread .o_Message').length, + 2, + "should have 2 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should have message1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `).length, + 1, + "should have message2 (Id 101)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThread + `).length, + 0, + "message1 should not have origin part in channel1 (same origin as channel)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThread + `).length, + 1, + "message2 should have origin part (origin is channel2 !== channel1)" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThread + `).textContent.trim(), + "(from #channel2)", + "message2 should display name of origin channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThreadLink + `).length, + 1, + "message2 should have link to redirect to origin" + ); + + // click on origin link of message2 (= channel2) + await afterNextRender(() => + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThreadLink + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 2 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel2 should be active channel on redirect from discuss app" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should have message1 (Id 100)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `).length, + 1, + "should have message2 (Id 101)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThread + `).length, + 1, + "message1 should have origin thread part (= channel1 !== channel2)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + .o_Message_originThread + `).length, + 0, + "message2 should not have origin thread part in channel2 (same as current channel)" + ); + assert.strictEqual( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThread + `).textContent.trim(), + "(from #channel1)", + "message1 should display name of origin channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_originThreadLink + `).length, + 1, + "message1 should have link to redirect to origin channel" + ); +}); + +QUnit.test('redirect to author (open chat)', async function (assert) { + assert.expect(9); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_1', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + // fetching messages from General (initial load) + return [{ + author_id: [7, "Demo"], + body: `<p>message1</p>`, + channel_ids: [1], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 1, + }, { + author_id: [3, "Me"], + body: `<p>message2</p>`, + channel_ids: [1], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 1, + }]; + } + if (step === 2) { + // fetching messages from DM (initial load) + return []; + } + } + if (args.model === 'res.users' && args.method === 'search') { + return [2]; + } + return this._super(...arguments); + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel 'General' should be active" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "Chat 'Demo' should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages" + ); + const msg1 = document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + const msg2 = document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `); + assert.strictEqual( + msg1.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "message1 should have author image" + ); + assert.ok( + msg1.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), + "message1 should have redirect to author" + ); + assert.strictEqual( + msg2.querySelectorAll(`:scope .o_Message_authorAvatar`).length, + 1, + "message2 should have author image" + ); + assert.notOk( + msg2.querySelector(`:scope .o_Message_authorAvatar`).classList.contains('o_redirect'), + "message2 should not have redirect to author (self-author)" + ); + + await afterNextRender(() => + msg1.querySelector(`:scope .o_Message_authorAvatar`).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_groupChannel + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 1 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "channel 'General' should become inactive after author redirection" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_groupChat + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_activeIndicator + `).classList.contains('o-item-active'), + "chat 'Demo' should become active after author redirection" + ); +}); + +QUnit.test('sidebar quick search', async function (assert) { + // feature enables at 20 or more channels + assert.expect(6); + + let channelsData = []; + for (let id = 1; id <= 20; id++) { + channelsData.push({ + channel_type: 'channel', + id, + name: `channel${id}`, + }); + } + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: channelsData, + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 20, + "should have 20 channel items" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_sidebar input.o_DiscussSidebar_quickSearch`).length, + 1, + "should have quick search in sidebar" + ); + + const quickSearch = document.querySelector(` + .o_Discuss_sidebar input.o_DiscussSidebar_quickSearch + `); + await afterNextRender(() => { + quickSearch.value = "1"; + const kevt1 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt1); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 11, + "should have filtered to 11 channel items" + ); + + await afterNextRender(() => { + quickSearch.value = "12"; + const kevt2 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt2); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 1, + "should have filtered to a single channel item" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item + `).dataset.threadLocalId, + this.env.models['mail.thread'].find(thread => + thread.id === 12 && + thread.model === 'mail.channel' + ).localId, + "should have filtered to a single channel item with Id 12" + ); + + await afterNextRender(() => { + quickSearch.value = "123"; + const kevt3 = new window.KeyboardEvent('input'); + quickSearch.dispatchEvent(kevt3); + }); + assert.strictEqual( + document.querySelectorAll(`.o_DiscussSidebar_groupChannel .o_DiscussSidebar_item`).length, + 0, + "should have filtered to no channel item" + ); +}); + +QUnit.test('basic control panel rendering', async function (assert) { + assert.expect(8); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "Inbox", + "display inbox in the breadcrumb" + ); + const markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.isVisible( + markAllReadButton, + "should have visible button 'Mark all read' in the control panel of inbox" + ); + assert.ok( + markAllReadButton.disabled, + "should have disabled button 'Mark all read' in the control panel of inbox (no messages)" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "Starred", + "display starred in the breadcrumb" + ); + const unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.isVisible( + unstarAllButton, + "should have visible button 'Unstar all' in the control panel of starred" + ); + assert.ok( + unstarAllButton.disabled, + "should have disabled button 'Unstar all' in the control panel of starred (no messages)" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.strictEqual( + document.querySelector(` + .o_widget_Discuss .o_control_panel .breadcrumb + `).textContent, + "#General", + "display general in the breadcrumb" + ); + const inviteButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonInvite`); + assert.isVisible( + inviteButton, + "should have visible button 'Invite' in the control panel of channel" + ); +}); + +QUnit.test('inbox: mark all messages as read', async function (assert) { + assert.expect(8); + + const self = this; + Object.assign(this.data.initMessaging, { + needaction_inbox_counter: 2, + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_needaction_counter: 2, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [7, "Demo"], + body: `<p>message1</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: "General", + res_id: 20, + }, { + author_id: [8, "Other"], + body: `<p>message2</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [3], + record_name: "General", + res_id: 20, + }]; + } + if (args.method === 'mark_all_as_read') { + // simulate mark as read notification + const data = { + message_ids: [100, 101], + type: 'mark_as_read', + }; + const notifications = [[['my-db', 'res.partner'], data]]; + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "inbox should have counter of 2" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "channel should have counter of 2" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 2, + "should have 2 messages in inbox" + ); + let markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.notOk( + markAllReadButton.disabled, + "should have enabled button 'Mark all read' in the control panel of inbox (has messages)" + ); + + await afterNextRender(() => markAllReadButton.click()); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "inbox should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "channel should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 0, + "should have no message in inbox" + ); + markAllReadButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonMarkAllRead`); + assert.ok( + markAllReadButton.disabled, + "should have disabled button 'Mark all read' in the control panel of inbox (no messages)" + ); +}); + +QUnit.test('starred: unstar all', async function (assert) { + assert.expect(6); + + const self = this; + Object.assign(this.data.initMessaging, { starred_counter: 2 }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_starred', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [7, "Demo"], + body: `<p>message1</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 20, + starred: true, + starred_partner_ids: [3], + }, { + author_id: [8, "Other"], + body: `<p>message2</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 101, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 20, + starred: true, + starred_partner_ids: [3], + }]; + } + if (args.method === 'unstar_all') { + // simulate toggle_star notification + const data = { + message_ids: [100, 101], + starred: false, + type: 'toggle_star', + }; + const notifications = [[['my-db', 'res.partner'], data]]; + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "2", + "starred should have counter of 2" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 2, + "should have 2 messages in starred" + ); + let unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.notOk( + unstarAllButton.disabled, + "should have enabled button 'Unstar all' in the control panel of starred (has messages)" + ); + + await afterNextRender(() => unstarAllButton.click()); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 0, + "should have no message in starred" + ); + unstarAllButton = document.querySelector(`.o_widget_Discuss_controlPanelButtonUnstarAll`); + assert.ok( + unstarAllButton.disabled, + "should have disabled button 'Unstar all' in the control panel of starred (no messages)" + ); +}); + +QUnit.test('toggle_star message', async function (assert) { + assert.expect(16); + + const self = this; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + let messageData = { + author_id: [11, "Demo"], + body: "<p>body</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + starred: false, + starred_partner_ids: [], + }; + + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [messageData]; + } + if (args.method === 'toggle_message_starred') { + assert.step('rpc:toggle_message_starred'); + assert.strictEqual( + args.args[0][0], + 100, + "should have message Id in args" + ); + // simulate toggle_star notification + messageData.starred = !messageData.starred; + const data = { + message_ids: [100], + starred: messageData.starred, + type: 'toggle_star', + }; + const notifications = [[['my-db', 'res.partner'], data]]; + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should display no counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should have 1 message in channel" + ); + let message = document.querySelector(`.o_Discuss .o_Message`); + assert.notOk( + message.classList.contains('o-starred'), + "message should not be starred" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "message should have star command" + ); + + await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + assert.verifySteps(['rpc:toggle_message_starred']); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + "1", + "starred should display a counter of 1" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should have kept 1 message in channel" + ); + message = document.querySelector(`.o_Discuss .o_Message`); + assert.ok( + message.classList.contains('o-starred'), + "message should be starred" + ); + + await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + assert.verifySteps(['rpc:toggle_message_starred']); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.starred.localId + }"] + .o_DiscussSidebarItem_counter + `).length, + 0, + "starred should no longer display a counter (= 0)" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss .o_Message`).length, + 1, + "should still have 1 message in channel" + ); + message = document.querySelector(`.o_Discuss .o_Message`); + assert.notOk( + message.classList.contains('o-starred'), + "message should no longer be starred" + ); +}); + +QUnit.test('composer state: text save and restore', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }, { + channel_type: 'channel', + id: 21, + name: "Special", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + return this._super(...arguments); + }, + }); + // Write text in composer for #general + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "A message"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('input')); + }); + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click() + ); + await afterNextRender(() => { + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "An other message"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('input')); + }); + // Switch back to #general + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="General"]`).click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "A message", + "should restore the input text" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebarItem[data-thread-name="Special"]`).click() + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "An other message", + "should restore the input text" + ); +}); + +QUnit.test('composer state: attachments save and restore', async function (assert) { + assert.expect(6); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }, { + channel_type: 'channel', + id: 21, + name: "Special", + }], + }, + }); + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + return this._super(...arguments); + }, + }); + const channels = document.querySelectorAll(` + .o_DiscussSidebar_groupChannel .o_DiscussSidebar_item + `); + // Add attachment in a message for #general + await afterNextRender(async () => { + const file = await createFile({ + content: 'hello, world', + contentType: 'text/plain', + name: 'text.txt', + }); + inputFiles( + document.querySelector('.o_FileUploader_input'), + [file] + ); + }); + // Switch to #special + await afterNextRender(() => channels[1].click()); + // Add attachments in a message for #special + const files = [ + await createFile({ + content: 'hello2, world', + contentType: 'text/plain', + name: 'text2.txt', + }), + await createFile({ + content: 'hello3, world', + contentType: 'text/plain', + name: 'text3.txt', + }), + await createFile({ + content: 'hello4, world', + contentType: 'text/plain', + name: 'text4.txt', + }), + ]; + await afterNextRender(() => + inputFiles( + document.querySelector('.o_FileUploader_input'), + files + ) + ); + // Switch back to #general + await afterNextRender(() => channels[0].click()); + // Check attachment is reloaded + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 1, + "should have 1 attachment in the composer" + ); + assert.strictEqual( + document.querySelector(`.o_Composer .o_Attachment`).dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 1).localId, + "should have correct 1st attachment in the composer" + ); + + // Switch back to #special + await afterNextRender(() => channels[1].click()); + // Check attachments are reloaded + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`).length, + 3, + "should have 3 attachments in the composer" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[0].dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 2).localId, + "should have attachment with id 2 as 1st attachment" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[1].dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 3).localId, + "should have attachment with id 3 as 2nd attachment" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer .o_Attachment`)[2].dataset.attachmentLocalId, + this.env.models['mail.attachment'].find(attachment => attachment.id === 4).localId, + "should have attachment with id 4 as 3rd attachment" + ); +}); + +QUnit.test('post a simple message', async function (assert) { + assert.expect(15); + + const self = this; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + let messagesData = []; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.channel_20', + }, + }, + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return messagesData; + } + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.args[0], + 20, + "should post message to channel Id 20" + ); + assert.strictEqual( + args.kwargs.body, + "Test", + "should post with provided content in composer input" + ); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_comment", + "should set subtype_xmlid as 'comment'" + ); + // simulate receiving a new message + const data = { + author_id: [3, "Admin"], + body: args.kwargs.body, + channel_ids: [20], + date: "2019-04-20 11:00:00", + id: 101, + message_type: args.kwargs.message_type, + model: 'mail.channel', + subtype_xmlid: args.kwargs.subtype_xmlid, + record_name: 'General', + res_id: 20, + }; + const notifications = [ + [['my-db', 'mail.channel', 20], data] + ]; + messagesData.push(data); + self.widget.call('bus_service', 'trigger', 'notification', notifications); + return; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(`.o_MessageList_empty`).length, + 1, + "should display thread with no message initially" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 0, + "should display no message initially" + ); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have empty content initially" + ); + + // insert some HTML in editable + document.querySelector(`.o_ComposerTextInput_textarea`).focus(); + document.execCommand('insertText', false, "Test"); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "Test", + "should have inserted text in editable" + ); + + await afterNextRender(() => + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })) + ); + assert.verifySteps(['message_post']); + assert.strictEqual( + document.querySelector(`.o_ComposerTextInput_textarea`).value, + "", + "should have no content in composer input after posting message" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 1, + "should display a message after posting message" + ); + const message = document.querySelector(`.o_Message`); + assert.strictEqual( + message.dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 101).localId, + "new message in thread should be linked to newly created message from message post" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_authorName`).textContent, + "Admin", + "new message in thread should be from Admin" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_content`).textContent, + "Test", + "new message in thread should have content typed from composer text input" + ); +}); + +QUnit.test('rendering of inbox message', async function (assert) { + // AKU TODO: kinda message specific test + assert.expect(7); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'project.task', + needaction_partner_ids: [44], + record_name: 'Refactoring', + res_id: 20, + }]; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message" + ); + const message = document.querySelector('.o_Message'); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_originThread`).length, + 1, + "should display origin thread of message" + ); + assert.strictEqual( + message.querySelector(`:scope .o_Message_originThread`).textContent, + " on Refactoring", + "should display origin thread name" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_command`).length, + 3, + "should display 3 commands" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandStar`).length, + 1, + "should display star command" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandReply`).length, + 1, + "should display reply command" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_Message_commandMarkAsRead`).length, + 1, + "should display mark as read command" + ); +}); + +QUnit.test('mark channel as seen on last message visible', async function (assert) { + assert.expect(3); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 10, + message_unread_counter: 1, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + return []; + } + if (step === 2) { + return [{ + author_id: [11, "Demo"], + body: "<p>body</p>", + date: "2019-04-20 10:00:00", + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 10, + }]; + } + throw new Error("should not call 'message_fetch' more than twice"); + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have discuss sidebar item with the channel" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-unread'), + "sidebar item of channel ID 10 should be unread" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).classList.contains('o-unread'), + "sidebar item of channel ID 10 should not longer be unread" + ); +}); + +QUnit.test('receive new needaction messages', async function (assert) { + assert.expect(12); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return []; + } + return this._super(...arguments); + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `), + "should have inbox in sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "inbox should be current discuss thread" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `), + "inbox item in sidebar should not have any counter" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 0, + "should have no messages in inbox initially" + ); + + // simulate receiving a new needaction message + await afterNextRender(() => { + const data = { + author_id: [7, "Demo"], + body: "<p>Test</p>", + date: "2019-04-20 11:00:00", + id: 100, + message_type: 'comment', + needaction_partner_ids: [3], + model: 'project.task', + record_name: 'Refactoring', + res_id: 20, + }; + const notifications = [[['my-db', 'ir.needaction', 3], data]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `), + "inbox item in sidebar should now have counter" + ); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + '1', + "inbox item in sidebar should have counter of '1'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 1, + "should have one message in inbox" + ); + assert.strictEqual( + document.querySelector(`.o_Discuss_thread .o_Message`).dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should display newly received needaction message" + ); + + // simulate receiving another new needaction message + await afterNextRender(() => { + const data2 = { + author_id: [7, "Demo"], + body: "<p>Test2</p>", + date: "2019-04-20 11:00:00", + id: 101, + message_type: 'comment', + needaction_partner_ids: [3], + model: 'project.task', + record_name: 'Refactoring', + res_id: 20, + }; + const notifications2 = [[['my-db', 'ir.needaction', 3], data2]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications2); + }); + assert.strictEqual( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + .o_DiscussSidebarItem_counter + `).textContent, + '2', + "inbox item in sidebar should have counter of '2'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_Message`).length, + 2, + "should have 2 messages in inbox" + ); + assert.ok( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `), + "should still display 1st needaction message" + ); + assert.ok( + document.querySelector(` + .o_Discuss_thread + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 101).localId + }"] + `), + "should display 2nd needaction message" + ); +}); + +QUnit.test('reply to message from inbox (message linked to document)', async function (assert) { + assert.expect(20); + + Object.assign(this.data.initMessaging, { needaction_inbox_counter: 1 }); + + let messagesData = []; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + return [{ + author_id: [7, "Demo"], + body: "<p>Test</p>", + date: "2019-04-20 11:00:00", + id: 100, + message_type: 'comment', + needaction_partner_ids: [3], + model: 'project.task', + record_name: 'Refactoring', + res_id: 20, + }]; + } + if (args.method === 'message_post') { + assert.step('message_post'); + assert.strictEqual( + args.model, + 'project.task', + "should post message to record with model 'project.task'" + ); + assert.strictEqual( + args.args[0], + 20, + "should post message to record with Id 20" + ); + assert.strictEqual( + args.kwargs.body, + "Test", + "should post with provided content in composer input" + ); + assert.strictEqual( + args.kwargs.message_type, + "comment", + "should set message type as 'comment'" + ); + assert.strictEqual( + args.kwargs.subtype_xmlid, + "mail.mt_comment", + "should set subtype_xmlid as 'comment'" + ); + messagesData.push({ + author_id: [3, "Admin"], + body: args.kwargs.body, + date: "2019-04-20 11:00:00", + id: 101, + message_type: args.kwargs.message_type, + model: args.model, + subtype_xmlid: args.kwargs.subtype_xmlid, + record_name: 'Refactoring', + res_id: 20, + }); + return; + } + if (args.method === 'message_format') { + return messagesData; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a single message" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should display message with ID 100" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThread').textContent, + " on Refactoring", + "should display message originates from record 'Refactoring'" + ); + + await afterNextRender(() => + document.querySelector('.o_Message_commandReply').click() + ); + assert.ok( + document.querySelector('.o_Message').classList.contains('o-selected'), + "message should be selected after clicking on reply icon" + ); + assert.ok( + document.querySelector('.o_Composer'), + "should have composer after clicking on reply to message" + ); + assert.strictEqual( + document.querySelector(`.o_Composer_threadName`).textContent, + " on: Refactoring", + "composer should display origin thread name of message" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer text input should be auto-focus" + ); + + await afterNextRender(() => { + document.execCommand('insertText', false, "Test"); + document.querySelector(`.o_ComposerTextInput_textarea`) + .dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter' })); + }); + assert.verifySteps(['message_post']); + assert.notOk( + document.querySelector('.o_Composer'), + "should no longer have composer after posting reply to message" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should still display a single message after posting reply" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should still display message with ID 100 after posting reply" + ); + assert.notOk( + document.querySelector('.o_Message').classList.contains('o-selected'), + "message should not longer be selected after posting reply" + ); + assert.ok( + document.querySelector('.o_notification'), + "should display a notification after posting reply" + ); + assert.strictEqual( + document.querySelector('.o_notification_content').textContent, + "Message posted on \"Refactoring\"", + "notification should tell that message has been posted to the record 'Refactoring'" + ); +}); + +QUnit.skip('load recent messages from thread (already loaded some old messages)', async function (assert) { + // TODO FIXME should be restored as part of task-2171881 (robust "load more" with "holes") + // Load old message that has been needaction, when opening this thread for + // the first time, it should load messages that are more recent. + assert.expect(17); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + name: "General", + }], + }, + needaction_inbox_counter: 1, + }); + let step = 0; + let mailMessages = {}; + for (let i = 0; i < 50; i++) { + mailMessages[100 + i] = { + author_id: [7, "Demo"], + body: `<p>Test${i}</p>`, + date: `2019-04-20 11:00:${i < 10 ? '0' + i : i}`, + id: 100 + i, + message_type: 'comment', + needaction_partner_ids: i === 0 ? [3] : [], + model: 'mail.channel', + record_name: "General", + res_id: 20, + }; + } + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + step++; + if (step === 1) { + assert.step('message_fetch:load_inbox'); + assert.deepEqual( + args.args[0], + [['needaction', '=', true]], + "should fetch needaction messages from inbox" + ); + const needactionMessages = Object + .values(mailMessages) + .filter(mailMessage => + mailMessage.needaction_partner_ids.length > 0 + ); + return needactionMessages; + } + if (step === 2) { + assert.step('message_fetch:load_channel_20'); + assert.deepEqual( + args.args[0], + [['channel_ids', 'in', [20]]], + "should fetch messages from channel ID 20" + ); + assert.strictEqual( + args.kwargs.limit, + 30, + "should limit fetch to 30 messages (load)" + ); + const moreRecentMessages = Object + .values(mailMessages) + .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1) + .splice(0, 30); + return moreRecentMessages; + } + if (step === 3) { + assert.step('message_fetch:load_more_channel_20'); + assert.deepEqual( + args.args[0], + // loaded 30 messages, so 150 - 30 = 120 = ID of last message fetched + // => load more messages starting from this ID + [['id', '<', 120], ['channel_ids', 'in', [20]]], + "should fetch more messages from 30 more recent messages" + ); + assert.strictEqual( + args.kwargs.limit, + 30, + "should limit fetch to 30 messages (load more)" + ); + const loadMoreMessages = Object + .values(mailMessages) + .filter(mailMessage => mailMessage.id < 120) + .sort((mailMsg1, mailMsg2) => mailMsg1.id > mailMsg2.id ? -1 : 1) + .splice(0, 30); + return loadMoreMessages; + } + throw new Error("should not fetch more than twice"); + } + return this._super(...arguments); + }, + }); + assert.verifySteps( + ['message_fetch:load_inbox'], + "should initially have fetched messages from inbox" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should fetch a single message from inbox" + ); + assert.strictEqual( + document.querySelector('.o_Message').dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "should have fetched 1st message of channel 'General' as needaction from inbox" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + assert.verifySteps( + ['message_fetch:load_channel_20'], + "should initially have fetched messages from channel 'General' (channel 20)" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 31, + "should display 30 messages from channel 'General' (only fetched ones)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should not display 1st message of 'General' as needaction from inbox" + ); + + await afterNextRender(() => { + document.querySelector(`.o_Discuss_thread .o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.verifySteps( + ['message_fetch:load_more_channel_20'], + "should have fetched more messages from channel 'General' (channel 20)" + ); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 50, + "should display 50 messages from channel 'General' (all fetched)" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `).length, + 1, + "should include 1st message of 'General' as needaction from inbox in the fetched more messages" + ); +}); + +QUnit.test('messages marked as read move to "History" mailbox', async function (assert) { + assert.expect(10); + + const self = this; + this.data['mail.message'].records = [{ + author_id: [5, 'Demo User'], + body: '<p>test 1</p>', + id: 1, + needaction: true, + needaction_partner_ids: [3], + }, { + author_id: [6, 'Test User'], + body: '<p>test 2</p>', + id: 2, + needaction: true, + needaction_partner_ids: [3], + }]; + this.data['mail.notification'].records = [{ + id: 50, + is_read: false, + mail_message_id: 1, + res_partner_id: 3, + }, { + id: 51, + is_read: false, + mail_message_id: 1, + res_partner_id: 3, + }]; + this.data.initMessaging.needaction_inbox_counter = 2; + await this.start({ + discuss: { + params: { + default_active_id: 'mail.box_history', + }, + }, + async mockRPC(route, args) { + if (args.method === 'mark_all_as_read') { + for (const message of this.data['mail.message'].records) { + message.history_partner_ids = [3]; + message.needaction_partner_ids = []; + } + const notificationData = { + type: 'mark_as_read', + message_ids: [1, 2], + }; + const notification = [[false, 'res.partner', 3], notificationData]; + self.widget.call('bus_service', 'trigger', 'notification', [notification]); + return 3; + } + return this._super(...arguments); + }, + }); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "History mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 1, + "should have empty thread in history" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "Inbox mailbox should be active thread" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 0, + "Inbox mailbox should not be empty" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 2, + "Inbox mailbox should have 2 messages" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead').click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.inbox.localId + }"] + `).classList.contains('o-active'), + "Inbox mailbox should still be active after mark as read" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 1, + "Inbox mailbox should now be empty after mark as read" + ); + + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).classList.contains('o-active'), + "History mailbox should be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_empty`).length, + 0, + "History mailbox should not be empty after mark as read" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Discuss_thread .o_MessageList_message`).length, + 2, + "History mailbox should have 2 messages" + ); +}); + +QUnit.test('all messages in "Inbox" in "History" after marked all as read', async function (assert) { + assert.expect(10); + + const self = this; + const messagesData = []; + const messageOffset = 200; + const partnerOffset = 100; + for (let i = messageOffset; i < messageOffset + 40; i++) { + messagesData.push({ + author_id: [partnerOffset + i, 'User ' + (partnerOffset + i)], + body: '<p>test ' + i + '</p>', + id: i, + needaction: true, + needaction_partner_ids: [3], + }); + } + + this.data['mail.message'].records = messagesData; + + let messageFetchCount = 0; + const initDef = makeTestPromise(); + const markAllReadDef = makeTestPromise(); + const clickHistoryDef = makeTestPromise(); + const loadMoreDef = makeTestPromise(); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'mark_all_as_read') { + const messageIDs = []; + for (let i = 0; i < messagesData.length; i++) { + this.data['mail.message'].records[i].history_partner_ids = [3]; + this.data['mail.message'].records[i].needaction_partner_ids = []; + this.data['mail.message'].records[i].needaction = false; + messageIDs.push(messageOffset + i); + } + const notificationData = { + type: 'mark_as_read', + message_ids: messageIDs, + }; + const notification = [[false, 'res.partner', 3], notificationData]; + self.widget.call('bus_service', 'trigger', 'notification', [notification]); + markAllReadDef.resolve(); + return 3; + } + if (args.method === 'message_fetch') { + // 1st message_fetch: 'Inbox' initially + // 2nd message_fetch: 'History' initially + // 3rd message_fetch: 'History' load more + assert.step(args.method); + + messageFetchCount++; + if (messageFetchCount === 1) { + initDef.resolve(); + } + if (messageFetchCount === 2) { + clickHistoryDef.resolve(); + } + if (messageFetchCount === 3) { + loadMoreDef.resolve(); + } + } + return this._super(...arguments); + }, + }); + + await initDef; + assert.verifySteps( + ['message_fetch'], + "should fetch messages once for needaction messages (Inbox)" + ); + assert.containsN( + document.body, + '.o_Message', + 30, + "there should be 30 messages that are loaded in Inbox" + ); + + await afterNextRender(async () => { + const markAllReadButton = document.querySelector('.o_widget_Discuss_controlPanelButtonMarkAllRead'); + markAllReadButton.click(); + await markAllReadDef; + }); + assert.containsNone( + document.body, + '.o_Message', + "there should no message in inbox anymore" + ); + + await afterNextRender(async () => { + document.querySelector(` + .o_DiscussSidebarItem[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click(); + await clickHistoryDef; + }); + assert.verifySteps( + ['message_fetch'], + "should fetch messages once for history" + ); + assert.containsN( + document.body, + '.o_Message', + 30, + "there should be 30 messages in History" + ); + + // simulate a scroll to top to load more messages + await afterNextRender(async () => { + document.querySelector('.o_MessageList').scrollTop = 0; + await loadMoreDef; + }); + assert.verifySteps( + ['message_fetch'], + "should fetch more messages in history for loadMore" + ); + assert.containsN( + document.body, + '.o_Message', + 40, + "there should be 40 messages in History" + ); +}); + +QUnit.test('receive new channel message: out of odoo focus (notification, channel)', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + }], + }, + }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + assert.strictEqual(payload.title, "1 Message"); + }); + + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message with odoo focused + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>Test</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }; + const notifications = [[['my-db', 'mail.channel', 20], messageData]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('receive new channel message: out of odoo focus (notification, chat)', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo User", + }], + id: 10, + message_unread_counter: 0, + }], + }, + }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + assert.strictEqual(payload.title, "1 Message"); + }); + + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message with odoo focused + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>Test</p>", + channel_ids: [10], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 10, + }; + const notifications = [[['my-db', 'mail.channel', 10], messageData]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('receive new channel messages: out of odoo focus (tab title)', async function (assert) { + assert.expect(12); + + let step = 0; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo User", + }], + id: 10, + message_unread_counter: 0, + }], + }, + }); + const bus = new Bus(); + bus.on('set_title_part', null, payload => { + step++; + assert.step('set_title_part'); + assert.strictEqual(payload.part, '_chat'); + if (step === 1) { + assert.strictEqual(payload.title, "1 Message"); + } + if (step === 2) { + assert.strictEqual(payload.title, "2 Messages"); + } + if (step === 3) { + assert.strictEqual(payload.title, "3 Messages"); + } + }); + + await this.start({ + env: { bus }, + services: { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused: () => false, + updateOption() {}, + }), + }, + }); + + // simulate receiving a new message in general with odoo focused + await afterNextRender(() => { + const messageData1 = { + author_id: [7, "Demo User"], + body: "<p>Test1</p>", + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: 126, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }; + const notifications1 = [[['my-db', 'mail.channel', 20], messageData1]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications1); + }); + assert.verifySteps(['set_title_part']); + + // simulate receiving a new message in chat with odoo focused + await afterNextRender(() => { + const messageData2 = { + author_id: [7, "Demo User"], + body: "<p>Test2</p>", + channel_ids: [10], + date: "2019-04-20 10:00:00", + id: 127, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 10, + }; + const notifications2 = [[['my-db', 'mail.channel', 10], messageData2]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications2); + }); + assert.verifySteps(['set_title_part']); + + // simulate receiving another new message in chat with odoo focused + await afterNextRender(() => { + const messageData3 = { + author_id: [7, "Demo User"], + body: "<p>Test3</p>", + channel_ids: [10], + date: "2019-04-20 10:00:00", + id: 128, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 10, + }; + const notifications3 = [[['my-db', 'mail.channel', 20], messageData3]]; + this.widget.call('bus_service', 'trigger', 'notification', notifications3); + }); + assert.verifySteps(['set_title_part']); +}); + +QUnit.test('auto-focus composer on opening thread', async function (assert) { + assert.expect(14); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + message_unread_counter: 0, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo User", + }], + id: 10, + message_unread_counter: 0, + }], + }, + }); + await this.start(); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="Inbox"] + `).length, + 1, + "should have mailbox 'Inbox' in the sidebar" + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Inbox"] + `).classList.contains('o-active'), + "mailbox 'Inbox' should be active initially" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).length, + 1, + "should have channel 'General' in the sidebar" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).classList.contains('o-active'), + "channel 'General' should not be active initially" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).length, + 1, + "should have chat 'Demo User' in the sidebar" + ); + assert.notOk( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).classList.contains('o-active'), + "chat 'Demo User' should not be active initially" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 0, + "there should be no composer when active thread of discuss is mailbox 'Inbox'" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_item[data-thread-name="General"]`).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="General"] + `).classList.contains('o-active'), + "channel 'General' should become active after selecting it from the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 1, + "there should be a composer when active thread of discuss is channel 'General'" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of channel 'General' should be automatically focused on opening" + ); + + document.querySelector(`.o_ComposerTextInput_textarea`).blur(); + assert.notOk( + document.activeElement === document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of channel 'General' should no longer focused on click away" + ); + + await afterNextRender(() => + document.querySelector(`.o_DiscussSidebar_item[data-thread-name="Demo User"]`).click() + ); + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-name="Demo User"] + `).classList.contains('o-active'), + "chat 'Demo User' should become active after selecting it from the sidebar" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Composer`).length, + 1, + "there should be a composer when active thread of discuss is chat 'Demo User'" + ); + assert.strictEqual( + document.activeElement, + document.querySelector(`.o_ComposerTextInput_textarea`), + "composer of chat 'Demo User' should be automatically focused on opening" + ); +}); + +QUnit.test('moderation: moderated channel with pending moderation message', async function (assert) { + assert.expect(37); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start(); + + assert.ok( + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `), + "should display the moderation box" + ); + const mailboxCounter = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + .o_DiscussSidebarItem_counter + `); + assert.ok( + mailboxCounter, + "there should be a counter next to the moderation mailbox in the sidebar" + ); + assert.strictEqual( + mailboxCounter.textContent.trim(), + "1", + "the mailbox counter of the moderation mailbox should display '1'" + ); + + // 1. go to moderation mailbox + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `).click() + ); + // check message + assert.containsOnce( + document.body, + '.o_Message', + "should be only one message in moderation box" + ); + assert.strictEqual( + document.querySelector('.o_Message_content').textContent, + "test", + "this message pending moderation should have the correct content" + ); + assert.containsOnce( + document.body, + '.o_Message_originThreadLink', + "thee message should have one origin" + ); + assert.strictEqual( + document.querySelector('.o_Message_originThreadLink').textContent, + "#general", + "the message pending moderation should have correct origin as its linked document" + ); + assert.containsOnce( + document.body, + '.o_Message_checkbox', + "there should be a moderation checkbox next to the message" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should be unchecked by default" + ); + // check select all (enabled) / unselect all (disabled) buttons + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonSelectAll', + "there should be a 'Select All' button in the control panel" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'), + 'disabled', + "the 'Select All' button should not be disabled" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonUnselectAll', + "there should be a 'Unselect All' button in the control panel" + ); + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'), + 'disabled', + "the 'Unselect All' button should be disabled" + ); + // check moderate all buttons (invisible) + assert.containsN( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration', + 3, + "there should be 3 buttons to moderate selected messages in the control panel" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-accept', + "there should one moderate button to accept messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + "the moderate button 'Accept' should be invisible by default" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-reject', + "there should one moderate button to reject messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'), + "the moderate button 'Reject' should be invisible by default" + ); + assert.containsOnce( + document.body, + '.o_widget_Discuss_controlPanelButtonModeration.o-discard', + "there should one moderate button to discard messages pending moderation" + ); + assert.isNotVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'), + "the moderate button 'Discard' should be invisible by default" + ); + + // click on message moderation checkbox + await afterNextRender(() => document.querySelector('.o_Message_checkbox').click()); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become checked after click" + ); + // check select all (disabled) / unselect all buttons (enabled) + assert.hasClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll'), + 'disabled', + "the 'Select All' button should be disabled" + ); + assert.doesNotHaveClass( + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll'), + 'disabled', + "the 'Unselect All' button should not be disabled" + ); + // check moderate all buttons updated (visible) + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept'), + "the moderate button 'Accept' should be visible" + ); + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-reject'), + "the moderate button 'Reject' should be visible" + ); + assert.isVisible( + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard'), + "the moderate button 'Discard' should be visible" + ); + + // test select buttons + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonUnselectAll').click() + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become unchecked after click" + ); + + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonSelectAll').click() + ); + assert.ok( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should become checked again after click" + ); + + // 2. go to channel 'general' + await afterNextRender(() => + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click() + ); + // check correct message + assert.containsOnce( + document.body, + '.o_Message', + "should be only one message in general channel" + ); + assert.containsOnce( + document.body, + '.o_Message_checkbox', + "there should be a moderation checkbox next to the message" + ); + assert.notOk( + document.querySelector('.o_Message_checkbox').checked, + "the moderation checkbox should not be checked here" + ); + await afterNextRender(() => document.querySelector('.o_Message_checkbox').click()); + // Don't test moderation actions visibility, since it is similar to moderation box. + + // 3. test discard button + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-discard').click() + ); + assert.containsOnce( + document.body, + '.o_ModerationDiscardDialog', + "discard dialog should be open" + ); + // the dialog will be tested separately + await afterNextRender(() => + document.querySelector('.o_ModerationDiscardDialog .o-cancel').click() + ); + assert.containsNone( + document.body, + '.o_ModerationDiscardDialog', + "discard dialog should be closed" + ); + + // 4. test reject button + await afterNextRender(() => + document.querySelector(` + .o_widget_Discuss_controlPanelButtonModeration.o-reject + `).click() + ); + assert.containsOnce( + document.body, + '.o_ModerationRejectDialog', + "reject dialog should be open" + ); + // the dialog will be tested separately + await afterNextRender(() => + document.querySelector('.o_ModerationRejectDialog .o-cancel').click() + ); + assert.containsNone( + document.body, + '.o_ModerationRejectDialog', + "reject dialog should be closed" + ); + + // 5. test accept button + await afterNextRender(() => + document.querySelector('.o_widget_Discuss_controlPanelButtonModeration.o-accept').click() + ); + assert.containsOnce( + document.body, + '.o_Message', + "should still be only one message in general channel" + ); + assert.containsNone( + document.body, + '.o_Message_checkbox', + "there should not be a moderation checkbox next to the message" + ); +}); + +QUnit.test('moderation: accept pending moderation message', async function (assert) { + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + assert.strictEqual( + messageIDs.length, + 1, + "should moderate one message" + ); + assert.strictEqual( + messageIDs[0], + 100, + "should moderate message with ID 100" + ); + assert.strictEqual( + decision, + 'accept', + "should accept the message" + ); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + assert.ok( + document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `), + "should display the message to moderate" + ); + const acceptButton = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationAction.o-accept + `); + assert.ok(acceptButton, "should display the accept button"); + + await afterNextRender(() => acceptButton.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const message = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + message, + "should display the accepted message" + ); + assert.containsNone( + message, + '.o_Message_moderationPending', + "the message should not be pending moderation" + ); +}); + +QUnit.test('moderation: reject pending moderation message (reject with explanation)', async function (assert) { + assert.expect(23); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + const kwargs = args.kwargs; + assert.strictEqual( + messageIDs.length, + 1, + "should moderate one message" + ); + assert.strictEqual( + messageIDs[0], + 100, + "should moderate message with ID 100" + ); + assert.strictEqual( + decision, + 'reject', + "should reject the message" + ); + assert.strictEqual( + kwargs.title, + "Message Rejected", + "should have correct reject message title" + ); + assert.strictEqual( + kwargs.comment, + "Your message was rejected by moderator.", + "should have correct reject message body / comment" + ); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + const pendingMessage = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + pendingMessage, + "should display the message to moderate" + ); + const rejectButton = pendingMessage.querySelector(':scope .o_Message_moderationAction.o-reject'); + assert.ok( + rejectButton, + "should display the reject button" + ); + + await afterNextRender(() => rejectButton.click()); + const dialog = document.querySelector('.o_ModerationRejectDialog'); + assert.ok( + dialog, + "a dialog should be prompt to the moderator on click reject" + ); + assert.strictEqual( + dialog.querySelector('.modal-title').textContent, + // TODO FIXME, this should be a proper title "Send explanation to author" + // see https://github.com/odoo/owl/issues/670 + "[object Object]", + "dialog should have correct title" + ); + + const messageTitle = dialog.querySelector(':scope .o_ModerationRejectDialog_title'); + assert.ok( + messageTitle, + "should have a title for rejecting" + ); + assert.hasAttrValue( + messageTitle, + 'placeholder', + "Subject", + "title for reject reason should have correct placeholder" + ); + assert.strictEqual( + messageTitle.value, + "Message Rejected", + "title for reject reason should have correct default value" + ); + + const messageComment = dialog.querySelector(':scope .o_ModerationRejectDialog_comment'); + assert.ok( + messageComment, + "should have a comment for rejecting" + ); + assert.hasAttrValue( + messageComment, + 'placeholder', + "Mail Body", + "comment for reject reason should have correct placeholder" + ); + assert.strictEqual( + messageComment.value, + "Your message was rejected by moderator.", + "comment for reject reason should have correct default text content" + ); + const confirmReject = dialog.querySelector(':scope .o-reject'); + assert.ok( + confirmReject, + "should have reject button" + ); + assert.strictEqual( + confirmReject.textContent, + "Reject" + ); + + await afterNextRender(() => confirmReject.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + 'should display the general channel' + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should now have no message in channel" + ); +}); + +QUnit.test('moderation: discard pending moderation message (reject without explanation)', async function (assert) { + assert.expect(16); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + is_moderator: true, + moderation_counter: 1, + moderation_channel_ids: [20], + }); + this.data['mail.message'].records = [{ + author_id: [2, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'moderate') { + assert.step('moderate'); + const messageIDs = args.args[0]; + const decision = args.args[1]; + assert.strictEqual(messageIDs.length, 1, "should moderate one message"); + assert.strictEqual(messageIDs[0], 100, "should moderate message with ID 100"); + assert.strictEqual(decision, 'discard', "should discard the message"); + } + return this._super(...arguments); + }, + }); + + // 1. go to moderation box + const moderationBox = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.messaging.moderation.localId + }"] + `); + assert.ok( + moderationBox, + "should display the moderation box" + ); + + await afterNextRender(() => moderationBox.click()); + const pendingMessage = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + pendingMessage, + "should display the message to moderate" + ); + + const discardButton = pendingMessage.querySelector(` + :scope .o_Message_moderationAction.o-discard + `); + assert.ok( + discardButton, + "should display the discard button" + ); + + await afterNextRender(() => discardButton.click()); + const dialog = document.querySelector('.o_ModerationDiscardDialog'); + assert.ok( + dialog, + "a dialog should be prompt to the moderator on click discard" + ); + assert.strictEqual( + dialog.querySelector('.modal-title').textContent, + // TODO FIXME, this should be a proper title "Confirmation" + // see https://github.com/odoo/owl/issues/670 + "[object Object]", + "dialog should have correct title" + ); + assert.strictEqual( + dialog.textContent, + "[object Object]×You are going to discard 1 message.Do you confirm the action?DiscardCancel", + "should warn the user on discard action" + ); + + const confirmDiscard = dialog.querySelector(':scope .o-discard'); + assert.ok( + confirmDiscard, + "should have discard button" + ); + assert.strictEqual( + confirmDiscard.textContent, + "Discard" + ); + + await afterNextRender(() => confirmDiscard.click()); + assert.verifySteps(['moderate']); + assert.containsOnce( + document.body, + '.o_MessageList_emptyTitle', + "should now have no message displayed in moderation box" + ); + + // 2. go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should now have no message in channel" + ); +}); + +QUnit.test('moderation: send message in moderated channel', async function (assert) { + assert.expect(4); + + const self = this; + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + }); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_post') { + const message = { + id: 100, + author_id: [13, 'Someone'], + body: args.kwargs.body, + channel_ids: [20], + message_type: args.kwargs.message_type, + model: 'mail.channel', + moderation_status: 'pending_moderation', + res_id: 20, + }; + const notificationData = { + type: 'author', + message: message, + }; + const notification = [[false, 'res.partner', 13], notificationData]; + self.widget.call('bus_service', 'trigger', 'notification', [notification]); + + return message.id; + } + return this._super(...arguments); + }, + }); + + // go to channel 'general' + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + assert.containsNone( + document.body, + '.o_Message', + "should have no message in channel" + ); + + // post a message + await afterNextRender(() => { + const textInput = document.querySelector('.o_ComposerTextInput_textarea'); + textInput.focus(); + document.execCommand('insertText', false, "Some Text"); + }); + await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); +}); + +QUnit.test('moderation: sent message accepted in moderated channel', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + }); + this.data['mail.message'].records = [{ + author_id: [13, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start(); + + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); + + // simulate accepted message + await afterNextRender(() => { + const messageData = { + author_id: [13, "Someone"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + model: 'mail.channel', + moderation_status: 'accepted', + res_id: 20, + }; + const notification = [[false, 'mail.channel', 20], messageData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + + // check message is accepted + const message = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + `); + assert.ok( + message, + "should still display the message" + ); + assert.containsNone( + message, + '.o_Message_moderationPending', + "the message should not be in pending moderation anymore" + ); +}); + +QUnit.test('moderation: sent message rejected in moderated channel', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + id: 20, + channel_type: "channel", + name: "general", + moderation: true, + }], + }, + }); + this.data['mail.message'].records = [{ + author_id: [13, "Someone"], + body: "<p>test</p>", + id: 100, + model: 'mail.channel', + moderation_status: 'pending_moderation', + need_moderation: true, + res_id: 20, + }]; + + await this.start(); + + const channel = document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `); + assert.ok( + channel, + "should display the general channel" + ); + + await afterNextRender(() => channel.click()); + const messagePending = document.querySelector(` + .o_Message[data-message-local-id="${ + this.env.models['mail.message'].find(message => message.id === 100).localId + }"] + .o_Message_moderationPending + `); + assert.ok( + messagePending, + "should display the pending message with pending info" + ); + assert.hasClass( + messagePending, + 'o-author', + "the message should be pending moderation as author" + ); + + // simulate reject from moderator + await afterNextRender(() => { + const notifData = { + type: 'deletion', + message_ids: [100], + }; + const notification = [[false, 'res.partner', 13], notifData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + // check no message + assert.containsNone( + document.body, + '.o_Message', + "message should be removed from channel after reject" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js new file mode 100644 index 00000000000..258d7770066 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class DiscussMobileMailboxSelection extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + return { + allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state), + discussThread: this.env.messaging.discuss.thread + ? this.env.messaging.discuss.thread.__state + : undefined, + }; + }, { + compareDepth: { + allOrderedAndPinnedMailboxes: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread[]} + */ + get orderedMailboxes() { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on a mailbox selection item. + * + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const { mailbox } = ev.currentTarget.dataset; + this.discuss.threadViewer.update({ thread: [['link', mailbox]] }); + } + +} + +Object.assign(DiscussMobileMailboxSelection, { + props: {}, + template: 'mail.DiscussMobileMailboxSelection', +}); + +return DiscussMobileMailboxSelection; + +}); diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss new file mode 100644 index 00000000000..b620e2f1328 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.scss @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussMobileMailboxSelection { + display: flex; + flex: 0 0 auto; +} + +.o_DiscussMobileMailboxSelection_button { + flex: 1 1 0; + padding: 8px; + z-index: 1; + + &.o-active { + z-index: 2; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussMobileMailboxSelection_button { + box-shadow: 0 2px 4px gray('400'); +} diff --git a/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml new file mode 100644 index 00000000000..e996a203966 --- /dev/null +++ b/addons/mail/static/src/components/discuss_mobile_mailbox_selection/discuss_mobile_mailbox_selection.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussMobileMailboxSelection" owl="1"> + <div class="o_DiscussMobileMailboxSelection"> + <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId"> + <button class="o_DiscussMobileMailboxSelection_button btn" + t-att-class="{ + 'btn-primary': discuss.thread === mailbox, + 'btn-secondary': discuss.thread !== mailbox, + 'o-active': discuss.thread === mailbox, + }" t-on-click="_onClick" t-att-data-mailbox-local-id="mailbox.localId" type="button" + > + <t t-esc="mailbox.name"/> + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js new file mode 100644 index 00000000000..11737fa04dc --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js @@ -0,0 +1,307 @@ +odoo.define('mail/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class DiscussSidebar extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore( + (...args) => this._useStoreSelector(...args), + { compareDepth: () => this._useStoreCompareDepth() } + ); + + /** + * Reference of the quick search input. Useful to filter channels and + * chats based on this input content. + */ + this._quickSearchInputRef = useRef('quickSearchInput'); + + // bind since passed as props + this._onAddChannelAutocompleteSelect = this._onAddChannelAutocompleteSelect.bind(this); + this._onAddChannelAutocompleteSource = this._onAddChannelAutocompleteSource.bind(this); + this._onAddChatAutocompleteSelect = this._onAddChatAutocompleteSelect.bind(this); + this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this); + } + + mounted() { + this._update(); + } + + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {mail.thread[]} + */ + get orderedMailboxes() { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } + + /** + * Return the list of chats that match the quick search value input. + * + * @returns {mail.thread[]} + */ + get quickSearchPinnedAndOrderedChats() { + const allOrderedAndPinnedChats = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + if (!this.discuss.sidebarQuickSearchValue) { + return allOrderedAndPinnedChats; + } + const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase(); + return allOrderedAndPinnedChats.filter(chat => { + const nameVal = chat.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + + /** + * Return the list of channels that match the quick search value input. + * + * @returns {mail.thread[]} + */ + get quickSearchOrderedAndPinnedMultiUserChannels() { + const allOrderedAndPinnedMultiUserChannels = this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + if (!this.discuss.sidebarQuickSearchValue) { + return allOrderedAndPinnedMultiUserChannels; + } + const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase(); + return allOrderedAndPinnedMultiUserChannels.filter(channel => { + const nameVal = channel.displayName.toLowerCase(); + return nameVal.includes(qsVal); + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _update() { + if (this._quickSearchInputRef.el) { + this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue; + } + } + + /** + * @private + * @returns {Object} + */ + _useStoreCompareDepth() { + return { + allOrderedAndPinnedChats: 1, + allOrderedAndPinnedMailboxes: 1, + allOrderedAndPinnedMultiUserChannels: 1, + allPinnedChannelAmount: 1, + }; + } + + /** + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + return { + allOrderedAndPinnedChats: + this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1) + .map(chat => chat.__state), + allOrderedAndPinnedMailboxes: this.orderedMailboxes.map(mailbox => mailbox.__state), + allOrderedAndPinnedMultiUserChannels: + this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1) + .map(channel => channel.__state), + allPinnedChannelAmount: + this.env.models['mail.thread'] + .all(thread => + thread.isPinned && + thread.model === 'mail.channel' + ).length, + discuss: this.env.messaging.discuss.__state, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAddChannelAutocompleteSelect(ev, ui) { + this.discuss.handleAddChannelAutocompleteSelect(ev, ui); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAddChannelAutocompleteSource(req, res) { + this.discuss.handleAddChannelAutocompleteSource(req, res); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onAddChatAutocompleteSelect(ev, ui) { + this.discuss.handleAddChatAutocompleteSelect(ev, ui); + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onAddChatAutocompleteSource(req, res) { + this.discuss.handleAddChatAutocompleteSource(req, res); + } + + /** + * Called when clicking on add channel icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChannelAdd(ev) { + ev.stopPropagation(); + this.discuss.update({ isAddingChannel: true }); + } + + /** + * Called when clicking on channel title. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChannelTitle(ev) { + ev.stopPropagation(); + return this.env.bus.trigger('do-action', { + action: { + name: this.env._t("Public Channels"), + type: 'ir.actions.act_window', + res_model: 'mail.channel', + views: [[false, 'kanban'], [false, 'form']], + domain: [['public', '!=', 'private']] + }, + }); + } + + /** + * Called when clicking on add chat icon. + * + * @private + * @param {MouseEvent} ev + */ + _onClickChatAdd(ev) { + ev.stopPropagation(); + this.discuss.update({ isAddingChat: true }); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideAddingItem(ev) { + ev.stopPropagation(); + this.discuss.clearIsAddingItem(); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onInputQuickSearch(ev) { + ev.stopPropagation(); + this.discuss.update({ + sidebarQuickSearchValue: this._quickSearchInputRef.el.value, + }); + } + +} + +Object.assign(DiscussSidebar, { + components, + props: {}, + template: 'mail.DiscussSidebar', +}); + +return DiscussSidebar; + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss new file mode 100644 index 00000000000..3e49cddf7dd --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.scss @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussSidebar { + display: flex; + flex-flow: column; + width: $o-mail-chat-sidebar-width; + + @include media-breakpoint-up(xl) { + width: $o-mail-chat-sidebar-width + 50px; + } +} + +.o_DiscussSidebar_group { + display: flex; + flex-flow: column; + flex: 0 0 auto; +} + +.o_DiscussSidebar_groupHeader { + display: flex; + align-items: center; + margin: 5px 0; +} + +.o_DiscussSidebar_groupHeaderItem { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right; + } + + &:last-child { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebar_itemNew { + display: flex; + justify-content: center; +} + +.o_DiscussSidebar_itemNewInput { + flex: 1 1 auto; + margin-left: $o-mail-discuss-sidebar-active-indicator-margin-right + 3px; + margin-right: $o-mail-discuss-sidebar-scrollbar-width; +} + +.o_DiscussSidebar_quickSearch { + border-radius: 10px; + margin: 0 $o-mail-discuss-sidebar-scrollbar-width 10px; + padding: 3px 10px; +} + +.o_DiscussSidebar_separator { + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussSidebar { + background-color: gray('900'); + color: gray('300'); +} + +.o_DiscussSidebar_groupHeader { + font-size: $font-size-sm; + text-transform: uppercase; + font-weight: bolder; +} + +.o_DiscussSidebar_groupHeaderItemAdd { + cursor: pointer; + + &:not(:hover) { + color: gray('600'); + } +} + +.o_DiscussSidebar_groupTitle { + + &:not(.o-clickable) { + color: gray('600'); + } + + &.o-clickable { + cursor: pointer; + + &:not(:hover) { + color: gray('600'); + } + } +} + +.o_DiscussSidebar_itemNewInput { + outline: none; +} + +.o_DiscussSidebar_quickSearch { + border: none; + outline: none; +} + +.o_DiscussSidebar_separator { + background-color: gray('600'); +} diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml new file mode 100644 index 00000000000..ac4ac02bd46 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussSidebar" owl="1"> + <div name="root" class="o_DiscussSidebar"> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupMailbox"> + <t t-foreach="orderedMailboxes" t-as="mailbox" t-key="mailbox.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="mailbox.localId" + /> + </t> + </div> + <hr class="o_DiscussSidebar_separator"/> + <t t-if="env.models['mail.thread'].all(thread => thread.isPinned and thread.model === 'mail.channel').length > 19"> + <input class="o_DiscussSidebar_quickSearch" t-on-input="_onInputQuickSearch" placeholder="Quick search..." t-ref="quickSearchInput" t-esc="discuss.sidebarQuickSearchValue"/> + </t> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChannel"> + <div class="o_DiscussSidebar_groupHeader"> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle o-clickable" t-on-click="_onClickChannelTitle"> + Channels + </div> + <div class="o-autogrow"/> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChannelAdd" title="Add or join a channel"/> + </div> + <div class="o_DiscussSidebar_list"> + <t t-if="discuss.isAddingChannel"> + <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew"> + <AutocompleteInput + class="o_DiscussSidebar_itemNewInput" + isFocusOnMount="true" + isHtml="true" + placeholder="'Find or create a channel...'" + select="_onAddChannelAutocompleteSelect" + source="_onAddChannelAutocompleteSource" + t-on-o-hide="_onHideAddingItem" + /> + </div> + </t> + <t t-foreach="quickSearchOrderedAndPinnedMultiUserChannels" t-as="channel" t-key="channel.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="channel.localId" + /> + </t> + </div> + </div> + <div class="o_DiscussSidebar_group o_DiscussSidebar_groupChat"> + <div class="o_DiscussSidebar_groupHeader"> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle"> + Direct Messages + </div> + <div class="o-autogrow"/> + <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupHeaderItemAdd fa fa-plus" t-on-click="_onClickChatAdd" title="Start a conversation"/> + </div> + <div class="o_DiscussSidebar_list"> + <t t-if="discuss.isAddingChat"> + <div class="o_DiscussSidebar_item o_DiscussSidebar_itemNew"> + <AutocompleteInput + class="o_DiscussSidebar_itemNewInput" + isFocusOnMount="true" + placeholder="'Find or start a conversation...'" + select="_onAddChatAutocompleteSelect" + source="_onAddChatAutocompleteSource" + t-on-o-hide="_onHideAddingItem" + /> + </div> + </t> + <t t-foreach="quickSearchPinnedAndOrderedChats" t-as="chat" t-key="chat.localId"> + <DiscussSidebarItem + class="o_DiscussSidebar_item" + threadLocalId="chat.localId" + /> + </t> + </div> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js new file mode 100644 index 00000000000..db22f9f2116 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js @@ -0,0 +1,203 @@ +odoo.define('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) { +'use strict'; + +const components = { + EditableText: require('mail/static/src/components/editable_text/editable_text.js'), + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.Dialog'); + +const { Component } = owl; + +class DiscussSidebarItem extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const correspondent = thread ? thread.correspondent : undefined; + return { + correspondent: correspondent ? correspondent.__state : undefined, + discuss: this.env.messaging.discuss.__state, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the counter of this discuss item, which is based on the thread type. + * + * @returns {integer} + */ + get counter() { + if (this.thread.model === 'mail.box') { + return this.thread.counter; + } else if (this.thread.channel_type === 'channel') { + return this.thread.message_needaction_counter; + } else if (this.thread.channel_type === 'chat') { + return this.thread.message_unread_counter; + } + return 0; + } + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {boolean} + */ + hasUnpin() { + return this.thread.channel_type === 'chat'; + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + _askAdminConfirmation() { + return new Promise(resolve => { + Dialog.confirm(this, + this.env._t("You are the administrator of this channel. Are you sure you want to leave?"), + { + buttons: [ + { + text: this.env._t("Leave"), + classes: 'btn-primary', + close: true, + click: resolve + }, + { + text: this.env._t("Discard"), + close: true + } + ] + } + ); + }); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onCancelRenaming(ev) { + this.discuss.cancelThreadRenaming(this.thread); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.discuss.threadViewer.update({ thread: [['link', this.thread]] }); + } + + /** + * Stop propagation to prevent selecting this item. + * + * @private + * @param {CustomEvent} ev + */ + _onClickedEditableText(ev) { + ev.stopPropagation(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + async _onClickLeave(ev) { + ev.stopPropagation(); + if (this.thread.creator === this.env.messaging.currentUser) { + await this._askAdminConfirmation(); + } + this.thread.unsubscribe(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRename(ev) { + ev.stopPropagation(); + this.discuss.setThreadRenaming(this.thread); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSettings(ev) { + ev.stopPropagation(); + return this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: this.thread.model, + res_id: this.thread.id, + views: [[false, 'form']], + target: 'current' + }, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnpin(ev) { + ev.stopPropagation(); + this.thread.unsubscribe(); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.newName + */ + _onValidateEditableText(ev) { + ev.stopPropagation(); + this.discuss.renameThread(this.thread, ev.detail.newName); + } + +} + +Object.assign(DiscussSidebarItem, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.DiscussSidebarItem', +}); + +return DiscussSidebarItem; + +}); diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss new file mode 100644 index 00000000000..649297585f9 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.scss @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DiscussSidebarItem { + display: flex; + align-items: center; + padding: map-get($spacers, 1) 0; + + &:hover .o_DiscussSidebarItem_commands { + display: flex; + } +} + +.o_DiscussSidebarItem_activeIndicator { + width: $o-mail-discuss-sidebar-active-indicator-width; + align-self: stretch; +} + +.o_DiscussSidebarItem_command { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: 0px; + } + + &:last-child { + margin-right: 0px; + } +} + +.o_DiscussSidebarItem_commands { + display: none; +} + +.o_DiscussSidebarItem_item { + margin-left: 3px; + margin-right: 3px; + + &:first-child { + margin-left: 0px; + margin-right: $o-mail-discuss-sidebar-active-indicator-margin-right; + } + + &:last-child { + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebarItem_name { + overflow: hidden; + text-overflow: ellipsis; + + &.o-editable { + margin-left: $o-mail-discuss-sidebar-active-indicator-width + $o-mail-discuss-sidebar-active-indicator-margin-right; + margin-right: $o-mail-discuss-sidebar-scrollbar-width; + } +} + +.o_DiscussSidebarItem_nameInput { + width: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DiscussSidebarItem { + cursor: pointer; + + &:hover { + background-color: darken(gray('900'), 5%); + } + + &.o-starred-box { + .o_DiscussSidebarItem_counter { + border-color: gray('600'); + background-color: gray('600'); + } + } +} + +.o_DiscussSidebarItem_activeIndicator.o-item-active { + background-color: $o-brand-primary; +} + +.o_DiscussSidebarItem_command:not(:hover) { + color: gray('600'); +} + +.o_DiscussSidebarItem_counter { + background-color: $o-brand-primary; +} + +.o_DiscussSidebarItem_name { + + &.o-item-unread { + font-weight: bold; + } +} + +.o_DiscussSidebarItem_nameInput { + outline: none; + border: none; + border-radius: 2px; +} diff --git a/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml new file mode 100644 index 00000000000..2db165b4c34 --- /dev/null +++ b/addons/mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DiscussSidebarItem" owl="1"> + <div class="o_DiscussSidebarItem" + t-att-class="{ + 'o-active': thread and discuss.thread === thread, + 'o-starred-box': thread and thread === env.messaging.starred, + 'o-unread': thread and thread.message_unread_counter > 0, + }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined" t-att-data-thread-name="thread ? thread.displayName : undefined" + > + <t t-if="thread"> + <div class=" o_DiscussSidebarItem_activeIndicator o_DiscussSidebarItem_item" t-att-class="{ 'o-item-active': discuss.thread === thread }"/> + <ThreadIcon class="o_DiscussSidebarItem_item" threadLocalId="thread.localId"/> + <t t-if="thread.channel_type === 'chat' and discuss.renamingThreads.includes(thread)"> + <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name o-editable"> + <EditableText + class="o_DiscussSidebarItem_nameInput" + placeholder="thread.correspondent.name" + value="thread.displayName" + t-on-o-cancel="_onCancelRenaming" + t-on-o-clicked="_onClickedEditableText" + t-on-o-validate="_onValidateEditableText" + /> + </div> + </t> + <t t-else=""> + <div class="o_DiscussSidebarItem_item o_DiscussSidebarItem_name" t-att-class="{ 'o-item-unread': thread.message_unread_counter > 0 }"> + <t t-esc="thread.displayName"/> + </div> + </t> + <div class="o-autogrow o_DiscussSidebarItem_item"/> + <t t-if="thread.model !== 'mail.box'"> + <div class="o_DiscussSidebarItem_commands o_DiscussSidebarItem_item"> + <t t-if="thread.channel_type === 'channel'"> + <div class="fa fa-cog o_DiscussSidebarItem_command o_DiscussSidebarItem_commandSettings" t-on-click="_onClickSettings" title="Channel settings" role="img"/> + <t t-if="!thread.message_needaction_counter and !thread.group_based_subscription"> + <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandLeave fa fa-times" t-on-click="_onClickLeave" title="Leave this channel" role="img"/> + </t> + </t> + <t t-if="thread.channel_type === 'chat'"> + <div class="o_DiscussSidebarItem_command o_DiscussSidebarItem_commandRename fa fa-cog" t-on-click="_onClickRename" title="Rename conversation" role="img"/> + </t> + <t t-if="hasUnpin()"> + <t t-if="!thread.message_unread_counter"> + <div class="fa fa-times o_DiscussSidebarItem_command o_DiscussSidebarItem_commandUnpin" t-on-click="_onClickUnpin" title="Unpin conversation" role="img"/> + </t> + </t> + </div> + </t> + <t t-if="counter > 0"> + <div class="o_DiscussSidebarItem_counter o_DiscussSidebarItem_item badge badge-pill"> + <t t-esc="counter"/> + </div> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.js b/addons/mail/static/src/components/drop_zone/drop_zone.js new file mode 100644 index 00000000000..2eb4dc8ef74 --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.js @@ -0,0 +1,136 @@ +odoo.define('mail/static/src/components/drop_zone/drop_zone.js', function (require) { +'use strict'; + +const { Component, useState } = owl; + +class DropZone extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + /** + * Determine whether the user is dragging files over the dropzone. + * Useful to provide visual feedback in that case. + */ + isDraggingInside: false, + }); + /** + * Counts how many drag enter/leave happened on self and children. This + * ensures the drop effect stays active when dragging over a child. + */ + this._dragCount = 0; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns whether the given node is self or a children of self. + * + * @param {Node} node + * @returns {boolean} + */ + contains(node) { + return this.el.contains(node); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Making sure that dragging content is external files. + * Ignoring other content dragging like text. + * + * @private + * @param {DataTransfer} dataTransfer + * @returns {boolean} + */ + _isDragSourceExternalFile(dataTransfer) { + const dragDataType = dataTransfer.types; + if (dragDataType.constructor === window.DOMStringList) { + return dragDataType.contains('Files'); + } + if (dragDataType.constructor === Array) { + return dragDataType.includes('Files'); + } + return false; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Shows a visual drop effect when dragging inside the dropzone. + * + * @private + * @param {DragEvent} ev + */ + _onDragenter(ev) { + ev.preventDefault(); + if (this._dragCount === 0) { + this.state.isDraggingInside = true; + } + this._dragCount++; + } + + /** + * Hides the visual drop effect when dragging outside the dropzone. + * + * @private + * @param {DragEvent} ev + */ + _onDragleave(ev) { + this._dragCount--; + if (this._dragCount === 0) { + this.state.isDraggingInside = false; + } + } + + /** + * Prevents default (from the template) in order to receive the drop event. + * The drop effect cursor works only when set on dragover. + * + * @private + * @param {DragEvent} ev + */ + _onDragover(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + } + + /** + * Triggers the `o-dropzone-files-dropped` event when new files are dropped + * on the dropzone, and then removes the visual drop effect. + * + * The parents should handle this event to process the files as they wish, + * such as uploading them. + * + * @private + * @param {DragEvent} ev + */ + _onDrop(ev) { + ev.preventDefault(); + if (this._isDragSourceExternalFile(ev.dataTransfer)) { + this.trigger('o-dropzone-files-dropped', { + files: ev.dataTransfer.files, + }); + } + this.state.isDraggingInside = false; + } + +} + +Object.assign(DropZone, { + props: {}, + template: 'mail.DropZone', +}); + +return DropZone; + +}); diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.scss b/addons/mail/static/src/components/drop_zone/drop_zone.scss new file mode 100644 index 00000000000..e1a901cb12e --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.scss @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_DropZone { + display: flex; + position: absolute; + left: 0; + bottom: 0; + height: 100%; + width: 100%; + z-index: 1; + align-items: center; + justify-content: center; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_DropZone { + color: $o-enterprise-primary-color; + background: rgba(255, 255, 255, 0.9); + border: 2px dashed $o-enterprise-primary-color; + + &.o-dragging-inside { + border-width: 5px; + } +} diff --git a/addons/mail/static/src/components/drop_zone/drop_zone.xml b/addons/mail/static/src/components/drop_zone/drop_zone.xml new file mode 100644 index 00000000000..b3db940fde9 --- /dev/null +++ b/addons/mail/static/src/components/drop_zone/drop_zone.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.DropZone" owl="1"> + <div class="o_DropZone" t-att-class="{ 'o-dragging-inside': state.isDraggingInside }" t-on-dragenter="_onDragenter" t-on-dragleave="_onDragleave" t-on-dragover="_onDragover" t-on-drop="_onDrop"> + <h4> + Drag Files Here <i class="fa fa-download"/> + </h4> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/editable_text/editable_text.js b/addons/mail/static/src/components/editable_text/editable_text.js new file mode 100644 index 00000000000..4a4b926741e --- /dev/null +++ b/addons/mail/static/src/components/editable_text/editable_text.js @@ -0,0 +1,82 @@ +odoo.define('mail/static/src/components/editable_text/editable_text.js', function (require) { +'use strict'; + +const { Component } = owl; + +class EditableText extends Component { + + mounted() { + this.el.focus(); + this.el.setSelectionRange(0, (this.el.value && this.el.value.length) || 0); + } + + willUnmount() { + this.trigger('o-cancel'); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onBlur(ev) { + this.trigger('o-cancel'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.trigger('o-clicked'); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + switch (ev.key) { + case 'Enter': + this._onKeydownEnter(ev); + break; + case 'Escape': + this.trigger('o-cancel'); + break; + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydownEnter(ev) { + const value = this.el.value; + const newName = value || this.props.placeholder; + if (this.props.value !== newName) { + this.trigger('o-validate', { newName }); + } else { + this.trigger('o-cancel'); + } + } + +} + +Object.assign(EditableText, { + defaultProps: { + placeholder: "", + value: "", + }, + props: { + placeholder: String, + value: String, + }, + template: 'mail.EditableText', +}); + +return EditableText; + +}); diff --git a/addons/mail/static/src/components/editable_text/editable_text.xml b/addons/mail/static/src/components/editable_text/editable_text.xml new file mode 100644 index 00000000000..5e3aa52abe6 --- /dev/null +++ b/addons/mail/static/src/components/editable_text/editable_text.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.EditableText" owl="1"> + <input class="o_EditableText" t-att-value="props.value" t-on-blur="_onBlur" t-on-click="_onClick" t-on-keydown="_onKeydown" t-att-placeholder="props.placeholder"/> + </t> + +</templates> diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.js b/addons/mail/static/src/components/emojis_popover/emojis_popover.js new file mode 100644 index 00000000000..2e8ee6c6ae8 --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js @@ -0,0 +1,42 @@ +odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); + +const { Component } = owl; + +class EmojisPopover extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + this.emojis = emojis; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEmoji(ev) { + this.trigger('o-popover-close'); + this.trigger('o-emoji-selection', { + unicode: ev.currentTarget.dataset.unicode, + }); + } + +} + +Object.assign(EmojisPopover, { + props: {}, + template: 'mail.EmojisPopover', +}); + +return EmojisPopover; + +}); diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.scss b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss new file mode 100644 index 00000000000..e49edff659d --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.scss @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_EmojisPopover { + display: flex; + flex-flow: row wrap; +} + +.o_EmojisPopover_emoji { + font-size: 1.1em; + margin: 3px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_EmojisPopover_emoji { + cursor: pointer; +} diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.xml b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml new file mode 100644 index 00000000000..cac840bbedc --- /dev/null +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.EmojisPopover" owl="1"> + <div class="o_EmojisPopover"> + <t t-foreach="emojis" t-as="emoji" t-key="emoji.unicode"> + <span class="o_EmojisPopover_emoji" t-on-click="_onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode"> + <t t-esc="emoji.unicode"/> + </span> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.js b/addons/mail/static/src/components/file_uploader/file_uploader.js new file mode 100644 index 00000000000..732eb312126 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.js @@ -0,0 +1,206 @@ +odoo.define('mail/static/src/components/file_uploader/file_uploader.js', function (require) { +'use strict'; + +const core = require('web.core'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class FileUploader extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this._fileInputRef = useRef('fileInput'); + this._fileUploadId = _.uniqueId('o_FileUploader_fileupload'); + this._onAttachmentUploaded = this._onAttachmentUploaded.bind(this); + } + + mounted() { + $(window).on(this._fileUploadId, this._onAttachmentUploaded); + } + + willUnmount() { + $(window).off(this._fileUploadId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {FileList|Array} files + * @returns {Promise} + */ + async uploadFiles(files) { + await this._unlinkExistingAttachments(files); + this._createTemporaryAttachments(files); + await this._performUpload(files); + this._fileInputRef.el.value = ''; + } + + openBrowserFileUploader() { + this._fileInputRef.el.click(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} fileData + * @returns {mail.attachment} + */ + _createAttachment(fileData) { + return this.env.models['mail.attachment'].create(Object.assign( + {}, + fileData, + this.props.newAttachmentExtraData + )); + } + + /** + * @private + * @param {File} file + * @returns {FormData} + */ + _createFormData(file) { + let formData = new window.FormData(); + formData.append('callback', this._fileUploadId); + formData.append('csrf_token', core.csrf_token); + formData.append('id', this.props.uploadId); + formData.append('model', this.props.uploadModel); + formData.append('ufile', file, file.name); + return formData; + } + + /** + * @private + * @param {FileList|Array} files + */ + _createTemporaryAttachments(files) { + for (const file of files) { + this._createAttachment({ + filename: file.name, + isTemporary: true, + name: file.name + }); + } + } + /** + * @private + * @param {FileList|Array} files + * @returns {Promise} + */ + async _performUpload(files) { + for (const file of files) { + const response = await window.fetch('/web/binary/upload_attachment', { + method: 'POST', + body: this._createFormData(file), + }); + let html = await response.text(); + const template = document.createElement('template'); + template.innerHTML = html.trim(); + window.eval(template.content.firstChild.textContent); + } + } + + /** + * @private + * @param {FileList|Array} files + * @returns {Promise} + */ + async _unlinkExistingAttachments(files) { + for (const file of files) { + const attachment = this.props.attachmentLocalIds + .map(attachmentLocalId => this.env.models['mail.attachment'].get(attachmentLocalId)) + .find(attachment => attachment.name === file.name && attachment.size === file.size); + // if the files already exits, delete the file before upload + if (attachment) { + attachment.remove(); + } + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery.Event} ev + * @param {...Object} filesData + */ + async _onAttachmentUploaded(ev, ...filesData) { + for (const fileData of filesData) { + const { error, filename, id, mimetype, name, size } = fileData; + if (error || !id) { + this.env.services['notification'].notify({ + type: 'danger', + message: owl.utils.escape(error), + }); + const relatedTemporaryAttachments = this.env.models['mail.attachment'] + .find(attachment => + attachment.filename === filename && + attachment.isTemporary + ); + for (const attachment of relatedTemporaryAttachments) { + attachment.delete(); + } + return; + } + // FIXME : needed to avoid problems on uploading + // Without this the useStore selector of component could be not called + // E.g. in attachment_box_tests.js + await new Promise(resolve => setTimeout(resolve)); + const attachment = this._createAttachment({ + filename, + id, + mimetype, + name, + size, + }); + this.trigger('o-attachment-created', { attachment }); + } + } + + /** + * Called when there are changes in the file input. + * + * @private + * @param {Event} ev + * @param {EventTarget} ev.target + * @param {FileList|Array} ev.target.files + */ + async _onChangeAttachment(ev) { + await this.uploadFiles(ev.target.files); + } + +} + +Object.assign(FileUploader, { + defaultProps: { + uploadId: 0, + uploadModel: 'mail.compose.message' + }, + props: { + attachmentLocalIds: { + type: Array, + element: String, + }, + newAttachmentExtraData: { + type: Object, + optional: true, + }, + uploadId: Number, + uploadModel: String, + }, + template: 'mail.FileUploader', +}); + +return FileUploader; + +}); diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.scss b/addons/mail/static/src/components/file_uploader/file_uploader.scss new file mode 100644 index 00000000000..32792313ed8 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.scss @@ -0,0 +1,3 @@ +.o_FileUploader_input { + display: none !important; +} diff --git a/addons/mail/static/src/components/file_uploader/file_uploader.xml b/addons/mail/static/src/components/file_uploader/file_uploader.xml new file mode 100644 index 00000000000..bf144037511 --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FileUploader" owl="1"> + <div class="o_FileUploader"> + <input class="o_FileUploader_input" t-on-change="_onChangeAttachment" multiple="true" type="file" t-ref="fileInput" t-key="'fileInput'"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/file_uploader/file_uploader_tests.js b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js new file mode 100644 index 00000000000..a5831a89efb --- /dev/null +++ b/addons/mail/static/src/components/file_uploader/file_uploader_tests.js @@ -0,0 +1,104 @@ +odoo.define('mail/static/src/components/file_uploader/file_uploader_tests.js', function (require) { +"use strict"; + +const components = { + FileUploader: require('mail/static/src/components/file_uploader/file_uploader.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + inputFiles, + nextAnimationFrame, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { file: { createFile } } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('file_uploader', {}, function () { +QUnit.module('file_uploader_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.components = []; + + this.createFileUploaderComponent = async props => { + const FileUploaderComponent = components.FileUploader; + FileUploaderComponent.env = this.env; + const fileUploader = new FileUploaderComponent( + null, + Object.assign({ attachmentLocalIds: [] }, props) + ); + await fileUploader.mount(this.widget.el); + this.components.push(fileUploader); + return fileUploader; + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + const { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + for (const fileUploader of this.components) { + fileUploader.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + delete components.FileUploader.env; + this.env = undefined; + }, +}); + +QUnit.test('no conflicts between file uploaders', async function (assert) { + assert.expect(2); + + await this.start(); + const fileUploader1 = await this.createFileUploaderComponent(); + const fileUploader2 = await this.createFileUploaderComponent(); + const file1 = await createFile({ + name: 'text1.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + inputFiles( + fileUploader1.el.querySelector('.o_FileUploader_input'), + [file1] + ); + await nextAnimationFrame(); // we can't use afterNextRender as fileInput are display:none + assert.strictEqual( + this.env.models['mail.attachment'].all().length, + 1, + 'Uploaded file should be the only attachment created' + ); + + const file2 = await createFile({ + name: 'text2.txt', + content: 'hello, world', + contentType: 'text/plain', + }); + inputFiles( + fileUploader2.el.querySelector('.o_FileUploader_input'), + [file2] + ); + await nextAnimationFrame(); + assert.strictEqual( + this.env.models['mail.attachment'].all().length, + 2, + 'Uploaded file should be the only attachment added' + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follow_button/follow_button.js b/addons/mail/static/src/components/follow_button/follow_button.js new file mode 100644 index 00000000000..b1639567c14 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.js @@ -0,0 +1,91 @@ +odoo.define('mail/static/src/components/follow_button/follow_button.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useState } = owl.hooks; + +class FollowButton extends Component { + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + /** + * Determine whether the unfollow button is highlighted or not. + */ + isUnfollowButtonHighlighted: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollow(ev) { + this.thread.follow(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickUnfollow(ev) { + this.thread.unfollow(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseLeaveUnfollow(ev) { + this.state.isUnfollowButtonHighlighted = false; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onMouseEnterUnfollow(ev) { + this.state.isUnfollowButtonHighlighted = true; + } + +} + +Object.assign(FollowButton, { + defaultProps: { + isDisabled: false, + }, + props: { + isDisabled: Boolean, + threadLocalId: String, + }, + template: 'mail.FollowButton', +}); + +return FollowButton; + +}); diff --git a/addons/mail/static/src/components/follow_button/follow_button.scss b/addons/mail/static/src/components/follow_button/follow_button.scss new file mode 100644 index 00000000000..431de0e8577 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.scss @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_FollowButton_follow { + color: gray('600'); +} + +.o_FollowButton_unfollow { + color: gray('600'); + + &.o-following { + color: $green; + } + + &.o-unfollow { + color: $orange; + } +} diff --git a/addons/mail/static/src/components/follow_button/follow_button.xml b/addons/mail/static/src/components/follow_button/follow_button.xml new file mode 100644 index 00000000000..00fc8d65310 --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowButton" owl="1"> + <div class="o_FollowButton"> + <t t-if="thread.isCurrentPartnerFollowing"> + <button class="o_FollowButton_unfollow btn btn-link" t-att-class="{ 'o-following': !state.isUnfollowButtonHighlighted, 'o-unfollow': state.isUnfollowButtonHighlighted }" t-att-disabled="props.isDisabled" t-on-click="_onClickUnfollow" t-on-mouseenter="_onMouseEnterUnfollow" t-on-mouseleave="_onMouseLeaveUnfollow"> + <t t-if="state.isUnfollowButtonHighlighted"> + <i class="fa fa-times"/> Unfollow + </t> + <t t-else=""> + <i class="fa fa-check"/> Following + </t> + </button> + </t> + <t t-else=""> + <button class="o_FollowButton_follow btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollow"> + Follow + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follow_button/follow_button_tests.js b/addons/mail/static/src/components/follow_button/follow_button_tests.js new file mode 100644 index 00000000000..a32cdb76e9d --- /dev/null +++ b/addons/mail/static/src/components/follow_button/follow_button_tests.js @@ -0,0 +1,331 @@ +odoo.define('mail/static/src/components/follow_button/follow_button_tests.js', function (require) { +'use strict'; + +const components = { + FollowButton: require('mail/static/src/components/follow_button/follow_button.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follow_button', {}, function () { +QUnit.module('follow_button_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowButtonComponent = async (thread, otherProps = {}) => { + const FollowButtonComponent = components.FollowButton; + FollowButtonComponent.env = this.env; + this.component = new FollowButtonComponent(null, + Object.assign(otherProps, { threadLocalId: thread.localId }) + ); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.FollowButton.env; + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread, { isDisabled: true }); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have 'Follow' button" + ); + assert.ok( + document.querySelector('.o_FollowButton_follow').disabled, + "'Follow' button should be disabled" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have 'Follow' button" + ); + assert.notOk( + document.querySelector('.o_FollowButton_follow').disabled, + "'Follow' button should be disabled" + ); +}); + +QUnit.test('hover following button', async function (assert) { + assert.expect(8); + const self = this; + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + return [{ + id: 100, + message_follower_ids: [1], + }]; + } else if (route.includes('message_subscribe')) { + return; + } else if (route.includes('message_unsubscribe')) { + return; + } else if (route.includes('mail/read_followers')) { + return { + followers: [{ + partner_id: self.env.session.partner_id, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + thread.follow(); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have 'Unfollow' button" + ); + assert.strictEqual( + document.querySelector('.o_FollowButton_unfollow').textContent.trim(), + 'Following', + "'unfollow' button should display 'Following' as text when not hovered" + ); + assert.containsNone( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-times', + "'unfollow' button should not contain a cross icon when not hovered" + ); + assert.containsOnce( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-check', + "'unfollow' button should contain a check icon when not hovered" + ); + + await afterNextRender(() => { + document + .querySelector('.o_FollowButton_unfollow') + .dispatchEvent(new window.MouseEvent('mouseenter')); + } + ); + assert.strictEqual( + document.querySelector('.o_FollowButton_unfollow').textContent.trim(), + 'Unfollow', + "'unfollow' button should display 'Unfollow' as text when hovered" + ); + assert.containsOnce( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-times', + "'unfollow' button should contain a cross icon when hovered" + ); + assert.containsNone( + document.querySelector('.o_FollowButton_unfollow'), + '.fa-check', + "'unfollow' button should not contain a check icon when hovered" + ); +}); + +QUnit.test('click on "follow" button', async function (assert) { + assert.expect(8); + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + assert.step('rpc:read_follower_ids'); + return [{ + id: 100, + message_follower_ids: [1], + }]; + } else if (route.includes('message_subscribe')) { + assert.step('rpc:message_subscribe'); + return; + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:read_followers_details'); + return { + followers: [{ + partner_id: 3, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have button follow" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowButton_follow').click(); + }); + assert.verifySteps([ + 'rpc:message_subscribe', + 'rpc:read_follower_ids', + 'rpc:read_followers_details', + ]); + assert.containsNone( + document.body, + '.o_FollowButton_follow', + "should not have follow button after clicked on follow" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have unfollow button after clicked on follow" + ); +}); + +QUnit.test('click on "unfollow" button', async function (assert) { + assert.expect(7); + const self = this; + + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + return [{ + id: 100, + message_follower_ids: [1], + }]; + } else if (route.includes('message_subscribe')) { + return; + } else if (route.includes('message_unsubscribe')) { + assert.step('rpc:message_unsubscribe'); + return; + } else if (route.includes('mail/read_followers')) { + return { + followers: [{ + partner_id: self.env.session.partner_id, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + thread.follow(); + await this.createFollowButtonComponent(thread); + assert.containsOnce( + document.body, + '.o_FollowButton', + "should have follow button component" + ); + assert.containsNone( + document.body, + '.o_FollowButton_follow', + "should not have button follow" + ); + assert.containsOnce( + document.body, + '.o_FollowButton_unfollow', + "should have button unfollow" + ); + + await afterNextRender(() => document.querySelector('.o_FollowButton_unfollow').click()); + assert.verifySteps(['rpc:message_unsubscribe']); + assert.containsOnce( + document.body, + '.o_FollowButton_follow', + "should have follow button after clicked on unfollow" + ); + assert.containsNone( + document.body, + '.o_FollowButton_unfollow', + "should not have unfollow button after clicked on unfollow" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower/follower.js b/addons/mail/static/src/components/follower/follower.js new file mode 100644 index 00000000000..f446c098914 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.js @@ -0,0 +1,81 @@ +odoo.define('mail/static/src/components/follower/follower.js', function (require) { +'use strict'; + +const components = { + FollowerSubtypeList: require('mail/static/src/components/follower_subtype_list/follower_subtype_list.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class Follower extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const follower = this.env.models['mail.follower'].get(props.followerLocalId); + return [follower ? follower.__state : undefined]; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower} + */ + get follower() { + return this.env.models['mail.follower'].get(this.props.followerLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDetails(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.env.messaging.openDocument({ + id: this.follower.resId, + model: this.follower.resModel, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickEdit(ev) { + ev.preventDefault(); + this.follower.showSubtypes(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickRemove(ev) { + this.follower.remove(); + } + +} + +Object.assign(Follower, { + components, + props: { + followerLocalId: String, + }, + template: 'mail.Follower', +}); + +return Follower; + +}); diff --git a/addons/mail/static/src/components/follower/follower.scss b/addons/mail/static/src/components/follower/follower.scss new file mode 100644 index 00000000000..509a119fb61 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.scss @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Follower { + display: flex; + flex-flow: row; + justify-content: space-between; + padding: map-get($spacers, 0); +} + +.o_Follower_avatar { + width: 24px; + height: 24px; + margin-inline-end: map-get($spacers, 2); +} + +.o_Follower_details { + align-items: center; + display: flex; + flex: 1; + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Follower_avatar { + border-radius: 50%; +} + +.o_Follower_button { + border-radius: 0; + + &:hover { + background: gray('400'); + color: $black; + } +} + +.o_Follower_details { + color: gray('700'); + + &:hover { + background: gray('400'); + color: $black; + } + + &.o-inactive { + opacity: 0.25; + font-style: italic; + } +} diff --git a/addons/mail/static/src/components/follower/follower.xml b/addons/mail/static/src/components/follower/follower.xml new file mode 100644 index 00000000000..26a26ece3f9 --- /dev/null +++ b/addons/mail/static/src/components/follower/follower.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Follower" owl="1"> + <div class="o_Follower"> + <t t-if="follower"> + <a class="o_Follower_details" t-att-class="{ 'o-inactive': !follower.isActive }" href="#" t-on-click="_onClickDetails"> + <img class="o_Follower_avatar" t-attf-src="/web/image/{{ follower.resModel }}/{{ follower.resId }}/image_128" alt="Avatar"/> + <span class="o_Follower_name" t-esc="follower.name"/> + </a> + <t t-if="follower.isEditable"> + <button class="btn btn-icon o_Follower_button o_Follower_editButton" title="Edit subscription" t-on-click="_onClickEdit"> + <i class="fa fa-pencil"/> + </button> + <button class="btn btn-icon o_Follower_button o_Follower_removeButton" title="Remove this follower" t-on-click="_onClickRemove"> + <i class="fa fa-remove"/> + </button> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower/follower_tests.js b/addons/mail/static/src/components/follower/follower_tests.js new file mode 100644 index 00000000000..87e09dda2df --- /dev/null +++ b/addons/mail/static/src/components/follower/follower_tests.js @@ -0,0 +1,354 @@ +odoo.define('mail/static/src/components/follower/follower_tests.js', function (require) { +'use strict'; + +const components = { + Follower: require('mail/static/src/components/follower/follower.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower', {}, function () { +QUnit.module('follower_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowerComponent = async (follower) => { + const FollowerComponent = components.Follower; + FollowerComponent.env = this.env; + this.component = new FollowerComponent(null, { followerLocalId: follower.localId }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.Follower.env; + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(5); + + await this.start({ + async mockRPC(route, args) { + if (route === '/web/image/mail.channel/1/image_128') { + return; + } + return this._super(...arguments); + }, + }); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: false, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + assert.containsOnce( + document.body, + '.o_Follower_avatar', + "should display the avatar of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_name', + "should display the name of the follower" + ); + assert.containsNone( + document.body, + '.o_Follower_button', + "should have no button as follower is not editable" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(6); + + await this.start({ + async mockRPC(route, args) { + if (route === 'web/image/mail.channel/1/image_128') { + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "François Perusse" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + assert.containsOnce( + document.body, + '.o_Follower_avatar', + "should display the avatar of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_name', + "should display the name of the follower" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should have an edit button" + ); + assert.containsOnce( + document.body, + '.o_Follower_removeButton', + "should have a remove button" + ); +}); + +QUnit.test('click on channel follower details', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 1, + "The redirect action should redirect to the right res id (1)" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.channel', + "The redirect action should redirect to the right res model (mail.channel)" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The redirect action should be of type 'ir.actions.act_window'" + ); + }); + + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route === 'web/image/mail.channel/1/image_128') { + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + channel: [['insert', { id: 1, model: 'mail.channel', name: "channel" }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + + document.querySelector('.o_Follower_details').click(); + assert.verifySteps( + ['do_action'], + "clicking on channel should redirect to channel form view" + ); +}); + +QUnit.test('click on partner follower details', async function (assert) { + assert.expect(7); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.res_id, + 3, + "The redirect action should redirect to the right res id (3)" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "The redirect action should redirect to the right res model (res.partner)" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The redirect action should be of type 'ir.actions.act_window'" + ); + }); + + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route === 'web/image/res.partner/3/image_128') { + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.session.partner_id, + name: "François Perusse", + }]], + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_details', + "should display a details part" + ); + + document.querySelector('.o_Follower_details').click(); + assert.verifySteps( + ['do_action'], + "clicking on follower should redirect to partner form view" + ); +}); + +QUnit.test('click on edit follower', async function (assert) { + assert.expect(5); + + await this.start({ + hasDialog: true, + async mockRPC(route, args) { + if (route === 'web/image/res.partner/3/image_128') { + return; + } + if (route.includes('/mail/read_subscription_data')) { + assert.step('fetch_subtypes'); + return [{ + default: true, + followed: true, + internal: false, + id: 1, + name: "Dummy test", + res_model: 'res.partner' + }]; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.session.partner_id, + name: "François Perusse", + }]], + }); + await this.createFollowerComponent(follower); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_editButton', + "should display an edit button" + ); + + await afterNextRender(() => document.querySelector('.o_Follower_editButton').click()); + assert.verifySteps( + ['fetch_subtypes'], + "clicking on edit follower should fetch subtypes" + ); + + assert.containsOnce( + document.body, + '.o_FollowerSubtypeList', + "A dialog allowing to edit follower subtypes should have been created" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js new file mode 100644 index 00000000000..54eac6e284c --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.js @@ -0,0 +1,152 @@ +odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu.js', function (require) { +'use strict'; + +const components = { + Follower: require('mail/static/src/components/follower/follower.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef, useState } = owl.hooks; + +class FollowerListMenu extends Component { + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + /** + * Determine whether the dropdown is open or not. + */ + isDropdownOpen: false, + }); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const followers = thread ? thread.followers : []; + return { + followers: followers.map(follower => follower.__state), + thread: thread ? thread.__state : undefined, + }; + }, { + compareDepth: { + followers: 1, + }, + }); + this._dropdownRef = useRef('dropdown'); + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _hide() { + this.state.isDropdownOpen = false; + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + ev.stopPropagation(); + switch (ev.key) { + case 'Escape': + ev.preventDefault(); + this._hide(); + break; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAddChannels(ev) { + ev.preventDefault(); + this._hide(); + this.thread.promptAddChannelFollower(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAddFollowers(ev) { + ev.preventDefault(); + this._hide(); + this.thread.promptAddPartnerFollower(); + } + + /** + * Close the dropdown when clicking outside of it. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // since dropdown is conditionally shown based on state, dropdownRef can be null + if (this._dropdownRef.el && !this._dropdownRef.el.contains(ev.target)) { + this._hide(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollowersButton(ev) { + this.state.isDropdownOpen = !this.state.isDropdownOpen; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFollower(ev) { + this._hide(); + } +} + +Object.assign(FollowerListMenu, { + components, + defaultProps: { + isDisabled: false, + }, + props: { + isDisabled: Boolean, + threadLocalId: String, + }, + template: 'mail.FollowerListMenu', +}); + +return FollowerListMenu; + +}); diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss new file mode 100644 index 00000000000..2fa52819c59 --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.scss @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerListMenu { + position: relative; +} + +.o_FollowerListMenu_dropdown { + display: flex; + flex-flow: column; + padding-top: map-get($spacers, 0); + padding-bottom: map-get($spacers, 0); + overflow-y: auto; +} + +.o_FollowerListMenu_followers { + display: flex; +} diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml new file mode 100644 index 00000000000..5bf6c129dda --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerListMenu" owl="1"> + <div class="o_FollowerListMenu" t-on-keydown="_onKeydown"> + <div class="o_FollowerListMenu_followers" t-ref="dropdown"> + <button class="o_FollowerListMenu_buttonFollowers btn btn-link" t-att-disabled="props.isDisabled" t-on-click="_onClickFollowersButton" title="Show Followers"> + <i class="fa fa-user"/> + <span class="o_FollowerListMenu_buttonFollowersCount pl-1" t-esc="thread.followers.length"/> + </button> + + <t t-if="state.isDropdownOpen"> + <div class="o_FollowerListMenu_dropdown dropdown-menu dropdown-menu-right" role="menu"> + <a class="o_FollowerListMenu_addFollowersButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddFollowers"> + Add Followers + </a> + <a class="o_FollowerListMenu_addChannelsButton dropdown-item" href="#" role="menuitem" t-on-click="_onClickAddChannels"> + Add Channels + </a> + <t t-if="thread.followers.length > 0"> + <div role="separator" class="dropdown-divider"/> + <t t-foreach="thread.followers" t-as="follower" t-key="follower.localId"> + <Follower + class="o_FollowerMenu_follower dropdown-item" + followerLocalId="follower.localId" + t-on-click="_onClickFollower" + /> + </t> + </t> + </div> + </t> + </div> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js new file mode 100644 index 00000000000..b67c87398ed --- /dev/null +++ b/addons/mail/static/src/components/follower_list_menu/follower_list_menu_tests.js @@ -0,0 +1,465 @@ +odoo.define('mail/static/src/components/follower_list_menu/follower_list_menu_tests.js', function (require) { +'use strict'; + +const components = { + FollowerListMenu: require('mail/static/src/components/follower_list_menu/follower_list_menu.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower_list_menu', {}, function () { +QUnit.module('follower_list_menu_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowerListMenuComponent = async (thread, otherProps = {}) => { + const FollowerListMenuComponent = components.FollowerListMenu; + FollowerListMenuComponent.env = this.env; + this.component = new FollowerListMenuComponent(null, + Object.assign(otherProps, { threadLocalId: thread.localId }) + ); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.FollowerListMenu.env; + }, +}); + +QUnit.test('base rendering not editable', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread, { isDisabled: true }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.ok( + document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled, + "followers button should be disabled" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should not be opened" + ); + + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should still be closed as button is disabled" + ); +}); + +QUnit.test('base rendering editable', async function (assert) { + assert.expect(5); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.notOk( + document.querySelector('.o_FollowerListMenu_buttonFollowers').disabled, + "followers button should not be disabled" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should not be opened" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); +}); + +QUnit.test('click on "add followers" button', async function (assert) { + assert.expect(18); + + const followerIds = []; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('action:open_view'); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + "'The 'add followers' action should contain thread model in context'" + ); + assert.notOk( + payload.action.context.mail_invite_follower_channel_only, + "The 'add followers' action should not be restricted to channels only" + ); + assert.strictEqual( + payload.action.context.default_res_id, + 100, + "The 'add followers' action should contain thread id in context" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.wizard.invite', + "The 'add followers' action should be a wizard invite of mail module" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The 'add followers' action should be of type 'ir.actions.act_window'" + ); + followerIds.push(1); + payload.options.on_close(); + }); + + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + assert.step('rpc:read_follower_ids'); + return [{ + id: 100, + message_follower_ids: followerIds, + }]; + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:read_followers_details'); + return { + followers: [{ + partner_id: 42, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "François Perusse", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "0", + "Followers counter should be equal to 0" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_addFollowersButton', + "followers dropdown should contain a 'Add followers' button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_addFollowersButton').click(); + }); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be closed after click on 'Add followers'" + ); + assert.verifySteps([ + 'action:open_view', + 'rpc:read_follower_ids', + 'rpc:read_followers_details', + ]); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "1", + "Followers counter should now be equal to 1" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerMenu_follower', + "Follower list should be refreshed and contain a follower" + ); + assert.strictEqual( + document.querySelector('.o_Follower_name').textContent, + "François Perusse", + "Follower added in follower list should be the one added" + ); +}); + +QUnit.test('click on "add channels" button', async function (assert) { + assert.expect(18); + + const followerIds = []; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('action:open_view'); + assert.strictEqual( + payload.action.context.default_res_model, + 'res.partner', + "'The 'add channels' action should contain thread model in context'" + ); + assert.ok( + payload.action.context.mail_invite_follower_channel_only, + "The 'add channels' action should be restricted to channels only" + ); + assert.strictEqual( + payload.action.context.default_res_id, + 100, + "The 'add channels' action should contain thread id in context" + ); + assert.strictEqual( + payload.action.res_model, + 'mail.wizard.invite', + "The 'add channels' action should be a wizard invite of mail module" + ); + assert.strictEqual( + payload.action.type, + "ir.actions.act_window", + "The 'add channels' action should be of type 'ir.actions.act_window'" + ); + followerIds.push(1); + payload.options.on_close(); + }); + await this.start({ + env: { bus }, + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('res.partner/read')) { + assert.step('rpc:read_follower_ids'); + return [{ + id: 100, + message_follower_ids: followerIds, + }]; + } else if (route.includes('mail/read_followers')) { + assert.step('rpc:read_followers_details'); + return { + followers: [{ + channel_id: 42, + email: "bla@bla.bla", + id: 1, + is_active: true, + is_editable: true, + name: "Supa channel", + }] + }; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.createFollowerListMenuComponent(thread); + + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "should have followers menu component" + ); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "0", + "Followers counter should be equal to 0" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_buttonFollowers', + "should have followers button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be opened" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu_addChannelsButton', + "followers dropdown should contain a 'Add channels' button" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_addChannelsButton').click(); + }); + assert.containsNone( + document.body, + '.o_FollowerListMenu_dropdown', + "followers dropdown should be closed after click on 'add channels'" + ); + assert.verifySteps([ + 'action:open_view', + 'rpc:read_follower_ids', + 'rpc:read_followers_details', + ]); + assert.strictEqual( + document.querySelector('.o_FollowerListMenu_buttonFollowersCount').textContent, + "1", + "Followers counter should now be equal to 1" + ); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_FollowerMenu_follower', + "Follower list should be refreshed and contain a follower" + ); + assert.strictEqual( + document.querySelector('.o_Follower_name').textContent, + "Supa channel", + "Follower added in follower list should be the one added" + ); +}); + +QUnit.test('click on remove follower', async function (assert) { + assert.expect(6); + + const self = this; + await this.start({ + async mockRPC(route, args) { + if (route.includes('web/image/')) { + return; + } else if (route.includes('message_unsubscribe')) { + assert.step('message_unsubscribe'); + // FIXME "integration" test, should be replaced ... maybe by a tour. + assert.deepEqual( + args.args, + [[100],[self.env.session.partner_id]], + "message unsubscribe should be called with right argument" + ); + return; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + await this.env.models['mail.follower'].create({ + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + partner: [['insert', { + email: "bla@bla.bla", + id: this.env.session.partner_id, + name: "François Perusse", + }]], + }); + await this.createFollowerListMenuComponent(thread); + + await afterNextRender(() => { + document.querySelector('.o_FollowerListMenu_buttonFollowers').click(); + }); + assert.containsOnce( + document.body, + '.o_Follower', + "should have follower component" + ); + assert.containsOnce( + document.body, + '.o_Follower_removeButton', + "should display a remove button" + ); + + await afterNextRender(() => { + document.querySelector('.o_Follower_removeButton').click(); + }); + assert.verifySteps( + ['message_unsubscribe'], + "clicking on remove button should call 'message_unsubscribe' route" + ); + assert.containsNone( + document.body, + '.o_Follower', + "should no more have follower component" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.js b/addons/mail/static/src/components/follower_subtype/follower_subtype.js new file mode 100644 index 00000000000..8ff47ada34f --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.js @@ -0,0 +1,69 @@ +odoo.define('mail/static/src/components/follower_subtype/follower_subtype.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class FollowerSubtype extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const followerSubtype = this.env.models['mail.follower_subtype'].get(props.followerSubtypeLocalId); + return [followerSubtype ? followerSubtype.__state : undefined]; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower|undefined} + */ + get follower() { + return this.env.models['mail.follower'].get(this.props.followerLocalId); + } + + /** + * @returns {mail.follower_subtype} + */ + get followerSubtype() { + return this.env.models['mail.follower_subtype'].get(this.props.followerSubtypeLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on cancel button. + * + * @private + * @param {Event} ev + */ + _onChangeCheckbox(ev) { + if (ev.target.checked) { + this.follower.selectSubtype(this.followerSubtype); + } else { + this.follower.unselectSubtype(this.followerSubtype); + } + } + +} + +Object.assign(FollowerSubtype, { + props: { + followerLocalId: String, + followerSubtypeLocalId: String, + }, + template: 'mail.FollowerSubtype', +}); + +return FollowerSubtype; + +}); diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.scss b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss new file mode 100644 index 00000000000..3be0ad4656e --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerSubtype_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_FollowerSubtype_label { + display: flex; + flex: 1; + flex-direction: row; + align-items: center; + margin-bottom: map-get($spacers, 0); + padding: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_FollowerSubtype_label { + cursor: pointer; + &:hover { + background-color: gray('200'); + } +} diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype.xml b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml new file mode 100644 index 00000000000..b23800099d6 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerSubtype" owl="1"> + <div class="o_FollowerSubtype"> + <label class="o_FollowerSubtype_label"> + <input class="o_FollowerSubtype_checkbox" type="checkbox" t-att-checked="follower.selectedSubtypes.includes(followerSubtype) ? 'checked': ''" t-on-change="_onChangeCheckbox"/> + <t t-esc="followerSubtype.name"/> + </label> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js new file mode 100644 index 00000000000..ac5baa13ec3 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype/follower_subtype_tests.js @@ -0,0 +1,241 @@ +odoo.define('mail/static/src/components/follower_subtype/follower_subtype_tests.js', function (require) { +'use strict'; + +const components = { + FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('follower_subtype', {}, function () { +QUnit.module('follower_subtype_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createFollowerSubtypeComponent = async ({ follower, followerSubtype }) => { + const FollowerSubtypeComponent = components.FollowerSubtype; + FollowerSubtypeComponent.env = this.env; + this.component = new FollowerSubtypeComponent(null, { + followerLocalId: follower.localId, + followerSubtypeLocalId: followerSubtype.localId, + }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.FollowerSubtype.env; + } +}); + +QUnit.test('simplest layout of a followed subtype', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ + selectedSubtypes: [['link', followerSubtype]], + subtypes: [['link', followerSubtype]], + }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_label', + "should have a label" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.strictEqual( + document.querySelector('.o_FollowerSubtype_label').textContent, + "Dummy test", + "should have the name of the subtype as label" + ); + assert.ok( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should be checked as follower subtype is followed" + ); +}); + +QUnit.test('simplest layout of a not followed subtype', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ subtypes: [['link', followerSubtype]] }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_label', + "should have a label" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.strictEqual( + document.querySelector('.o_FollowerSubtype_label').textContent, + "Dummy test", + "should have the name of the subtype as label" + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should not be checked as follower subtype is not followed" + ); +}); + +QUnit.test('toggle follower subtype checkbox', async function (assert) { + assert.expect(5); + + await this.start(); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'res.partner', + }); + const follower = this.env.models['mail.follower'].create({ + channel: [['insert', { + id: 1, + model: 'mail.channel', + name: "François Perusse", + }]], + followedThread: [['link', thread]], + id: 2, + isActive: true, + isEditable: true, + }); + const followerSubtype = this.env.models['mail.follower_subtype'].create({ + id: 1, + isDefault: true, + isInternal: false, + name: "Dummy test", + resModel: 'res.partner' + }); + follower.update({ subtypes: [['link', followerSubtype]] }); + await this.createFollowerSubtypeComponent({ + follower, + followerSubtype, + }); + assert.containsOnce( + document.body, + '.o_FollowerSubtype', + "should have follower subtype component" + ); + assert.containsOnce( + document.body, + '.o_FollowerSubtype_checkbox', + "should have a checkbox" + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should not be checked as follower subtype is not followed" + ); + + await afterNextRender(() => + document.querySelector('.o_FollowerSubtype_checkbox').click() + ); + assert.ok( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should now be checked" + ); + + await afterNextRender(() => + document.querySelector('.o_FollowerSubtype_checkbox').click() + ); + assert.notOk( + document.querySelector('.o_FollowerSubtype_checkbox').checked, + "checkbox should be no more checked" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js new file mode 100644 index 00000000000..851382861ea --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.js @@ -0,0 +1,87 @@ +odoo.define('mail/static/src/components/follower_subtype_list/follower_subtype_list.js', function (require) { +'use strict'; + +const components = { + FollowerSubtype: require('mail/static/src/components/follower_subtype/follower_subtype.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component, QWeb } = owl; + +class FollowerSubtypeList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const followerSubtypeList = this.env.models['mail.follower_subtype_list'].get(props.localId); + const follower = followerSubtypeList + ? followerSubtypeList.follower + : undefined; + const followerSubtypes = follower ? follower.subtypes : []; + return { + follower: follower ? follower.__state : undefined, + followerSubtypeList: followerSubtypeList + ? followerSubtypeList.__state + : undefined, + followerSubtypes: followerSubtypes.map(subtype => subtype.__state), + }; + }, { + compareDepth: { + followerSubtypes: 1, + }, + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.follower_subtype_list} + */ + get followerSubtypeList() { + return this.env.models['mail.follower_subtype_list'].get(this.props.localId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when clicking on cancel button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickCancel(ev) { + this.followerSubtypeList.follower.closeSubtypes(); + } + + /** + * Called when clicking on apply button. + * + * @private + * @param {MouseEvent} ev + */ + _onClickApply(ev) { + this.followerSubtypeList.follower.updateSubtypes(); + } + +} + +Object.assign(FollowerSubtypeList, { + components, + props: { + localId: String, + }, + template: 'mail.FollowerSubtypeList', +}); + +QWeb.registerComponent('FollowerSubtypeList', FollowerSubtypeList); + +return FollowerSubtypeList; + +}); diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss new file mode 100644 index 00000000000..82aef9fc974 --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.scss @@ -0,0 +1,8 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FollowerSubtypeList_subtypes { + display: flex; + flex-flow: column; +} diff --git a/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml new file mode 100644 index 00000000000..9ab73328a9e --- /dev/null +++ b/addons/mail/static/src/components/follower_subtype_list/follower_subtype_list.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.FollowerSubtypeList" owl="1"> + <div class="o_FollowerSubtypeList modal-dialog"> + <t t-if="followerSubtypeList"> + <div class="modal-content"> + <header class="modal-header"> + <h4 class="modal-title"> + Edit Subscription of <t t-esc="followerSubtypeList.follower.name"/> + </h4> + <i class="close fa fa-times" aria-label="Close" t-on-click="_onClickCancel"/> + </header> + <main class="modal-body"> + <div class="o_FollowerSubtypeList_subtypes"> + <t t-foreach="followerSubtypeList.follower.subtypes" t-as="subtype" t-key="subtype.id"> + <FollowerSubtype + class="o_FollowerSubtypeList_subtype" + followerLocalId="followerSubtypeList.follower.localId" + followerSubtypeLocalId="subtype.localId" + /> + </t> + </div> + </main> + <div class="modal-footer"> + <button class="o-apply btn btn-primary" t-on-click="_onClickApply"> + Apply + </button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel"> + Cancel + </button> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/mail_template/mail_template.js b/addons/mail/static/src/components/mail_template/mail_template.js new file mode 100644 index 00000000000..45dc8c344c6 --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.js @@ -0,0 +1,79 @@ +odoo.define('mail/static/src/components/mail_template/mail_template.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MailTemplate extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const activity = this.env.models['mail.activity'].get(props.activityLocalId); + const mailTemplate = this.env.models['mail.mail_template'].get(props.mailTemplateLocalId); + return { + activity: activity ? activity.__state : undefined, + mailTemplate: mailTemplate ? mailTemplate.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.activity} + */ + get activity() { + return this.env.models['mail.activity'].get(this.props.activityLocalId); + } + + /** + * @returns {mail.mail_template} + */ + get mailTemplate() { + return this.env.models['mail.mail_template'].get(this.props.mailTemplateLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickPreview(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.mailTemplate.preview(this.activity); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickSend(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.mailTemplate.send(this.activity); + } + +} + +Object.assign(MailTemplate, { + props: { + activityLocalId: String, + mailTemplateLocalId: String, + }, + template: 'mail.MailTemplate', +}); + +return MailTemplate; + +}); diff --git a/addons/mail/static/src/components/mail_template/mail_template.scss b/addons/mail/static/src/components/mail_template/mail_template.scss new file mode 100644 index 00000000000..7800ab62bc5 --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.scss @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MailTemplate { + display: flex; + flex: 0 0 auto; + align-items: center; +} + +.o_MailTemplate_button { + padding-top: map-get($spacers, 0); + padding-bottom: map-get($spacers, 0); +} + +.o_MailTemplate_name { + margin-inline-start: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MailTemplate_text { + color: gray('500'); + font-style: italic; +} diff --git a/addons/mail/static/src/components/mail_template/mail_template.xml b/addons/mail/static/src/components/mail_template/mail_template.xml new file mode 100644 index 00000000000..48f7c050fcc --- /dev/null +++ b/addons/mail/static/src/components/mail_template/mail_template.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MailTemplate" owl="1"> + <div class="o_MailTemplate"> + <t t-if="mailTemplate"> + <i class="fa fa-envelope-o" title="Mail" role="img"/> + <span class="o_MailTemplate_name" t-esc="mailTemplate.name"/> + <span>:</span> + <button + class="o_MailTemplate_button o_MailTemplate_preview btn btn-link" + t-att-data-mail-template-id="mailTemplate.id" + t-on-click="_onClickPreview" + > + Preview + </button> + <span class="o_MailTemplate_text">or</span> + <button + class="o_MailTemplate_button o_MailTemplate_send btn btn-link" + t-att-data-mail-template-id="mailTemplate.id" + t-on-click="_onClickSend" + > + Send Now + </button> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js new file mode 100644 index 00000000000..f4940a1e566 --- /dev/null +++ b/addons/mail/static/src/components/message/message.js @@ -0,0 +1,621 @@ +odoo.define('mail/static/src/components/message/message.js', function (require) { +'use strict'; + +const components = { + AttachmentList: require('mail/static/src/components/attachment_list/attachment_list.js'), + MessageSeenIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), + ModerationBanDialog: require('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js'), + ModerationDiscardDialog: require('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js'), + ModerationRejectDialog: require('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js'), + NotificationPopover: require('mail/static/src/components/notification_popover/notification_popover.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const { timeFromNow } = require('mail.utils'); + +const { _lt } = require('web.core'); +const { getLangDatetimeFormat } = require('web.time'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +const READ_MORE = _lt("read more"); +const READ_LESS = _lt("read less"); + +class Message extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + // Determine if the moderation ban dialog is displayed. + hasModerationBanDialog: false, + // Determine if the moderation discard dialog is displayed. + hasModerationDiscardDialog: false, + // Determine if the moderation reject dialog is displayed. + hasModerationRejectDialog: false, + /** + * Determine whether the message is clicked. When message is in + * clicked state, it keeps displaying the commands. + */ + isClicked: false, + /** + * Time elapsed from message datetime to current datetime. + */ + timeElapsed: null, + }); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const partnerRoot = this.env.messaging.partnerRoot; + const originThread = message ? message.originThread : undefined; + const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId); + const thread = threadViewer ? threadViewer.thread : undefined; + const threadStringifiedDomain = threadViewer + ? threadViewer.stringifiedDomain + : undefined; + return { + attachments: message + ? message.attachments.map(attachment => attachment.__state) + : undefined, + author: author ? author.__state : undefined, + hasMessageCheckbox: message ? message.hasCheckbox : false, + isDeviceMobile: this.env.messaging.device.isMobile, + isMessageChecked: message && threadViewer + ? message.isChecked(thread, threadStringifiedDomain) + : false, + message: message ? message.__state : undefined, + notifications: message ? message.notifications.map(notif => notif.__state) : [], + originThread: originThread ? originThread.__state : undefined, + partnerRoot: partnerRoot ? partnerRoot.__state : undefined, + thread: thread ? thread.__state : undefined, + threadViewer: threadViewer ? threadViewer.__state : undefined, + }; + }, { + compareDepth: { + notifications: 1, + }, + }); + /** + * Reference to the content of the message. + */ + this._contentRef = useRef('content'); + /** + * To get checkbox state. + */ + this._checkboxRef = useRef('checkbox'); + /** + * Id of setInterval used to auto-update time elapsed of message at + * regular time. + */ + this._intervalId = undefined; + this._constructor(); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + this._insertReadMoreLess($(this._contentRef.el)); + this._update(); + } + + patched() { + this._update(); + } + + willUnmount() { + clearInterval(this._intervalId); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get avatar() { + if ( + this.message.author && + this.message.author === this.env.messaging.partnerRoot + ) { + return '/mail/static/src/img/odoobot.png'; + } else if (this.message.author) { + // TODO FIXME for public user this might not be accessible. task-2223236 + // we should probably use the correspondig attachment id + access token + // or create a dedicated route to get message image, checking the access right of the message + return `/web/image/res.partner/${this.message.author.id}/image_128`; + } else if (this.message.message_type === 'email') { + return '/mail/static/src/img/email_icon.png'; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get the date time of the message at current user locale time. + * + * @returns {string} + */ + get datetime() { + return this.message.date.format(getLangDatetimeFormat()); + } + + /** + * Determine whether author redirect feature is enabled on message. + * Click on message author should redirect to author. + * + * @returns {boolean} + */ + get hasAuthorRedirect() { + if (!this.props.hasAuthorRedirect) { + return false; + } + if (!this.message.author) { + return false; + } + if (this.message.author === this.env.messaging.currentPartner) { + return false; + } + return true; + } + + /** + * Determine whether the message origin thread is the same as the context + * of displaying this message. In other word, if the enclosing thread + * component of this message component is linked to the origin thread of + * this message, then the origin is the same. + * + * @returns {boolean} + */ + get hasDifferentOriginThread() { + if (!this.threadViewer) { + return false; + } + if (!this.message.originThread) { + return false; + } + return this.message.originThread !== this.threadViewer.thread; + } + + /** + * @returns {mail.attachment[]} + */ + get imageAttachments() { + return this.message.attachments.filter(attachment => attachment.fileType === 'image'); + } + + /** + * Tell whether the bottom of this message is visible or not. + * + * @param {Object} param0 + * @param {integer} [offset=0] + * @returns {boolean} + */ + isBottomVisible({ offset=0 } = {}) { + if (!this.el) { + return false; + } + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // bottom with (double) 10px offset + return ( + elRect.bottom < parentRect.bottom + offset && + parentRect.top < elRect.bottom + offset + ); + } + + /** + * Tell whether the message is partially visible on browser window or not. + * + * @returns {boolean} + */ + isPartiallyVisible() { + const elRect = this.el.getBoundingClientRect(); + if (!this.el.parentNode) { + return false; + } + const parentRect = this.el.parentNode.getBoundingClientRect(); + // intersection with 5px offset + return ( + elRect.top < parentRect.bottom + 5 && + parentRect.top < elRect.bottom + 5 + ); + } + + /** + * Determine whether the message is starred. + * + * @returns {boolean} + */ + get isStarred() { + return this.message.allThreads.includes(this.env.messaging.starred); + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.attachment[]} + */ + get nonImageAttachments() { + return this.message.attachments.filter(attachment => attachment.fileType !== 'image'); + } + + /** + * Make this message viewable in its enclosing scroll environment (usually + * message list). + * + * @param {Object} [param0={}] + * @param {string} [param0.behavior='auto'] + * @param {string} [param0.block='end'] + * @returns {Promise} + */ + async scrollIntoView({ behavior='auto', block='end' }={}) { + this.el.scrollIntoView({ + behavior, + block, + inline: 'nearest', + }); + if (behavior === 'smooth') { + return new Promise(resolve => setTimeout(resolve, 500)); + } else { + return Promise.resolve(); + } + } + + /** + * Get the shorttime format of the message date. + * + * @returns {string} + */ + get shortTime() { + return this.message.date.format('hh:mm'); + } + + /** + * @returns {mail.thread_viewer} + */ + get threadViewer() { + return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId); + } + + /** + * @returns {Object} + */ + get trackingValues() { + return this.message.tracking_value_ids.map(trackingValue => { + const value = Object.assign({}, trackingValue); + value.changed_field = _.str.sprintf(this.env._t("%s:"), value.changed_field); + if (value.field_type === 'datetime') { + if (value.old_value) { + value.old_value = + moment.utc(value.old_value).local().format('LLL'); + } + if (value.new_value) { + value.new_value = + moment.utc(value.new_value).local().format('LLL'); + } + } else if (value.field_type === 'date') { + if (value.old_value) { + value.old_value = + moment(value.old_value).local().format('LL'); + } + if (value.new_value) { + value.new_value = + moment(value.new_value).local().format('LL'); + } + } + return value; + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Modifies the message to add the 'read more/read less' functionality + * All element nodes with 'data-o-mail-quote' attribute are concerned. + * All text nodes after a ``#stopSpelling`` element are concerned. + * Those text nodes need to be wrapped in a span (toggle functionality). + * All consecutive elements are joined in one 'read more/read less'. + * + * @private + * @param {jQuery} $element + */ + _insertReadMoreLess($element) { + const groups = []; + let readMoreNodes; + + // nodeType 1: element_node + // nodeType 3: text_node + const $children = $element.contents() + .filter((index, content) => + content.nodeType === 1 || (content.nodeType === 3 && content.nodeValue.trim()) + ); + + for (const child of $children) { + let $child = $(child); + + // Hide Text nodes if "stopSpelling" + if ( + child.nodeType === 3 && + $child.prevAll('[id*="stopSpelling"]').length > 0 + ) { + // Convert Text nodes to Element nodes + $child = $('<span>', { + text: child.textContent, + 'data-o-mail-quote': '1', + }); + child.parentNode.replaceChild($child[0], child); + } + + // Create array for each 'read more' with nodes to toggle + if ( + $child.attr('data-o-mail-quote') || + ( + $child.get(0).nodeName === 'BR' && + $child.prev('[data-o-mail-quote="1"]').length > 0 + ) + ) { + if (!readMoreNodes) { + readMoreNodes = []; + groups.push(readMoreNodes); + } + $child.hide(); + readMoreNodes.push($child); + } else { + readMoreNodes = undefined; + this._insertReadMoreLess($child); + } + } + + for (const group of groups) { + // Insert link just before the first node + const $readMoreLess = $('<a>', { + class: 'o_Message_readMore', + href: '#', + text: READ_MORE, + }).insertBefore(group[0]); + + // Toggle All next nodes + let isReadMore = true; + $readMoreLess.click(e => { + e.preventDefault(); + isReadMore = !isReadMore; + for (const $child of group) { + $child.hide(); + $child.toggle(!isReadMore); + } + $readMoreLess.text(isReadMore ? READ_MORE : READ_LESS); + }); + } + } + + /** + * @private + */ + _update() { + if (!this.state.timeElapsed) { + this.state.timeElapsed = timeFromNow(this.message.date); + } + clearInterval(this._intervalId); + this._intervalId = setInterval(() => { + this.state.timeElapsed = timeFromNow(this.message.date); + }, 60 * 1000); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onChangeCheckbox() { + this.message.toggleCheck(this.threadViewer.thread, this.threadViewer.stringifiedDomain); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + if (ev.target.closest('.o_mention')) { + this.env.messaging.redirect({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + ev.preventDefault(); + return; + } + if (ev.target.closest('.o_mail_redirect')) { + this.env.messaging.redirect({ + id: Number(ev.target.dataset.oeId), + model: ev.target.dataset.oeModel, + }); + ev.preventDefault(); + return; + } + ev.stopPropagation(); + this.state.isClicked = !this.state.isClicked; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickAuthor(ev) { + if (!this.hasAuthorRedirect) { + return; + } + if (!this.message.author) { + return; + } + this.env.messaging.redirect({ + id: this.message.author.id, + model: this.message.author.model, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFailure(ev) { + this.message.openResendAction(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAccept(ev) { + ev.preventDefault(); + this.message.moderate('accept'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationAllow(ev) { + ev.preventDefault(); + this.message.moderate('allow'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationBan(ev) { + ev.preventDefault(); + this.state.hasModerationBanDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationDiscard(ev) { + ev.preventDefault(); + this.state.hasModerationDiscardDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickModerationReject(ev) { + ev.preventDefault(); + this.state.hasModerationRejectDialog = true; + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickOriginThread(ev) { + ev.preventDefault(); + this.env.messaging.redirect({ + id: this.message.originThread.id, + model: this.message.originThread.model, + }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickStar(ev) { + ev.stopPropagation(); + this.message.toggleStar(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + ev.stopPropagation(); + this.message.markAsRead(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickReply(ev) { + ev.stopPropagation(); + this.message.replyTo(); + } + + /** + * @private + */ + _onDialogClosedModerationBan() { + this.state.hasModerationBanDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationDiscard() { + this.state.hasModerationDiscardDialog = false; + } + + /** + * @private + */ + _onDialogClosedModerationReject() { + this.state.hasModerationRejectDialog = false; + } + +} + +Object.assign(Message, { + components, + defaultProps: { + hasAuthorRedirect: false, + hasCheckbox: false, + hasMarkAsReadIcon: false, + hasReplyIcon: false, + isSelected: false, + isSquashed: false, + }, + props: { + attachmentsDetailsMode: { + type: String, + optional: true, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasAuthorRedirect: Boolean, + hasCheckbox: Boolean, + hasMarkAsReadIcon: Boolean, + hasReplyIcon: Boolean, + isSelected: Boolean, + isSquashed: Boolean, + messageLocalId: String, + threadViewerLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.Message', +}); + +return Message; + +}); diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss new file mode 100644 index 00000000000..fa7b7474558 --- /dev/null +++ b/addons/mail/static/src/components/message/message.scss @@ -0,0 +1,354 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_Message { + display: flex; + flex: 0 0 auto; + padding: map-get($spacers, 2); +} + +.o_Message_authorAvatar { + height: 100%; + width: 100%; + object-fit: cover; +} + +.o_Message_authorAvatarContainer { + position: relative; + height: 36px; + width: 36px; +} + +.o_Message_authorName { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_checkbox { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_commandStar { + font-size: 1.3em; +} + +.o_Message_Composer { + flex: 1 1 auto; +} + +.o_Message_commands { + display: flex; + align-items: center; +} + +.o_Message_content { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + word-wrap: break-word; + word-break: break-word; + + > p:last-of-type { + margin-bottom: 0; + } +} + +.o_Message_core { + margin-inline-end: map-get($spacers, 3); + overflow: auto; +} + +.o_Message_footer { + display: flex; + flex-direction: column; +} + +.o_Message_header { + display: flex; + flex-flow: row wrap; + align-items: baseline; +} + +.o_Message_headerCommands { + margin-inline-end: map-get($spacers, 2); + align-self: center; + + .o_Message_headerCommand { + padding-left: map-get($spacers, 2); + padding-right: map-get($spacers, 2); + + &.o-mobile { + padding-left: map-get($spacers, 3); + padding-right: map-get($spacers, 3); + + &:first-child { + padding-left: map-get($spacers, 2); + } + + &:last-child { + padding-right: map-get($spacers, 2); + } + } + } +} + +.o_Message_headerDate { + margin-inline-end: map-get($spacers, 2); + font-size: 0.8em; +} + +.o_Message_moderationAction { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationPending { + margin-inline-end: map-get($spacers, 3); +} + +.o_Message_moderationSubHeader { + display: flex; + flex-flow: row wrap; + align-items: center; +} + +.o_Message_originThread { + margin-inline-end: map-get($spacers, 2); +} + +.o_Message_partnerImStatusIcon { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +.o_Message_readMore { + display: block; +} + +.o_Message_sidebar { + flex: 0 0 50px; + max-width: 50px; + display: flex; + margin-inline-end: map-get($spacers, 2); + justify-content: center; + + &.o-message-squashed { + align-items: flex-start; + } +} + +.o_Message_sidebarItem { + margin-left: map-get($spacers, 1); + margin-right: map-get($spacers, 1); + + &.o-message-squashed { + display: flex; + } +} + +.o_Message_trackingValues { + margin-top: map-get($spacers, 3); +} + +.o_Message_trackingValue { + display: flex; + align-items: center; +} + +.o_Message_trackingValueItem { + margin-inline-end: map-get($spacers, 1); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_Message { + background-color: white; + + &:hover, &.o-clicked { + + .o_Message_command { + display: flex; + } + + .o_Message_commands { + display: flex; + } + + .o_Message_sidebarItem.o-message-squashed { + display: flex; + } + + .o_Message_seenIndicator.o-message-squashed { + display: none; + } + } + + .o_Message_partnerImStatusIcon { + color: white; + } + + &.o-not-discussion { + background-color: lighten(gray('300'), 5%); + border-bottom: 1px solid darken(gray('300'), 5%); + + .o_Message_partnerImStatusIcon { + color: lighten(gray('300'), 5%); + } + + &.o-selected { + border-bottom: 1px solid darken(gray('400'), 5%); + } + } + + &.o-selected { + background-color: gray('400'); + + .o_Message_partnerImStatusIcon { + color: gray('400'); + } + } + + &.o-starred { + + .o_Message_commandStar { + display: flex; + } + + .o_Message_commands { + display: flex; + } + } +} + +.o_Message_authorName { + font-weight: bold; +} + +.o_Message_authorRedirect { + cursor: pointer; +} + +.o_Message_command { + cursor: pointer; + color: gray('400'); + + &:not(.o-mobile) { + display: none; + + &:hover { + filter: brightness(0.8); + } + } + + &.o-mobile { + filter: brightness(0.8); + + &:hover { + filter: brightness(0.75); + } + } + + &.o-message-selected { + color: gray('500'); + } +} + +.o_Message_commandStar { + + &.o-message-starred { + color: gold; + + &:hover { + filter: brightness(0.9); + } + } +} + +.o_Message_content .o_mention { + color: $o-brand-primary; + cursor: pointer; + + &:hover { + color: darken($o-brand-primary, 15%); + } +} + +.o_Message_date { + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_headerCommands:not(.o-mobile) { + display: none; +} + +.o_Message_originThread { + font-size: 0.8em; + color: gray('500'); + + &.o-message-selected { + color: gray('600'); + } +} + +.o_Message_originThreadLink { + font-size: 1.25em; // original size +} + +.o_Message_partnerImStatusIcon:not(.o_Message_partnerImStatusIcon-mobile) { + font-size: x-small; +} + +.o_Message_moderationAction { + font-weight: bold; + font-style: italic; + + &.o-accept, + &.o-allow { + color: $o-mail-moderation-accept-color; + @include hover-focus { + color: darken($o-mail-moderation-accept-color, $emphasized-link-hover-darken-percentage); + } + } + + &.o-ban, + &.o-discard, + &.o-reject { + color: $o-mail-moderation-reject-color; + @include hover-focus { + color: darken($o-mail-moderation-reject-color, $emphasized-link-hover-darken-percentage); + } + } +} + +.o_Message_moderationPending { + font-style: italic; + + &.o-author { + color: theme-color('danger'); + font-weight: bold; + } +} + +.o_Message_notificationIconContainer { + color: gray('600'); + cursor: pointer; + + &.o-error { + color: $red; + } +} + +.o_Message_sidebarCommands { + display: none; +} + +.o_Message_sidebarItem.o-message-squashed { + display: none; +} diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml new file mode 100644 index 00000000000..b55e7a5ccf6 --- /dev/null +++ b/addons/mail/static/src/components/message/message.xml @@ -0,0 +1,220 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.Message" owl="1"> + <div class="o_Message" + t-att-class="{ + 'o-clicked': state.isClicked, + 'o-discussion': message and (message.is_discussion or message.is_notification), + 'o-mobile': env.messaging.device.isMobile, + 'o-not-discussion': message and !(message.is_discussion or message.is_notification), + 'o-notification': message and message.message_type === 'notification', + 'o-selected': props.isSelected, + 'o-squashed': props.isSquashed, + 'o-starred': message and isStarred, + }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId" + > + <t t-if="message" name="rootCondition"> + <div class="o_Message_sidebar" t-att-class="{ 'o-message-squashed': props.isSquashed }"> + <t t-if="!props.isSquashed"> + <div class="o_Message_authorAvatarContainer o_Message_sidebarItem"> + <img class="o_Message_authorAvatar rounded-circle" t-att-class="{ o_Message_authorRedirect: hasAuthorRedirect, o_redirect: hasAuthorRedirect }" t-att-src="avatar" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''" alt=""/> + <t t-if="message.author and message.author.im_status"> + <PartnerImStatusIcon + class="o_Message_partnerImStatusIcon" + t-att-class="{ + 'o-message-not-discussion': !(message.is_discussion or message.is_notification), + 'o-message-selected': props.isSelected, + 'o_Message_partnerImStatusIcon-mobile': env.messaging.device.isMobile, + }" + partnerLocalId="message.author.localId" + /> + </t> + </div> + </t> + <t t-else=""> + <div class="o_Message_date o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-esc="shortTime"/> + </div> + <div class="o_Message_commands o_Message_sidebarCommands o_Message_sidebarItem o-message-squashed" t-att-class="{ 'o-message-selected': props.isSelected, 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="message.message_type !== 'notification'"> + <div class="o_Message_command o_Message_commandStar fa" + t-att-class="{ + 'fa-star': isStarred, + 'fa-star-o': !isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" + /> + </t> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread"> + <MessageSeenIndicator class="o_Message_seenIndicator o-message-squashed" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/> + </t> + </t> + </div> + <div class="o_Message_core"> + <t t-if="!props.isSquashed"> + <div class="o_Message_header"> + <t t-if="message.author"> + <div class="o_Message_authorName" t-att-class="hasAuthorRedirect ? 'o_Message_authorRedirect o_redirect' : ''" t-on-click="_onClickAuthor" t-att-title="hasAuthorRedirect ? 'Open chat' : ''"> + <t t-esc="message.author.nameOrDisplayName"/> + </div> + </t> + <t t-elif="message.email_from"> + <a class="o_Message_authorName" t-attf-href="mailto:{{ message.email_from }}?subject=Re: {{ message.subject ? message.subject : '' }}"> + <t t-esc="message.email_from"/> + </a> + </t> + <t t-else=""> + <div class="o_Message_authorName"> + Anonymous + </div> + </t> + <div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime"> + - <t t-esc="state.timeElapsed"/> + </div> + <t t-if="message.isCurrentPartnerAuthor and threadViewer and threadViewer.thread"> + <MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadViewer.thread.localId"/> + </t> + <t t-if="hasDifferentOriginThread"> + <div class="o_Message_originThread" t-att-class="{ 'o-message-selected': props.isSelected }"> + <t t-if="message.originThread.model === 'mail.channel'"> + (from <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread">#<t t-esc="message.originThread.name"/></a>) + </t> + <t t-else=""> + on <a class="o_Message_originThreadLink" href="#" t-on-click="_onClickOriginThread"><t t-esc="message.originThread.name"/></a> + </t> + </div> + </t> + <t t-if="message.moderation_status === 'pending_moderation' and !message.isModeratedByCurrentPartner"> + <span class="o_Message_moderationPending o-author" title="Your message is pending moderation.">Pending moderation</span> + </t> + <t t-if="message.notifications.length > 0"> + <t t-if="message.failureNotifications.length > 0"> + <span class="o_Message_notificationIconContainer o-error" t-on-click="_onClickFailure"> + <i name="failureIcon" class="o_Message_notificationIcon fa fa-envelope"/> + </span> + </t> + <t t-else=""> + <Popover> + <span class="o_Message_notificationIconContainer"> + <i name="notificationIcon" class="o_Message_notificationIcon fa fa-envelope-o"/> + </span> + <t t-set="opened"> + <NotificationPopover + notificationLocalIds="message.notifications.map(notification => notification.localId)" + /> + </t> + </Popover> + </t> + </t> + <div class="o_Message_commands o_Message_headerCommands" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!message.isTemporary and ((message.message_type !== 'notification' and message.originThread and message.originThread.model === 'mail.channel') or !message.isTransient) and message.moderation_status !== 'pending_moderation'"> + <span class="o_Message_command o_Message_commandStar o_Message_headerCommand fa" + t-att-class="{ + 'fa-star': isStarred, + 'fa-star-o': !isStarred, + 'o-message-selected': props.isSelected, + 'o-message-starred': isStarred, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickStar" title="Mark as Todo" + /> + </t> + <t t-if="props.hasReplyIcon"> + <span class="o_Message_command o_Message_commandReply o_Message_headerCommand fa fa-reply" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickReply" title="Reply" + /> + </t> + <t t-if="props.hasMarkAsReadIcon"> + <span class="o_Message_command o_Message_commandMarkAsRead o_Message_headerCommand fa fa-check" + t-att-class="{ + 'o-message-selected': props.isSelected, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickMarkAsRead" title="Mark as Read" + /> + </t> + </div> + </div> + <t t-if="message.isModeratedByCurrentPartner"> + <div class="o_Message_moderationSubHeader"> + <t t-if="threadViewer and props.hasCheckbox and message.hasCheckbox"> + <input class="o_Message_checkbox" type="checkbox" t-att-checked="message.isChecked(threadViewer.thread, threadViewer.stringifiedDomain) ? 'checked': ''" t-on-change="_onChangeCheckbox" t-ref="checkbox"/> + </t> + <span class="o_Message_moderationPending">Pending moderation:</span> + <a class="o_Message_moderationAction o-accept" href="#" title="Accept" t-on-click="_onClickModerationAccept">Accept</a> + <a class="o_Message_moderationAction o-reject" href="#" title="Remove message with explanation" t-on-click="_onClickModerationReject">Reject</a> + <a class="o_Message_moderationAction o-discard" href="#" title="Remove message without explanation" t-on-click="_onClickModerationDiscard">Discard</a> + <a class="o_Message_moderationAction o-allow" href="#" title="Add this email address to white list of people" t-on-click="_onClickModerationAllow">Always Allow</a> + <a class="o_Message_moderationAction o-ban" href="#" title="Ban this email address" t-on-click="_onClickModerationBan">Ban</a> + </div> + </t> + </t> + <div class="o_Message_content" t-ref="content"> + <t t-raw="message.prettyBody"/> + <t t-if="message.subtype_description"> + <p t-esc="message.subtype_description"/> + </t> + <t t-if="trackingValues.length > 0"> + <ul class="o_Message_trackingValues"> + <t t-foreach="trackingValues" t-as="value" t-key="value.id"> + <li> + <div class="o_Message_trackingValue"> + <div class="o_Message_trackingValueItem" t-esc="value.changed_field"/> + <t t-if="value.old_value"> + <div class="o_Message_trackingValueItem" t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> + <t t-if="value.old_value !== value.new_value"> + <div class="o_Message_trackingValueItem fa fa-long-arrow-right" title="Changed" role="img"/> + </t> + </t> + <t t-if="value.old_value !== value.new_value"> + <div class="o_Message_trackingValueItem" t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/> + </t> + </div> + </li> + </t> + </ul> + </t> + </div> + <t t-if="message.attachments and message.attachments.length > 0"> + <div class="o_Message_footer"> + <!-- TODO XDU This does not allow to preview all attachments at once, find somethind else --> + <t t-if="imageAttachments and imageAttachments.length > 0"> + <AttachmentList + class="o_Message_attachmentList" + areAttachmentsDownloadable="true" + areAttachmentsEditable="true" + attachmentLocalIds="imageAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="props.attachmentsDetailsMode" + /> + </t> + <t t-if="nonImageAttachments and nonImageAttachments.length > 0"> + <AttachmentList + class="o_Message_attachmentList" + areAttachmentsDownloadable="true" + areAttachmentsEditable="true" + attachmentLocalIds="nonImageAttachments.map(attachment => attachment.localId)" + attachmentsDetailsMode="props.attachmentsDetailsMode" + /> + </t> + </div> + </t> + </div> + <t t-if="state.hasModerationBanDialog"> + <ModerationBanDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationBan"/> + </t> + <t t-if="state.hasModerationDiscardDialog"> + <ModerationDiscardDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationDiscard"/> + </t> + <t t-if="state.hasModerationRejectDialog"> + <ModerationRejectDialog messageLocalIds="[message.localId]" t-on-dialog-closed="_onDialogClosedModerationReject"/> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message/message_tests.js b/addons/mail/static/src/components/message/message_tests.js new file mode 100644 index 00000000000..b09d0a181bd --- /dev/null +++ b/addons/mail/static/src/components/message/message_tests.js @@ -0,0 +1,692 @@ +odoo.define('mail/static/src/components/message/message_tests.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createMessageComponent = async (message, otherProps) => { + const MessageComponent = components.Message; + MessageComponent.env = this.env; + this.component = new MessageComponent(null, Object.assign({ + messageLocalId: message.localId, + }, otherProps)); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.Message.env; + }, +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(12); + + await this.start(); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + assert.strictEqual( + document.querySelectorAll('.o_Message').length, + 1, + "should display a message component" + ); + const messageEl = document.querySelector('.o_Message'); + assert.strictEqual( + messageEl.dataset.messageLocalId, + this.env.models['mail.message'].find(message => message.id === 100).localId, + "message component should be linked to message store model" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar`).length, + 1, + "message should have a sidebar" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_sidebar .o_Message_authorAvatar`).length, + 1, + "message should have author avatar in the sidebar" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).tagName, + 'IMG', + "message author avatar should be an image" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorAvatar`).dataset.src, + '/web/image/res.partner/7/image_128', + "message author avatar should GET image of the related partner" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_authorName`).length, + 1, + "message should display author name" + ); + assert.strictEqual( + messageEl.querySelector(`:scope .o_Message_authorName`).textContent, + "Demo User", + "message should display correct author name" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_date`).length, + 1, + "message should display date" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_commands`).length, + 1, + "message should display list of commands" + ); + assert.strictEqual( + messageEl.querySelectorAll(`:scope .o_Message_content`).length, + 1, + "message should display the content" + ); + assert.strictEqual(messageEl.querySelector(`:scope .o_Message_content`).innerHTML, + "<p>Test</p>", + "message should display the correct content" + ); +}); + +QUnit.test('delete attachment linked to message', async function (assert) { + assert.expect(1); + + await this.start(); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "BLAH.jpg", + id: 10, + name: "BLAH", + }]], + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + }); + await this.createMessageComponent(message); + await afterNextRender(() => document.querySelector('.o_Attachment_asideItemUnlink').click()); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 10)); +}); + +QUnit.test('moderation: moderated channel with pending moderation message (author)', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 1, display_name: "Admin" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message); + + assert.strictEqual( + document.querySelectorAll(`.o_Message_moderationPending.o-author`).length, + 1, + "should have the message pending moderation" + ); +}); + +QUnit.test('moderation: moderated channel with pending moderation message (moderator)', async function (assert) { + assert.expect(9); + + Object.assign(this.data.initMessaging, { + moderation_channel_ids: [20], + }); + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 20, + model: 'mail.channel', + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: 7, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + moderation_status: 'pending_moderation', + originThread: [['link', thread]], + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message); + const messageEl = document.querySelector('.o_Message'); + assert.ok(messageEl, "should display a message"); + assert.containsOnce(messageEl, `.o_Message_moderationSubHeader`, + "should have the message pending moderation" + ); + assert.containsNone(messageEl, `.o_Message_checkbox`, + "should not have the moderation checkbox by default" + ); + assert.containsN(messageEl, '.o_Message_moderationAction', 5, + "there should be 5 contextual moderation decisions next to the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-accept', + "there should be a contextual moderation decision to accept the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-reject', + "there should be a contextual moderation decision to reject the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-discard', + "there should be a contextual moderation decision to discard the message" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-allow', + "there should be a contextual moderation decision to allow the user of the message)" + ); + assert.containsOnce(messageEl, '.o_Message_moderationAction.o-ban', + "there should be a contextual moderation decision to ban the user of the message" + ); + // The actions are tested as part of discuss tests. +}); + +QUnit.test('Notification Sent', async function (assert) { + assert.expect(9); + + await this.start(); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'sent', + notification_type: 'email', + partner: [['insert', { id: 12, name: "Someone" }]], + }]], + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconContainer', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope-o', + "icon should represent email success" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconContainer').click(); + }); + assert.containsOnce( + document.body, + '.o_NotificationPopover', + "notification popover should be open" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationIcon', + "popover should have one icon" + ); + assert.hasClass( + document.querySelector('.o_NotificationPopover_notificationIcon'), + 'fa-check', + "popover should have the sent icon" + ); + assert.containsOnce( + document.body, + '.o_NotificationPopover_notificationPartnerName', + "popover should have the partner name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(), + "Someone", + "partner name should be correct" + ); +}); + +QUnit.test('Notification Error', async function (assert) { + assert.expect(8); + + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_message_action', + "action should be the one to resend email" + ); + assert.strictEqual( + payload.options.additional_context.mail_message_to_resend, + 10, + "action should have correct message id" + ); + }); + + await this.start({ env: { bus } }); + const message = this.env.models['mail.message'].create({ + id: 10, + message_type: 'email', + notifications: [['insert', { + id: 11, + notification_status: 'exception', + notification_type: 'email', + }]], + }); + await this.createMessageComponent(message); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIconContainer', + "should display the notification icon container" + ); + assert.containsOnce( + document.body, + '.o_Message_notificationIcon', + "should display the notification icon" + ); + assert.hasClass( + document.querySelector('.o_Message_notificationIcon'), + 'fa-envelope', + "icon should represent email error" + ); + + await afterNextRender(() => { + document.querySelector('.o_Message_notificationIconContainer').click(); + }); + assert.verifySteps( + ['do_action'], + "should do an action to display the resend email dialog" + ); +}); + +QUnit.test("'channel_fetch' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); +}); + +QUnit.test("'channel_seen' notification received is correctly handled", async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel seen notification + const notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is seen" + ); +}); + +QUnit.test("'channel_fetch' notification then 'channel_seen' received are correctly handled", async function (assert) { + assert.expect(4); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + members: [ + [['link', currentPartner]], + [['insert', { id: 11, display_name: "Recipient" }]] + ], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V) as message is not yet received" + ); + + // Simulate received channel fetched notification + let notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_fetched', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "message seen indicator component should only contain one check (V) as message is just received" + ); + + // Simulate received channel seen notification + notifications = [ + [['myDB', 'mail.channel', 11], { + info: 'channel_seen', + last_message_id: 100, + partner_id: 11, + }], + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message seen indicator component should contain two checks (V) as message is now seen" + ); +}); + +QUnit.test('do not show messaging seen indicator if not authored by me', async function (assert) { + assert.expect(2); + + await this.start(); + const author = this.env.models['mail.partner'].create({ + id: 100, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + partnerSeenInfos: [['create', [ + { + lastFetchedMessage: [['insert', {id: 100}]], + partner: [['insert', {id: this.env.session.partner_id}]], + }, + { + lastFetchedMessage: [['insert', {id: 100}]], + partner: [['link', author]], + }, + ]]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', author]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsNone( + document.body, + '.o_Message_seenIndicator', + "message component should not have any message seen indicator" + ); +}); + +QUnit.test('do not show messaging seen indicator if before last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User", + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(99, 11), + message: [['insert', {id: 99}]], + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const lastSeenMessage = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>You already saw me</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 99, + threadCaches: [['link', thread.mainCache]], + }); + thread.update({ + partnerSeenInfos: [['create', [ + { + lastSeenMessage: [['link', lastSeenMessage]], + partner: [['link', currentPartner]], + }, + { + lastSeenMessage: [['link', lastSeenMessage]], + partner: [['insert', {id: 100}]], + }, + ]]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsNone( + document.body, + '.o_MessageSeenIndicator_icon', + "message component should not have any check (V)" + ); +}); + +QUnit.test('only show messaging seen indicator if authored by me, after last seen by all message', async function (assert) { + assert.expect(3); + + await this.start(); + const currentPartner = this.env.models['mail.partner'].create({ + id: this.env.session.partner_id, + display_name: "Demo User" + }); + const thread = this.env.models['mail.thread'].create({ + id: 11, + partnerSeenInfos: [['create', [ + { + lastSeenMessage: [['insert', {id: 100}]], + partner: [['link', currentPartner]], + }, + { + partner: [['insert', {id: 100}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 99}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 11), + message: [['insert', {id: 100}]], + }]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageComponent(message, { threadViewerLocalId: threadViewer.localId }); + + assert.containsOnce( + document.body, + '.o_Message', + "should display a message component" + ); + assert.containsOnce( + document.body, + '.o_Message_seenIndicator', + "message component should have a message seen indicator" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "message component should have two checks (V)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js new file mode 100644 index 00000000000..07fb1d054f0 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.js @@ -0,0 +1,65 @@ +odoo.define('mail/static/src/components/message_author_prefix/message_author_prefix.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MessageAuthorPrefix extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const author = message ? message.author : undefined; + const thread = props.threadLocalId + ? this.env.models['mail.thread'].get(props.threadLocalId) + : undefined; + return { + author: author ? author.__state : undefined, + currentPartner: this.env.messaging.currentPartner + ? this.env.messaging.currentPartner.__state + : undefined, + message: message ? message.__state : undefined, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.thread|undefined} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(MessageAuthorPrefix, { + props: { + messageLocalId: String, + threadLocalId: { + type: String, + optional: true, + }, + }, + template: 'mail.MessageAuthorPrefix', +}); + +return MessageAuthorPrefix; + +}); diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss new file mode 100644 index 00000000000..362eaeb5b92 --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.scss @@ -0,0 +1,11 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageAuthorPrefixIcon { + margin-right: 3px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ diff --git a/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml new file mode 100644 index 00000000000..de8992e4b8f --- /dev/null +++ b/addons/mail/static/src/components/message_author_prefix/message_author_prefix.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageAuthorPrefix" owl="1"> + <span class="o_MessageAuthorPrefix"> + <t t-if="message"> + <t t-if="message.author and message.author === env.messaging.currentPartner"> + <i class="o_MessageAuthorPrefixIcon fa fa-mail-reply"/>You: + </t> + <t t-elif="thread and thread.channel_type === 'channel'"> + <t t-esc="message.author.nameOrDisplayName"/>: + </t> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message_list/message_list.js b/addons/mail/static/src/components/message_list/message_list.js new file mode 100644 index 00000000000..c948689c6d1 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.js @@ -0,0 +1,531 @@ +odoo.define('mail/static/src/components/message_list/message_list.js', function (require) { +'use strict'; + +const components = { + Message: require('mail/static/src/components/message/message.js'), +}; +const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class MessageList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId); + const thread = threadViewer ? threadViewer.thread : undefined; + const threadCache = threadViewer ? threadViewer.threadCache : undefined; + return { + isDeviceMobile: this.env.messaging.device.isMobile, + messages: threadCache + ? threadCache.orderedMessages.map(message => message.__state) + : [], + thread: thread ? thread.__state : undefined, + threadCache: threadCache ? threadCache.__state : undefined, + threadViewer: threadViewer ? threadViewer.__state : undefined, + }; + }, { + compareDepth: { + messages: 1, + }, + }); + this._getRefs = useRefs(); + /** + * Determine whether the auto-scroll on load is active or not. This + * is useful to disable some times, such as when mounting message list + * in ASC order: the initial scroll position is at the top of the + * conversation, and most of the time the expected initial scroll + * position should be at the bottom of the thread. During this time, + * the programmatical scrolling should not trigger auto-load messages + * on scroll. + */ + this._isAutoLoadOnScrollActive = true; + /** + * Reference of the "load more" item. Useful to trigger load more + * on scroll when it becomes visible. + */ + this._loadMoreRef = useRef('loadMore'); + /** + * Tracked last thread cache rendered. Useful to determine scroll + * position on patch if it is on the same thread cache or not. + */ + this._renderedThreadCache = null; + /** + * Tracked last selected message. Useful to determine when patch comes + * from a message selection on a given thread cache, so that it + * auto-scroll to that message. + */ + this._selectedMessage = null; + /** + * Snapshot computed during willPatch, which is used by patched. + */ + this._willPatchSnapshot = undefined; + this._onScroll = _.throttle(this._onScroll.bind(this), 100); + } + + mounted() { + this._update(); + } + + willPatch() { + const lastMessageRef = this.lastMessageRef; + this._willPatchSnapshot = { + isLastMessageVisible: + lastMessageRef && + lastMessageRef.isBottomVisible({ offset: 10 }), + scrollHeight: this.el.scrollHeight, + scrollTop: this.el.scrollTop, + }; + } + + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Update the scroll position of the message list. + * This is not done in patched/mounted hooks because scroll position is + * dependent on UI globally. To illustrate, imagine following UI: + * + * +----------+ < viewport top = scrollable top + * | message | + * | list | + * | | + * +----------+ < scrolltop = viewport bottom = scrollable bottom + * + * Now if a composer is mounted just below the message list, it is shrinked + * and scrolltop is altered as a result: + * + * +----------+ < viewport top = scrollable top + * | message | + * | list | < scrolltop = viewport bottom <-+ + * | | |-- dist = composer height + * +----------+ < scrollable bottom <-+ + * +----------+ + * | composer | + * +----------+ + * + * Because of this, the scroll position must be changed when whole UI + * is rendered. To make this simpler, this is done when <ThreadViewer/> + * component is patched. This is acceptable when <ThreadViewer/> has a + * fixed height, which is the case for the moment. + */ + async adjustFromComponentHints() { + for (const hint of this.threadViewer.componentHintList) { + switch (hint.type) { + case 'change-of-thread-cache': + this._adjustFromChangeOfThreadCache(hint); + break; + case 'current-partner-just-posted-message': + this._adjustFromCurrentPartnerJustPostedMessage(hint); + break; + case 'more-messages-loaded': + this._adjustFromMoreMessagesLoaded(hint); + break; + default: + this.threadViewer.markComponentHintProcessed(hint); + break; + } + } + this._willPatchSnapshot = undefined; + } + + /** + * @param {mail.message} message + * @returns {string} + */ + getDateDay(message) { + const date = message.date.format('YYYY-MM-DD'); + if (date === moment().format('YYYY-MM-DD')) { + return this.env._t("Today"); + } else if ( + date === moment() + .subtract(1, 'days') + .format('YYYY-MM-DD') + ) { + return this.env._t("Yesterday"); + } + return message.date.format('LL'); + } + + /** + * @returns {integer} + */ + getScrollTop() { + return this.el.scrollTop; + } + + /** + * @returns {mail/static/src/components/message/message.js|undefined} + */ + get mostRecentMessageRef() { + if (this.props.order === 'desc') { + return this.messageRefs[0]; + } + const { length: l, [l - 1]: mostRecentMessageRef } = this.messageRefs; + return mostRecentMessageRef; + } + + /** + * @param {integer} messageId + * @returns {mail/static/src/components/message/message.js|undefined} + */ + messageRefFromId(messageId) { + return this.messageRefs.find(ref => ref.message.id === messageId); + } + + /** + * Get list of sub-components Message, ordered based on prop `order` + * (ASC/DESC). + * + * The asynchronous nature of OWL rendering pipeline may reveal disparity + * between knowledgeable state of store between components. Use this getter + * with extreme caution! + * + * Let's illustrate the disparity with a small example: + * + * - Suppose this component is aware of ordered (record) messages with + * following IDs: [1, 2, 3, 4, 5], and each (sub-component) messages map + * each of these records. + * - Now let's assume a change in store that translate to ordered (record) + * messages with following IDs: [2, 3, 4, 5, 6]. + * - Because store changes trigger component re-rendering by their "depth" + * (i.e. from parents to children), this component may be aware of + * [2, 3, 4, 5, 6] but not yet sub-components, so that some (component) + * messages should be destroyed but aren't yet (the ref with message ID 1) + * and some do not exist yet (no ref with message ID 6). + * + * @returns {mail/static/src/components/message/message.js[]} + */ + get messageRefs() { + const refs = this._getRefs(); + const ascOrderedMessageRefs = Object.entries(refs) + .filter(([refId, ref]) => ( + // Message refs have message local id as ref id, and message + // local ids contain name of model 'mail.message'. + refId.includes(this.env.models['mail.message'].modelName) && + // Component that should be destroyed but haven't just yet. + ref.message + ) + ) + .map(([refId, ref]) => ref) + .sort((ref1, ref2) => (ref1.message.id < ref2.message.id ? -1 : 1)); + if (this.props.order === 'desc') { + return ascOrderedMessageRefs.reverse(); + } + return ascOrderedMessageRefs; + } + + /** + * @returns {mail.message[]} + */ + get orderedMessages() { + const threadCache = this.threadViewer.threadCache; + if (this.props.order === 'desc') { + return [...threadCache.orderedMessages].reverse(); + } + return threadCache.orderedMessages; + } + + /** + * @param {integer} value + */ + async setScrollTop(value) { + this._isAutoLoadOnScrollActive = false; + this.el.scrollTop = value; + await new Promise(resolve => setTimeout(resolve, 0)); + this._isAutoLoadOnScrollActive = true; + } + + /** + * @param {mail.message} prevMessage + * @param {mail.message} message + * @returns {boolean} + */ + shouldMessageBeSquashed(prevMessage, message) { + if (!this.props.hasSquashCloseMessages) { + return false; + } + if (Math.abs(message.date.diff(prevMessage.date)) > 60000) { + // more than 1 min. elasped + return false; + } + if (prevMessage.message_type !== 'comment' || message.message_type !== 'comment') { + return false; + } + if (prevMessage.author !== message.author) { + // from a different author + return false; + } + if (prevMessage.originThread !== message.originThread) { + return false; + } + if ( + prevMessage.moderation_status === 'pending_moderation' || + message.moderation_status === 'pending_moderation' + ) { + return false; + } + if ( + prevMessage.notifications.length > 0 || + message.notifications.length > 0 + ) { + // visual about notifications is restricted to non-squashed messages + return false; + } + const prevOriginThread = prevMessage.originThread; + const originThread = message.originThread; + if ( + prevOriginThread && + originThread && + prevOriginThread.model === originThread.model && + originThread.model !== 'mail.channel' && + prevOriginThread.id !== originThread.id + ) { + // messages linked to different document thread + return false; + } + return true; + } + + /** + * @returns {mail.thread_viewer} + */ + get threadViewer() { + return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} hint + */ + async _adjustFromChangeOfThreadCache(hint) { + const threadCache = this.threadViewer.threadCache; + if (!threadCache.isLoaded) { + return; + } + let isProcessed = false; + if (threadCache.messages.length > 0) { + if (this.threadViewer.threadCacheInitialPosition !== undefined) { + this.el.scrollTop = this.threadViewer.threadCacheInitialPosition; + isProcessed = true; + } else { + const lastMessage = threadCache.lastMessage; + if (this.messageRefFromId(lastMessage.id)) { + this._scrollToMostRecentMessage(); + isProcessed = true; + } + } + } else { + isProcessed = true; + } + if (isProcessed) { + this.threadViewer.markComponentHintProcessed(hint); + } + } + + /** + * @private + * @param {Object} hint + * @param {integer} hint.messageId + */ + async _adjustFromCurrentPartnerJustPostedMessage(hint) { + const threadCache = this.threadViewer.threadCache; + const { messageId } = hint; + if (threadCache.isLoaded) { + const threadCacheMessageIds = threadCache.messages.map(message => message.id); + if (threadCacheMessageIds.includes(messageId) && this.messageRefFromId(messageId)) { + await this._scrollToMessage(messageId); + this.threadViewer.markComponentHintProcessed(hint); + } + } + } + + /** + * @private + * @param {Object} hint + */ + _adjustFromMoreMessagesLoaded(hint) { + if (!this._willPatchSnapshot) { + this.threadViewer.markComponentHintProcessed(hint); + return; + } + const { scrollHeight, scrollTop } = this._willPatchSnapshot; + if (this.props.order === 'asc') { + this.el.scrollTop = this.el.scrollHeight - scrollHeight + scrollTop; + } + this.threadViewer.markComponentHintProcessed(hint); + } + + /** + * @private + */ + _checkThreadMarkAsRead() { + const thread = this.threadViewer.thread; + const threadCache = this.threadViewer.threadCache; + if (!threadCache) { + return; + } + if (threadCache.messages.length === 0) { + return; + } + if (!this.mostRecentMessageRef) { + return; + } + if ( + threadCache === thread.mainCache && + this.mostRecentMessageRef.isPartiallyVisible() + ) { + thread.markAsSeen(); + } + } + + /** + * @private + * @returns {boolean} + */ + _isLoadMoreVisible() { + const loadMore = this._loadMoreRef.el; + if (!loadMore) { + return false; + } + const loadMoreRect = loadMore.getBoundingClientRect(); + const elRect = this.el.getBoundingClientRect(); + const isInvisible = loadMoreRect.top > elRect.bottom || loadMoreRect.bottom < elRect.top; + return !isInvisible; + } + + /** + * @private + */ + _loadMore() { + this.threadViewer.threadCache.loadMoreMessages(); + } + + /** + * @private + * @returns {Promise} + */ + async _scrollToMostRecentMessage() { + if (!this.mostRecentMessageRef) { + return; + } + this._isAutoLoadOnScrollActive = false; + await this.mostRecentMessageRef.scrollIntoView(); + if (!this.el) { + this._isAutoLoadOnScrollActive = true; + return; + } + this.el.scrollTop = this.props.order === 'asc' + ? this.el.scrollTop + 15 + : this.el.scrollTop - 15; + this._isAutoLoadOnScrollActive = true; + } + + /** + * @param {integer} messageId + */ + async _scrollToMessage(messageId) { + const messageRef = this.messageRefFromId(messageId); + if (!messageRef) { + return; + } + this._isAutoLoadOnScrollActive = false; + await messageRef.scrollIntoView(); + if (!this.el) { + this._isAutoLoadOnScrollActive = true; + return; + } + this.el.scrollTop = this.props.order === 'asc' + ? this.el.scrollTop + 15 + : this.el.scrollTop - 15; + this._isAutoLoadOnScrollActive = true; + } + + /** + * @private + */ + _update() { + this._checkThreadMarkAsRead(); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickLoadMore(ev) { + ev.preventDefault(); + this._loadMore(); + } + + /** + * @private + * @param {ScrollEvent} ev + */ + _onScroll(ev) { + if (!this.el) { + // could be unmounted in the meantime (due to throttled behavior) + return; + } + this.threadViewer.saveThreadCacheScrollPositionsAsInitial(this.el.scrollTop); + if (!this._isAutoLoadOnScrollActive) { + return; + } + if (this._isLoadMoreVisible()) { + this._loadMore(); + } + this._checkThreadMarkAsRead(); + } + +} + +Object.assign(MessageList, { + components, + defaultProps: { + hasMessageCheckbox: false, + hasSquashCloseMessages: false, + haveMessagesAuthorRedirect: false, + haveMessagesMarkAsReadIcon: false, + haveMessagesReplyIcon: false, + order: 'asc', + }, + props: { + hasMessageCheckbox: Boolean, + hasSquashCloseMessages: Boolean, + haveMessagesAuthorRedirect: Boolean, + haveMessagesMarkAsReadIcon: Boolean, + haveMessagesReplyIcon: Boolean, + order: { + type: String, + validate: prop => ['asc', 'desc'].includes(prop), + }, + selectedMessageLocalId: { + type: String, + optional: true, + }, + threadViewerLocalId: String, + }, + template: 'mail.MessageList', +}); + +return MessageList; + +}); diff --git a/addons/mail/static/src/components/message_list/message_list.scss b/addons/mail/static/src/components/message_list/message_list.scss new file mode 100644 index 00000000000..cb06adda261 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.scss @@ -0,0 +1,135 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageList { + display: flex; + flex-flow: column; + overflow: auto; + + &.o-empty { + align-items: center; + justify-content: center; + } + + &:not(.o-empty) { + padding-bottom: 15px; + } +} + +.o_MessageList_empty { + flex: 1 1 auto; + height: 100%; + width: 100%; + align-self: center; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + padding: 20px; + line-height: 2.5rem; +} + +.o_MessageList_isLoadingMore { + align-self: center; +} + +.o_MessageList_isLoadingMoreIcon { + margin-right: 3px; +} + +.o_MessageList_loadMore { + align-self: center; +} + +.o_MessageList_separator { + display: flex; + align-items: center; + padding: 0 0; + flex: 0 0 auto; +} + +.o_MessageList_separatorDate { + padding: 15px 0; +} + +.o_MessageList_separatorLine { + flex: 1 1 auto; + width: auto; +} + +.o_MessageList_separatorNewMessages { + // bug with safari: container does not auto-grow from child size + padding: 0 0; + margin-right: 15px; +} + +.o_MessageList_separatorLabel { + padding: 0 10px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MessageList { + background-color: white; +} + +.o_MessageList_empty { + text-align: center; +} + +.o_MessageList_emptyTitle { + font-weight: bold; + font-size: 1.3rem; + + &.o-neutral-face-icon:before { + @extend %o-nocontent-init-image; + @include size(120px, 140px); + background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center; + } +} + +.o_MessageList_loadMore { + cursor: pointer; +} + +.o_MessageList_message.o-has-message-selection:not(.o-selected) { + opacity: 0.5; +} + +.o_MessageList_separator { + font-weight: bold; +} + +.o_MessageList_separatorLine { + border-color: gray('400'); +} + +.o_MessageList_separatorLineNewMessages { + border-color: lighten($o-brand-odoo, 15%); +} + +.o_MessageList_separatorNewMessages { + color: lighten($o-brand-odoo, 15%); + +} + +.o_MessageList_separatorLabel { + background-color: white; +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_MessageList_separatorNewMessages:not(.o-disable-animation) { + &.fade-leave-active { + transition: opacity 0.5s; + } + + &.fade-leave-to { + opacity: 0; + } +} diff --git a/addons/mail/static/src/components/message_list/message_list.xml b/addons/mail/static/src/components/message_list/message_list.xml new file mode 100644 index 00000000000..4951bcea951 --- /dev/null +++ b/addons/mail/static/src/components/message_list/message_list.xml @@ -0,0 +1,107 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageList" owl="1"> + <div class="o_MessageList" t-att-class="{ 'o-empty': threadViewer and threadViewer.messages.length === 0, 'o-has-message-selection': props.selectedMessageLocalId }" t-on-scroll="_onScroll"> + <t t-if="threadViewer"> + <!-- No result messages --> + <t t-if="threadViewer.messages.length === 0"> + <div class="o_MessageList_empty o_MessageList_item"> + <t t-if="threadViewer.thread === env.messaging.inbox"> + <div class="o_MessageList_emptyTitle"> + Congratulations, your inbox is empty + </div> + New messages appear here. + </t> + <t t-elif="threadViewer.thread === env.messaging.starred"> + <div class="o_MessageList_emptyTitle"> + No starred messages + </div> + You can mark any message as 'starred', and it shows up in this mailbox. + </t> + <t t-elif="threadViewer.thread === env.messaging.history"> + <div class="o_MessageList_emptyTitle o-neutral-face-icon"> + No history messages + </div> + Messages marked as read will appear in the history. + </t> + <t t-elif="threadViewer.thread === env.messaging.moderation"> + <div class="o_MessageList_emptyTitle"> + You have no messages to moderate. + </div> + Messages pending moderation appear here. + </t> + <t t-else=""> + There are no messages in this conversation. + </t> + </div> + </t> + <!-- LOADING (if order asc)--> + <t t-if="props.order === 'asc' and threadViewer.messages.length > 0"> + <t t-call="mail.MessageList.loadMore"/> + </t> + <!-- MESSAGES --> + <t t-set="current_day" t-value="0"/> + <t t-set="prev_message" t-value="0"/> + <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and !threadViewer.thread.seen_message_id"> + <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade"> + <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span> + </div> + </t> + <t t-foreach="orderedMessages" t-as="message" t-key="message.localId"> + <t t-set="message_day" t-value="getDateDay(message)"/> + <t t-if="current_day !== message_day"> + <div class="o_MessageList_separator o_MessageList_separatorDate o_MessageList_item"> + <hr class="o_MessageList_separatorLine"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelDate"><t t-esc="message_day"/></span><hr class="o_MessageList_separatorLine"/> + <t t-set="current_day" t-value="message_day"/> + <t t-set="isMessageSquashed" t-value="false"/> + </div> + </t> + <t t-else=""> + <t t-set="isMessageSquashed" t-value="shouldMessageBeSquashed(prev_message, message)"/> + </t> + <Message + class="o_MessageList_item o_MessageList_message" + t-att-class="{ + 'o-has-message-selection': props.selectedMessageLocalId, + }" + hasAuthorRedirect="props.haveMessagesAuthorRedirect" + hasMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" + hasCheckbox="props.hasMessageCheckbox" + hasReplyIcon="props.haveMessagesReplyIcon" + isSelected="props.selectedMessageLocalId === message.localId" + isSquashed="isMessageSquashed" + messageLocalId="message.localId" + threadViewerLocalId="threadViewer.localId" + t-ref="{{ message.localId }}" + /> + <t t-set="prev_message" t-value="message"/> + <t t-if="threadViewer.thread.model === 'mail.channel' and threadViewer.thread.message_unread_counter > 0 and threadViewer.thread.seen_message_id === message.id"> + <div class="o_MessageList_separator o_MessageList_separatorNewMessages o_MessageList_item" t-att-class="{ 'o-disable-animation': env.disableAnimation }" t-transition="fade"> + <hr class="o_MessageList_separatorLine o_MessageList_separatorLineNewMessages"/><span class="o_MessageList_separatorLabel o_MessageList_separatorLabelNewMessages">New messages</span> + </div> + </t> + </t> + <!-- LOADING (if order desc)--> + <t t-if="props.order === 'desc' and threadViewer.messages.length > 0"> + <t t-call="mail.MessageList.loadMore"/> + </t> + </t> + </div> + </t> + + <t t-name="mail.MessageList.loadMore" owl="1"> + <t t-if="threadViewer.threadCache.isLoadingMore"> + <div class="o_MessageList_item o_MessageList_isLoadingMore"> + <i class="o_MessageList_isLoadingMoreIcon fa fa-spin fa-spinner"/> + Loading... + </div> + </t> + <t t-elif="!threadViewer.threadCache.isAllHistoryLoaded"> + <a class="o_MessageList_item o_MessageList_loadMore" href="#" t-on-click="_onClickLoadMore" t-ref="loadMore"> + Load more + </a> + </t> + </t> + +</templates> diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js new file mode 100644 index 00000000000..8c5239c9e2d --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.js @@ -0,0 +1,130 @@ +odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class MessageSeenIndicator extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const message = this.env.models['mail.message'].get(props.messageLocalId); + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const messageSeenIndicator = this.env.models['mail.message_seen_indicator'].find( + messageSeenIndicator => + messageSeenIndicator.message === message && + messageSeenIndicator.thread === thread + ); + return { + messageSeenIndicator: messageSeenIndicator ? messageSeenIndicator.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get indicatorTitle() { + if (!this.messageSeenIndicator) { + return ''; + } + if (this.messageSeenIndicator.hasEveryoneSeen) { + return this.env._t("Seen by Everyone"); + } + if (this.messageSeenIndicator.hasSomeoneSeen) { + const partnersThatHaveSeen = this.messageSeenIndicator.partnersThatHaveSeen.map( + partner => partner.name + ); + if (partnersThatHaveSeen.length === 1) { + return _.str.sprintf( + this.env._t("Seen by %s"), + partnersThatHaveSeen[0] + ); + } + if (partnersThatHaveSeen.length === 2) { + return _.str.sprintf( + this.env._t("Seen by %s and %s"), + partnersThatHaveSeen[0], + partnersThatHaveSeen[1] + ); + } + return _.str.sprintf( + this.env._t("Seen by %s, %s and more"), + partnersThatHaveSeen[0], + partnersThatHaveSeen[1] + ); + } + if (this.messageSeenIndicator.hasEveryoneFetched) { + return this.env._t("Received by Everyone"); + } + if (this.messageSeenIndicator.hasSomeoneFetched) { + const partnersThatHaveFetched = this.messageSeenIndicator.partnersThatHaveFetched.map( + partner => partner.name + ); + if (partnersThatHaveFetched.length === 1) { + return _.str.sprintf( + this.env._t("Received by %s"), + partnersThatHaveFetched[0] + ); + } + if (partnersThatHaveFetched.length === 2) { + return _.str.sprintf( + this.env._t("Received by %s and %s"), + partnersThatHaveFetched[0], + partnersThatHaveFetched[1] + ); + } + return _.str.sprintf( + this.env._t("Received by %s, %s and more"), + partnersThatHaveFetched[0], + partnersThatHaveFetched[1] + ); + } + return ''; + } + + /** + * @returns {mail.message} + */ + get message() { + return this.env.models['mail.message'].get(this.props.messageLocalId); + } + + /** + * @returns {mail.message_seen_indicator} + */ + get messageSeenIndicator() { + return this.env.models['mail.message_seen_indicator'].find(messageSeenIndicator => + messageSeenIndicator.message === this.message && + messageSeenIndicator.thread === this.thread + ); + } + + /** + * @returns {mail.Thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } +} + +Object.assign(MessageSeenIndicator, { + props: { + messageLocalId: String, + threadLocalId: String, + }, + template: 'mail.MessageSeenIndicator', +}); + +return MessageSeenIndicator; + +}); diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss new file mode 100644 index 00000000000..3a9d566e399 --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.scss @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessageSeenIndicator { + display: flex; + position: relative; + flex-wrap: nowrap; +} + +.o_MessageSeenIndicator_icon { + + &.o-first { + padding-left: map-get($spacers, 1); + } + + &.o-second { + position: absolute; + top: -1px; + left: -1px; + } +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MessageSeenIndicator { + opacity: 0.6; + + &.o-all-seen { + color: $o-brand-odoo; + } + + &:hover { + cursor: pointer; + opacity: 0.8; + } +} diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml new file mode 100644 index 00000000000..e905afaa392 --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessageSeenIndicator" owl="1"> + <span class="o_MessageSeenIndicator" t-att-class="{ 'o-all-seen': messageSeenIndicator and messageSeenIndicator.hasEveryoneSeen }" t-att-title="indicatorTitle"> + <t t-if="messageSeenIndicator and !messageSeenIndicator.isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone"> + <t t-if="messageSeenIndicator.hasSomeoneFetched or messageSeenIndicator.hasSomeoneSeen"> + <i class="o_MessageSeenIndicator_icon o-first fa fa-check"/> + </t> + <t t-if="messageSeenIndicator.hasSomeoneSeen"> + <i class="o_MessageSeenIndicator_icon o-second fa fa-check"/> + </t> + </t> + </span> + </t> +</templates> diff --git a/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js new file mode 100644 index 00000000000..9184dc0c692 --- /dev/null +++ b/addons/mail/static/src/components/message_seen_indicator/message_seen_indicator_tests.js @@ -0,0 +1,293 @@ +odoo.define('mail/static/src/components/message_seen_indicator/message_seen_indicator_tests', function (require) { +'use strict'; + +const components = { + MessageSendIndicator: require('mail/static/src/components/message_seen_indicator/message_seen_indicator.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('message_seen_indicator', {}, function () { +QUnit.module('message_seen_indicator_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createMessageSeenIndicatorComponent = async ({ message, thread }, otherProps) => { + const MessageSeenIndicatorComponent = components.MessageSendIndicator; + MessageSeenIndicatorComponent.env = this.env; + this.component = new MessageSeenIndicatorComponent(null, Object.assign({ + messageLocalId: message.localId, + threadLocalId: thread.localId, + }, otherProps)); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.MessageSendIndicator.env; + }, +}); + +QUnit.test('rendering when just one has received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]] + }, + { + partner: [['create', {id: 100}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "should display only one seen indicator icon" + ); +}); + +QUnit.test('rendering when everyone have received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + lastFetchedMessage: [['insert', {id: 99}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator_icon', + "should display only one seen indicator icon" + ); +}); + +QUnit.test('rendering when just one has seen the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + lastFetchedMessage: [['insert', {id: 99}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +QUnit.test('rendering when just one has seen & received the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.doesNotHaveClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not be considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +QUnit.test('rendering when just everyone has seen the message', async function (assert) { + assert.expect(3); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + id: 1000, + model: 'mail.channel', + partnerSeenInfos: [['create', [ + { + partner: [['create', {id: 10}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + { + partner: [['create', {id: 100}]], + lastFetchedMessage: [['insert', {id: 100}]], + lastSeenMessage: [['insert', {id: 100}]], + }, + ]]], + messageSeenIndicators: [['insert', { + id: this.env.models['mail.message_seen_indicator'].computeId(100, 1000), + message: [['insert', {id: 100}]], + }]], + }); + const message = this.env.models['mail.message'].create({ + author: [['insert', { id: this.env.session.partner_id, display_name: "Demo User" }]], + body: "<p>Test</p>", + id: 100, + threadCaches: [['link', thread.mainCache]], + }); + await this.createMessageSeenIndicatorComponent({message, thread}); + assert.containsOnce( + document.body, + '.o_MessageSeenIndicator', + "should display a message seen indicator component" + ); + assert.hasClass( + document.querySelector('.o_MessageSeenIndicator'), + 'o-all-seen', + "indicator component should not considered as all seen" + ); + assert.containsN( + document.body, + '.o_MessageSeenIndicator_icon', + 2, + "should display two seen indicator icon" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.js b/addons/mail/static/src/components/messaging_menu/messaging_menu.js new file mode 100644 index 00000000000..864c7df97e1 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.js @@ -0,0 +1,266 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const components = { + AutocompleteInput: require('mail/static/src/components/autocomplete_input/autocomplete_input.js'), + MobileMessagingNavbar: require('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js'), + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class MessagingMenu extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + /** + * global JS generated ID for this component. Useful to provide a + * custom class to autocomplete input, so that click in an autocomplete + * item is not considered as a click away from messaging menu in mobile. + */ + this.id = _.uniqueId('o_messagingMenu_'); + useStore((...args) => this._useStoreSelector(...args)); + + /** + * Reference of the new message input in mobile. Useful to include it + * and autocomplete menu as "inside" the messaging menu, to prevent + * closing the messaging menu otherwise. + */ + this._mobileNewMessageInputRef = useRef('mobileNewMessageInput'); + + // bind since passed as props + this._onMobileNewMessageInputSelect = this._onMobileNewMessageInputSelect.bind(this); + this._onMobileNewMessageInputSource = this._onMobileNewMessageInputSource.bind(this); + + this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); + } + + mounted() { + document.addEventListener('click', this._onClickCaptureGlobal, true); + } + + willUnmount() { + document.removeEventListener('click', this._onClickCaptureGlobal, true); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.discuss} + */ + get discuss() { + return this.env.messaging && this.env.messaging.discuss; + } + + /** + * @returns {mail.messaging_menu} + */ + get messagingMenu() { + return this.env.messaging.messagingMenu; + } + + /** + * @returns {string} + */ + get mobileNewMessageInputPlaceholder() { + return this.env._t("Search user..."); + } + + /** + * @returns {Object[]} + */ + get tabs() { + return [{ + icon: 'fa fa-envelope', + id: 'all', + label: this.env._t("All"), + }, { + icon: 'fa fa-user', + id: 'chat', + label: this.env._t("Chat"), + }, { + icon: 'fa fa-users', + id: 'channel', + label: this.env._t("Channel"), + }]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _useStoreSelector(props) { + return { + messagingMenu: this.env.messaging.messagingMenu.__state, + isDeviceMobile: this.env.messaging.device.isMobile, + isDiscussOpen: this.env.messaging.discuss.isOpen, + isMessagingInitialized: this.env.messaging.isInitialized, + }; + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickCaptureGlobal(ev) { + // in mobile: keeps the messaging menu open in background + // TODO: maybe need to move this to a mobile component? + // task-2089887 + if ( + this.env.messaging.device.isMobile && + this.env.messaging.chatWindowManager.hasVisibleChatWindows + ) { + return; + } + // closes the menu when clicking outside + if (this.el.contains(ev.target)) { + return; + } + const input = this._mobileNewMessageInputRef.comp; + if (input && input.contains(ev.target)) { + return; + } + this.messagingMenu.close(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickDesktopTabButton(ev) { + ev.stopPropagation(); + this.messagingMenu.update({ activeTabId: ev.currentTarget.dataset.tabId }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickNewMessage(ev) { + ev.stopPropagation(); + if (!this.env.messaging.device.isMobile) { + this.env.models['mail.thread'].openNewMessage(); + this.messagingMenu.close(); + } else { + this.messagingMenu.toggleMobileNewMessage(); + } + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickToggler(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.messagingMenu.toggleOpen(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + _onHideMobileNewMessage(ev) { + ev.stopPropagation(); + this.messagingMenu.toggleMobileNewMessage(); + } + + /** + * @private + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + _onMobileNewMessageInputSelect(ev, ui) { + const partnerId = ui.item.id; + const partner = this.env.models['mail.partner'].find(partner => partner.id === partnerId); + const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); + if (chat) { + chat.open(); + } else { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + partnerId, + type: 'chat', + }); + } + if (!this.env.messaging.device.isMobile) { + this.messagingMenu.close(); + } + } + + /** + * @private + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + _onMobileNewMessageInputSource(req, res) { + const value = _.escape(req.term); + this.env.models['mail.partner'].imSearch({ + callback: partners => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: value, + limit: 10, + }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {string} ev.detail.tabId + */ + _onSelectMobileNavbarTab(ev) { + ev.stopPropagation(); + this.messagingMenu.update({ activeTabId: ev.detail.tabId }); + } + + /** + * @private + * @param {CustomEvent} ev + * @param {Object} ev.detail + * @param {mail.thread} ev.detail.thread + */ + _onSelectThread(ev) { + ev.stopPropagation(); + this.env.models['mail.thread'].get(ev.detail.thread).open(); + if (!this.env.messaging.device.isMobile) { + this.messagingMenu.close(); + } + } + +} + +Object.assign(MessagingMenu, { + components, + props: {}, + template: 'mail.MessagingMenu', +}); + +return MessagingMenu; + +}); diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.scss b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss new file mode 100644 index 00000000000..776d6af5371 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.scss @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MessagingMenu_counter { + position: relative; + transform: translate(-5px, -5px); + margin-right: -10px; // "cancel" right padding of systray items +} + +.o_MessagingMenu_dropdownMenu { + display: flex; + flex-flow: column; + padding-top: 0; + padding-bottom: 0; + overflow-y: auto; + + &.o-messaging-not-initialized { + align-items: center; + justify-content: center; + } + + &:not(.o-mobile) { + flex: 0 1 auto; + width: 350px; + min-height: 50px; + max-height: 400px; + z-index: 1100; // on top of chat windows + } + + &.o-mobile { + flex: 1 1 auto; + position: fixed; + top: $o-mail-chat-window-header-height-mobile; + bottom: 0; + left: 0; + right: 0; + width: 100%; + margin: 0; + max-height: none; + } +} + +.o_MessagingMenu_dropdownMenuHeader { + + &:not(.o-mobile) { + display: flex; + } + + &.o-mobile { + display: grid; + grid-template-areas: + "top" + "bottom"; + grid-template-rows: auto auto; + padding: 5px + } +} + +.o_MessagingMenu_dropdownLoadingIcon { + margin-right: 3px; +} + +.o_MessagingMenu_icon { + font-size: larger +} + +.o_MessagingMenu_loading { + font-size: small; + position: absolute; + bottom: 50%; + right: 0; +} + +.o_MessagingMenu_newMessageButton.o-mobile { + grid-area: top; + justify-self: start; +} + +.o_MessagingMenu_mobileNewMessageInput { + grid-area: bottom; + padding: 8px; + margin-top: 10px +} + +.o_MessagingMenu_notificationList.o-mobile { + flex: 1 1 auto; + overflow-y: auto; +} + + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +// Make hightlight more consistent, due to messaging menu looking quite similar to discuss app in mobile +.o_MessagingMenu.show { + background-color: rgba(black, 0.1); +} + +.o_MessagingMenu_counter { + background-color: $o-brand-primary; +} + +.o_MessagingMenu_dropdownMenu.o-mobile { + border: 0; +} + +.o_MessagingMenu_dropdownMenuHeader { + border-bottom: 1px solid gray('400'); + z-index: 1; +} + +.o_MessagingMenu_mobileNewMessageInput { + appearance: none; + border: 1px solid gray('400'); + border-radius: 5px; + outline: none; +} + +.o_MessagingMenu_tabButton.o-desktop { + + &.o-active { + font-weight: bold; + } + + &:not(:hover) { + + &:not(.o-active) { + color: gray('500'); + } + } +} diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu.xml b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml new file mode 100644 index 00000000000..77c33e5ce7e --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MessagingMenu" owl="1"> + <li class="o_MessagingMenu" t-att-class="{ show: messagingMenu.isOpen, 'o-mobile': env.messaging.device.isMobile }"> + <!-- TODO FIXME restore mobile stuff here (activeMobileThread) --> + <t t-if="!env.messaging.device.isMobile or true"> + <a class="o_MessagingMenu_toggler o-no-caret" t-att-class="{ show: messagingMenu.isOpen }" href="#" t-on-click="_onClickToggler" data-toggle="dropdown" data-display="static" title="Conversations" role="button" aria-expanded="false"> + <i class="o_MessagingMenu_icon fa fa-comments-o" role="img" aria-label="Messages"/> + <t t-if="!env.messaging.isInitialized"> + <i class="o_MessagingMenu_loading fa fa-spinner fa-spin"/> + </t> + <t t-elif="messagingMenu.counter > 0"> + <span class="o_MessagingMenu_counter badge badge-pill"> + <t t-esc="messagingMenu.counter"/> + </span> + </t> + </a> + </t> + <!-- TODO FIXME restore mobile stuff here (activeMobileThread) --> + <t t-if="messagingMenu.isOpen and (!env.messaging.device.isMobile or true)"> + <div class="o_MessagingMenu_dropdownMenu dropdown-menu dropdown-menu-right" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-messaging-not-initialized': !env.messaging.isInitialized }" role="menu"> + <t t-if="!env.messaging.isInitialized"> + <span><i class="o_MessagingMenu_dropdownLoadingIcon fa fa-spinner fa-spin"/>Please wait...</span> + </t> + <t t-else=""> + <div class="o_MessagingMenu_dropdownMenuHeader" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-if="!env.messaging.device.isMobile"> + <t t-foreach="['all', 'chat', 'channel']" t-as="tabId" t-key="tabId"> + <button class="o_MessagingMenu_tabButton o-desktop btn btn-link" t-att-class="{ 'o-active': messagingMenu.activeTabId === tabId, }" t-on-click="_onClickDesktopTabButton" type="button" role="tab" t-att-data-tab-id="tabId"> + <t t-if="tabId === 'all'">All</t> + <t t-elif="tabId === 'chat'">Chat</t> + <t t-elif="tabId === 'channel'">Channels</t> + </button> + </t> + </t> + <t t-if="env.messaging.device.isMobile"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <div class="o-autogrow"/> + <t t-if="!env.messaging.device.isMobile and !discuss.isOpen"> + <t t-call="mail.MessagingMenu.newMessageButton"/> + </t> + <t t-if="env.messaging.device.isMobile and messagingMenu.isMobileNewMessageToggled"> + <AutocompleteInput + class="o_MessagingMenu_mobileNewMessageInput" + customClass="id + '_mobileNewMessageInputAutocomplete'" + isFocusOnMount="true" + placeholder="mobileNewMessageInputPlaceholder" + select="_onMobileNewMessageInputSelect" + source="_onMobileNewMessageInputSource" + t-on-o-hide="_onHideMobileNewMessage" + t-ref="mobileNewMessageInput" + /> + </t> + </div> + <NotificationList + class="o_MessagingMenu_notificationList" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + filter="messagingMenu.activeTabId" + t-on-o-select-thread="_onSelectThread" + /> + <t t-if="env.messaging.device.isMobile"> + <MobileMessagingNavbar + class="o_MessagingMenu_mobileNavbar" + activeTabId="messagingMenu.activeTabId" + tabs="tabs" + t-on-o-select-mobile-messaging-navbar-tab="_onSelectMobileNavbarTab" + /> + </t> + </t> + </div> + </t> + </li> + </t> + + <t t-name="mail.MessagingMenu.newMessageButton" owl="1"> + <button class="o_MessagingMenu_newMessageButton btn" + t-att-class="{ + 'btn-link': !env.messaging.device.isMobile, + 'btn-secondary': env.messaging.device.isMobile, + 'o-mobile': env.messaging.device.isMobile, + }" t-on-click="_onClickNewMessage" type="button" + > + New message + </button> + </t> + +</templates>
\ No newline at end of file diff --git a/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js new file mode 100644 index 00000000000..60159360899 --- /dev/null +++ b/addons/mail/static/src/components/messaging_menu/messaging_menu_tests.js @@ -0,0 +1,849 @@ +odoo.define('mail/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { makeTestPromise } = require('web.test_utils'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('messaging_menu', {}, function () { +QUnit.module('messaging_menu_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { discussWidget, env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + hasMessagingMenu: true, + })); + this.discussWidget = discussWidget; + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.widget) { + this.widget.destroy(); + } + }, +}); + +QUnit.test('messaging not initialized', async function (assert) { + assert.expect(2); + + await this.start({ + async mockRPC(route) { + if (route === '/mail/init_messaging') { + // simulate messaging never initialized + return new Promise(resolve => {}); + } + return this._super(...arguments); + }, + waitUntilMessagingInitialized: false, + }); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 1, + "should display loading icon on messaging menu when messaging not yet initialized" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent, + "Please wait...", + "should prompt loading when opening messaging menu" + ); +}); + +QUnit.test('messaging becomes initialized', async function (assert) { + assert.expect(2); + + const messagingInitializedProm = makeTestPromise(); + + await this.start({ + async mockRPC(route) { + const _super = this._super.bind(this, ...arguments); // limitation of class.js + if (route === '/mail/init_messaging') { + await messagingInitializedProm; + } + return _super(); + }, + waitUntilMessagingInitialized: false, + }); + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + + // simulate messaging becomes initialized + await afterNextRender(() => messagingInitializedProm.resolve()); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu_loading').length, + 0, + "should no longer display loading icon on messaging menu when messaging becomes initialized" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu_dropdownMenu').textContent.includes("Please wait..."), + "should no longer prompt loading when opening messaging menu when messaging becomes initialized" + ); +}); + +QUnit.test('basic rendering', async function (assert) { + assert.expect(21); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + assert.strictEqual( + document.querySelectorAll('.o_MessagingMenu').length, + 1, + "should have messaging menu" + ); + assert.notOk( + document.querySelector('.o_MessagingMenu').classList.contains('show'), + "should not mark messaging menu item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_toggler`).length, + 1, + "should have clickable element on messaging menu" + ); + assert.notOk( + document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'), + "should not mark messaging menu clickable item as shown by default" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_icon`).length, + 1, + "should have icon on clickable element in messaging menu" + ); + assert.ok( + document.querySelector(`.o_MessagingMenu_icon`).classList.contains('fa-comments-o'), + "should have 'comments' icon on clickable element in messaging menu" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 0, + "should not display any messaging menu dropdown by default" + ); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.ok( + document.querySelector('.o_MessagingMenu').classList.contains('show'), + "should mark messaging menu item as shown" + ); + assert.ok( + document.querySelector(`.o_MessagingMenu_toggler`).classList.contains('show'), + "should mark messaging menu clickable item as shown" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu`).length, + 1, + "should display messaging menu dropdown after click" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenuHeader`).length, + 1, + "should have dropdown menu header" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenuHeader + .o_MessagingMenu_tabButton + `).length, + 3, + "should have 3 tab buttons to filter items in the header" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have button to make a new message" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList + `).length, + 1, + "should display thread preview list" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_NotificationList_noConversation + `).length, + 1, + "should display no conversation in thread preview list" + ); +}); + +QUnit.test('counter is taking into account failure notification', async function (assert) { + assert.expect(2); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Channel", + }]; + await this.start(); + + assert.containsOnce( + document.body, + '.o_MessagingMenu_counter', + "should display a notification counter next to the messaging menu for one notification" + ); + assert.strictEqual( + document.querySelector('.o_MessagingMenu_counter').textContent, + "1", + "should display a counter of '1' next to the messaging menu" + ); +}); + +QUnit.test('switch tab', async function (assert) { + assert.expect(15); + + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).length, + 1, + "1 tab button should be 'All'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).length, + 1, + "1 tab button should be 'Chat'" + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).length, + 1, + "1 tab button should be 'Channels'" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not be active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should not be active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="chat"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should not become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should stay inactive" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="channel"]`).click() + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should stay active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should become inactive" + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become active" + ); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_tabButton[data-tab-id="all"]`).click() + ); + assert.ok( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="all"] + `).classList.contains('o-active'), + "'all' tab button should become active" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="chat"] + `).classList.contains('o-active'), + "'chat' tab button should stay inactive" + ); + assert.notOk( + document.querySelector(` + .o_MessagingMenu_tabButton[data-tab-id="channel"] + `).classList.contains('o-active'), + "'channel' tab button should become inactive" + ); +}); + +QUnit.test('new message', async function (assert) { + assert.expect(3); + + await this.start({ + hasChatWindow: true, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_newMessageButton`).click() + ); + + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-new-message'), + "chat window should be for new message" + ); + assert.ok( + document.querySelector(`.o_ChatWindow`).classList.contains('o-focused'), + "chat window should be focused" + ); +}); + +QUnit.test('no new message when discuss is open', async function (assert) { + assert.expect(3); + + await this.start({ + autoOpenDiscuss: true, + hasDiscuss: true, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open" + ); + + // simulate closing discuss app + await afterNextRender(() => this.discussWidget.on_detach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 1, + "should have 'new message' when discuss is closed" + ); + + // simulate opening discuss app + await afterNextRender(() => this.discussWidget.on_attach_callback()); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_newMessageButton`).length, + 0, + "should not have 'new message' when discuss is open again" + ); +}); + +QUnit.test('channel preview: basic rendering', async function (assert) { + assert.expect(9); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + record_name: "General", + res_id: 20, + }, + }]; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_sidebar + `).length, + 1, + "preview should have a sidebar" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + `).length, + 1, + "preview should have some content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + `).length, + 1, + "preview should have header in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_header + .o_ThreadPreview_name + `).length, + 1, + "preview should have name in header of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_name + `).textContent, + "General", "preview should have name of channel" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_content + .o_ThreadPreview_core + `).length, + 1, + "preview should have core in content" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).length, + 1, + "preview should have inline text in core of content" + ); + assert.strictEqual( + document.querySelector(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview_core + .o_ThreadPreview_inlineText + `).textContent.trim(), + "Demo: test", + "preview should have message content as inline text of core content" + ); +}); + +QUnit.test('filtered previews', async function (assert) { + assert.expect(12); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + channel_direct_message: [{ + channel_type: "chat", + direct_partner: [{ + id: 7, + name: "Demo", + }], + id: 10, + }], + }, + }); + await this.start({ + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return [{ + id: 20, + last_message: { + author_id: [7, "Demo"], + body: "<p>test</p>", + channel_ids: [20], + id: 100, + message_type: 'comment', + model: 'mail.channel', + res_id: 20, + }, + }, { + id: 10, + last_message: { + author_id: [7, "Demo"], + body: "<p>test2</p>", + channel_ids: [10], + id: 101, + message_type: 'comment', + model: 'mail.channel', + res_id: 10, + }, + }]; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 0, + "should not have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="channel"]').click() + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview + `).length, + 1, + "should have one preview" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 0, + "should not have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of channel" + ); + + await afterNextRender(() => + document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]').click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).length, + 2, + "should have 2 previews" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 10 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of chat" + ); + assert.strictEqual( + document.querySelectorAll(` + .o_MessagingMenu_dropdownMenu + .o_ThreadPreview[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).length, + 1, + "should have preview of channel" + ); +}); + +QUnit.test('open chat window from preview', async function (assert) { + assert.expect(1); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 20, + name: "General", + }], + }, + }); + await this.start({ + hasChatWindow: true, + async mockRPC(route, args) { + if (args.method === 'channel_fetch_preview') { + return []; + } + return this._super(...arguments); + }, + }); + + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_toggler`).click() + ); + await afterNextRender(() => + document.querySelector(`.o_MessagingMenu_dropdownMenu .o_ThreadPreview`).click() + ); + assert.strictEqual( + document.querySelectorAll(`.o_ChatWindow`).length, + 1, + "should have open a chat window" + ); +}); + +QUnit.test('no code injection in message body preview', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: "channel", + id: 1, + name: "General", + }], + }, + }); + + this.data['mail.channel'].records = [{ + id: 1, + name: "general", + channel_type: "channel", + channel_message_ids: [1], + }]; + this.data['mail.message'].records = [{ + id: 1, + author_id: [1, 'Georges'], + body: "<p><em>&shoulnotberaised</em><script>throw new Error('CodeInjectionError');</script></p>", + channel_ids: [1], + }]; + + await this.start(); + + await afterNextRender(() => { + document.querySelector(`.o_MessagingMenu_toggler`).click(); + }); + assert.containsOnce( + document.body, + '.o_MessagingMenu_dropdownMenu .o_ThreadPreview', + "should display a preview", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_core', + "preview should have core in content", + ); + assert.containsOnce( + document.body, + '.o_ThreadPreview_inlineText', + "preview should have inline text in core of content", + ); + assert.strictEqual( + document.querySelector('.o_ThreadPreview_inlineText') + .textContent.replace(/\s/g, ""), + "Georges:&shoulnotberaisedthrownewError('CodeInjectionError');", + "should display correct uninjected last message inline content" + ); + assert.containsNone( + document.querySelector('.o_ThreadPreview_inlineText'), + 'script', + "last message inline content should not have any code injection" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js new file mode 100644 index 00000000000..923480b3f73 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js @@ -0,0 +1,50 @@ +odoo.define('mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.js', function (require) { +'use strict'; + +const { Component } = owl; + +class MobileMessagingNavbar extends Component { + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + this.trigger('o-select-mobile-messaging-navbar-tab', { + tabId: ev.currentTarget.dataset.tabId, + }); + } + +} + +Object.assign(MobileMessagingNavbar, { + defaultProps: { + tabs: [], + }, + props: { + activeTabId: String, + tabs: { + type: Array, + element: { + type: Object, + shape: { + icon: { + type: String, + optional: true, + }, + id: String, + label: String, + }, + }, + }, + }, + template: 'mail.MobileMessagingNavbar', +}); + +return MobileMessagingNavbar; + +}); diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss new file mode 100644 index 00000000000..df0611f9115 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.scss @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_MobileMessagingNavbar { + display: flex; + flex: 0 0 auto; + z-index: 1; +} + +.o_MobileMessagingNavbar_tab { + display: flex; + flex-flow: column; + align-items: center; + flex: 1 1 0; + padding: 8px; +} + +.o_MobileMessagingNavbar_tabIcon { + margin-bottom: 4%; + font-size: 1.3em; +} + +.o_MobileMessagingNavbar_tabLabel { + font-size: 0.8em; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_MobileMessagingNavbar { + background-color: white; + box-shadow: 0 0 8px gray('400'); +} + +.o_MobileMessagingNavbar_tab { + box-shadow: 1px 0 0 gray('400'); + + &.o-active { + color: $o-brand-primary; + } +} diff --git a/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml new file mode 100644 index 00000000000..d60611bf786 --- /dev/null +++ b/addons/mail/static/src/components/mobile_messaging_navbar/mobile_messaging_navbar.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.MobileMessagingNavbar" owl="1"> + <div class="o_MobileMessagingNavbar"> + <t t-foreach="props.tabs" t-as="tab" t-key="tab.id"> + <div class="o_MobileMessagingNavbar_tab" t-att-class="{ 'o-active': props.activeTabId === tab.id }" t-on-click="_onClick" t-att-data-tab-id="tab.id"> + <t t-if="tab.icon"> + <span class="o_MobileMessagingNavbar_tabIcon" t-att-class="tab.icon"/> + </t> + <span class="o_MobileMessagingNavbar_tabLabel"><t t-esc="tab.label"/></span> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js new file mode 100644 index 00000000000..ec260829cf4 --- /dev/null +++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js @@ -0,0 +1,79 @@ +odoo.define('mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.OwlDialog'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ModerationBanDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => this.env.models['mail.message'].get(localId)); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickBan() { + this._dialogRef.comp._close(); + this.env.models['mail.message'].moderate(this.messages, 'ban'); + } + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + +} + +Object.assign(ModerationBanDialog, { + components: { Dialog }, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationBanDialog', +}); + +return ModerationBanDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml new file mode 100644 index 00000000000..d0bc59c19a6 --- /dev/null +++ b/addons/mail/static/src/components/moderation_ban_dialog/moderation_ban_dialog.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationBanDialog" owl="1"> + <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 --> + <t t-set="title">Confirmation</t> + <Dialog contentClass="'o_ModerationBanDialog'" title="title" size="'medium'" t-ref="dialog"> + <t t-if="messages.length === 1"> + <p>You are going to ban the following user:</p> + </t> + <t t-else=""> + <p>You are going to ban the following users:</p> + </t> + <ul class="my-5"> + <t t-foreach="messages" t-as="message" t-key="message.localId"> + <li t-esc="message.email_from"/> + </t> + </ul> + <p>Do you confirm the action?</p> + <t t-set-slot="buttons"> + <button class="o-ban btn btn-primary" t-on-click="_onClickBan">Ban</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js new file mode 100644 index 00000000000..597d2ac3262 --- /dev/null +++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js @@ -0,0 +1,94 @@ +odoo.define('mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.OwlDialog'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ModerationDiscardDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + getText() { + if (this.messages.length === 1) { + return this.env._t("You are going to discard 1 message."); + } + return _.str.sprintf( + this.env._t("You are going to discard %s messages."), + this.messages.length + ); + } + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickDiscard() { + this._dialogRef.comp._close(); + this.env.models['mail.message'].moderate(this.messages, 'discard'); + } + +} + +Object.assign(ModerationDiscardDialog, { + components: { Dialog }, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationDiscardDialog', +}); + +return ModerationDiscardDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml new file mode 100644 index 00000000000..13cafb0f047 --- /dev/null +++ b/addons/mail/static/src/components/moderation_discard_dialog/moderation_discard_dialog.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationDiscardDialog" owl="1"> + <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 --> + <t t-set="title">Confirmation</t> + <Dialog contentClass="'o_ModerationDiscardDialog'" title="title" size="'medium'" t-ref="dialog"> + <p t-esc="getText()"/> + <p>Do you confirm the action?</p> + <t t-set-slot="buttons"> + <button class="o-discard btn btn-primary" t-on-click="_onClickDiscard">Discard</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js new file mode 100644 index 00000000000..95f90e42c7d --- /dev/null +++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const Dialog = require('web.OwlDialog'); + +const { Component, useState } = owl; +const { useRef } = owl.hooks; + +class ModerationRejectDialog extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.state = useState({ + title: this.env._t("Message Rejected"), + comment: this.env._t("Your message was rejected by moderator."), + }); + useStore(props => { + const messages = props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + return { + messages: messages.map(message => message ? message.__state : undefined), + }; + }, { + compareDepth: { + messages: 1, + }, + }); + // to manually trigger the dialog close event + this._dialogRef = useRef('dialog'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.message[]} + */ + get messages() { + return this.props.messageLocalIds.map(localId => + this.env.models['mail.message'].get(localId) + ); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickCancel() { + this._dialogRef.comp._close(); + } + + /** + * @private + */ + _onClickReject() { + this._dialogRef.comp._close(); + const kwargs = { + title: this.state.title, + comment: this.state.comment, + }; + this.env.models['mail.message'].moderate(this.messages, 'reject', kwargs); + } + +} + +Object.assign(ModerationRejectDialog, { + components: { Dialog }, + props: { + messageLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.ModerationRejectDialog', +}); + +return ModerationRejectDialog; + +}); diff --git a/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml new file mode 100644 index 00000000000..a908265d33b --- /dev/null +++ b/addons/mail/static/src/components/moderation_reject_dialog/moderation_reject_dialog.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + <t t-name="mail.ModerationRejectDialog" owl="1"> + <div><!-- TODO FIXME necessary div for useRef, can be removed at next owl update, see https://github.com/odoo/owl/issues/638 --> + <t t-set="title">Send explanation to author</t> + <Dialog contentClass="'o_ModerationRejectDialog'" title="title" size="'medium'" t-ref="dialog"> + <input class="o_ModerationRejectDialog_title form-control" type="text" placeholder="Subject" autofocus="autofocus" t-model="state.title"/> + <textarea class="o_ModerationRejectDialog_comment form-control mt16" placeholder="Mail Body" t-model="state.comment"/> + <t t-set-slot="buttons"> + <button class="o-reject btn btn-primary" t-on-click="_onClickReject">Reject</button> + <button class="o-cancel btn btn-secondary" t-on-click="_onClickCancel">Cancel</button> + </t> + </Dialog> + </div> + </t> +</templates> diff --git a/addons/mail/static/src/components/notification_group/notification_group.js b/addons/mail/static/src/components/notification_group/notification_group.js new file mode 100644 index 00000000000..4020a610dc6 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.js @@ -0,0 +1,85 @@ +odoo.define('mail/static/src/components/notification_group/notification_group.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class NotificationGroup extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const group = this.env.models['mail.notification_group'].get(props.notificationGroupLocalId); + return { + group: group ? group.__state : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.notification_group} + */ + get group() { + return this.env.models['mail.notification_group'].get(this.props.notificationGroupLocalId); + } + + /** + * @returns {string|undefined} + */ + image() { + if (this.group.notification_type === 'email') { + return '/mail/static/src/img/smiley/mailfailure.jpg'; + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.group.openDocuments(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.group.openCancelAction(); + } + +} + +Object.assign(NotificationGroup, { + props: { + notificationGroupLocalId: String, + }, + template: 'mail.NotificationGroup', +}); + +return NotificationGroup; + +}); diff --git a/addons/mail/static/src/components/notification_group/notification_group.scss b/addons/mail/static/src/components/notification_group/notification_group.scss new file mode 100644 index 00000000000..0ba937ec127 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.scss @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_NotificationGroup { + @include o-mail-notification-list-item-layout(); + + &:hover .o_NotificationGroup_markAsRead { + // TODO also mixin this + // task-2258605 + opacity: 1; + } +} + +.o_NotificationGroup_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_NotificationGroup_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_NotificationGroup_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_NotificationGroup_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_NotificationGroup_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_NotificationGroup_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_NotificationGroup_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_NotificationGroup_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_NotificationGroup_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_NotificationGroup_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_NotificationGroup_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_NotificationGroup_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationGroup { + @include o-mail-notification-list-item-style(); +} + +.o_NotificationGroup_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_NotificationGroup_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_NotificationGroup_date { + @include o-mail-notification-list-item-date-style(); +} + +.o_NotificationGroup_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_NotificationGroup_name { + @include o-mail-notification-list-item-bold-style(); +} diff --git a/addons/mail/static/src/components/notification_group/notification_group.xml b/addons/mail/static/src/components/notification_group/notification_group.xml new file mode 100644 index 00000000000..c2f3dcebda1 --- /dev/null +++ b/addons/mail/static/src/components/notification_group/notification_group.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationGroup" owl="1"> + <div class="o_NotificationGroup" t-on-click="_onClick"> + <t t-if="group"> + <div class="o_NotificationGroup_sidebar"> + <div class="o_NotificationGroup_imageContainer o_NotificationGroup_sidebarItem"> + <img class="o_NotificationGroup_image rounded-circle" t-att-src="image()" alt="Message delivery failure image"/> + </div> + </div> + <div class="o_NotificationGroup_content"> + <div class="o_NotificationGroup_header"> + <span class="o_NotificationGroup_name"> + <t t-esc="group.res_model_name"/> + </span> + <span class="o_NotificationGroup_counter"> + (<t t-esc="group.notifications.length"/>) + </span> + <span class="o-autogrow"/> + <span class="o_NotificationGroup_date"> + <t t-esc="group.date.fromNow()"/> + </span> + </div> + <div class="o_NotificationGroup_core"> + <span class="o_NotificationGroup_coreItem o_NotificationGroup_inlineText"> + <t t-if="group.notification_type === 'email'"> + An error occurred when sending an email. + </t> + </span> + <span class="o-autogrow"/> + <span class="o_NotificationGroup_coreItem o_NotificationGroup_markAsRead fa fa-check" title="Discard message delivery failures" t-on-click="_onClickMarkAsRead" t-ref="markAsRead"/> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_list/notification_list.js b/addons/mail/static/src/components/notification_list/notification_list.js new file mode 100644 index 00000000000..5f714afc6d6 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.js @@ -0,0 +1,216 @@ +odoo.define('mail/static/src/components/notification_list/notification_list.js', function (require) { +'use strict'; + +const components = { + NotificationGroup: require('mail/static/src/components/notification_group/notification_group.js'), + ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'), + ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class NotificationList extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + this.storeProps = useStore((...args) => this._useStoreSelector(...args), { + compareDepth: { + // list + notification object created in useStore + notifications: 2, + }, + }); + } + + mounted() { + this._loadPreviews(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object[]} + */ + get notifications() { + const { notifications } = this.storeProps; + return notifications; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Load previews of given thread. Basically consists of fetching all missing + * last messages of each thread. + * + * @private + */ + async _loadPreviews() { + const threads = this.notifications + .filter(notification => notification.thread) + .map(notification => this.env.models['mail.thread'].get(notification.thread)); + this.env.models['mail.thread'].loadPreviews(threads); + } + + /** + * @private + * @param {Object} props + */ + _useStoreSelector(props) { + const threads = this._useStoreSelectorThreads(props); + let threadNeedactionNotifications = []; + if (props.filter === 'all') { + // threads with needactions + threadNeedactionNotifications = this.env.models['mail.thread'] + .all(t => t.model !== 'mail.box' && t.needactionMessages.length > 0) + .sort((t1, t2) => { + if (t1.needactionMessages.length > 0 && t2.needactionMessages.length === 0) { + return -1; + } + if (t1.needactionMessages.length === 0 && t2.needactionMessages.length > 0) { + return 1; + } + if (t1.lastNeedactionMessage && t2.lastNeedactionMessage) { + return t1.lastNeedactionMessage.date.isBefore(t2.lastNeedactionMessage.date) ? 1 : -1; + } + if (t1.lastNeedactionMessage) { + return -1; + } + if (t2.lastNeedactionMessage) { + return 1; + } + return t1.id < t2.id ? -1 : 1; + }) + .map(thread => { + return { + thread, + type: 'thread_needaction', + uniqueId: thread.localId + '_needaction', + }; + }); + } + // thread notifications + const threadNotifications = threads + .sort((t1, t2) => { + if (t1.message_unread_counter > 0 && t2.message_unread_counter === 0) { + return -1; + } + if (t1.message_unread_counter === 0 && t2.message_unread_counter > 0) { + return 1; + } + if (t1.lastMessage && t2.lastMessage) { + return t1.lastMessage.date.isBefore(t2.lastMessage.date) ? 1 : -1; + } + if (t1.lastMessage) { + return -1; + } + if (t2.lastMessage) { + return 1; + } + return t1.id < t2.id ? -1 : 1; + }) + .map(thread => { + return { + thread, + type: 'thread', + uniqueId: thread.localId, + }; + }); + let notifications = threadNeedactionNotifications.concat(threadNotifications); + if (props.filter === 'all') { + const notificationGroups = this.env.messaging.notificationGroupManager.groups; + notifications = Object.values(notificationGroups) + .sort((group1, group2) => + group1.date.isAfter(group2.date) ? -1 : 1 + ).map(notificationGroup => { + return { + notificationGroup, + uniqueId: notificationGroup.localId, + }; + }).concat(notifications); + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + notifications, + }; + } + + /** + * @private + * @param {Object} props + * @throws {Error} in case `props.filter` is not supported + * @returns {mail.thread[]} + */ + _useStoreSelectorThreads(props) { + if (props.filter === 'mailbox') { + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.box') + .sort((mailbox1, mailbox2) => { + if (mailbox1 === this.env.messaging.inbox) { + return -1; + } + if (mailbox2 === this.env.messaging.inbox) { + return 1; + } + if (mailbox1 === this.env.messaging.starred) { + return -1; + } + if (mailbox2 === this.env.messaging.starred) { + return 1; + } + const mailbox1Name = mailbox1.displayName; + const mailbox2Name = mailbox2.displayName; + mailbox1Name < mailbox2Name ? -1 : 1; + }); + } else if (props.filter === 'channel') { + return this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'channel' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else if (props.filter === 'chat') { + return this.env.models['mail.thread'] + .all(thread => + thread.channel_type === 'chat' && + thread.isPinned && + thread.model === 'mail.channel' + ) + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else if (props.filter === 'all') { + // "All" filter is for channels and chats + return this.env.models['mail.thread'] + .all(thread => thread.isPinned && thread.model === 'mail.channel') + .sort((c1, c2) => c1.displayName < c2.displayName ? -1 : 1); + } else { + throw new Error(`Unsupported filter ${props.filter}`); + } + } + +} + +Object.assign(NotificationList, { + _allowedFilters: ['all', 'mailbox', 'channel', 'chat'], + components, + defaultProps: { + filter: 'all', + }, + props: { + filter: { + type: String, + validate: prop => NotificationList._allowedFilters.includes(prop), + }, + }, + template: 'mail.NotificationList', +}); + +return NotificationList; + +}); diff --git a/addons/mail/static/src/components/notification_list/notification_list.scss b/addons/mail/static/src/components/notification_list/notification_list.scss new file mode 100644 index 00000000000..18e3114952c --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.scss @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + + .o_NotificationList { + display: flex; + flex-flow: column; + overflow: auto; + + &.o-empty { + justify-content: center; + } +} + +.o_NotificationList_noConversation { + display: flex; + align-items: center; + justify-content: center; + padding: map-get($spacers, 4) map-get($spacers, 2); +} + +.o_NotificationList_separator { + flex: 0 0 auto; + width: map-get($sizes, 100); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_NotificationList_separator { + border-bottom: $border-width solid $border-color; +} + +.o_NotificationList_noConversation { + color: $text-muted; +} diff --git a/addons/mail/static/src/components/notification_list/notification_list.xml b/addons/mail/static/src/components/notification_list/notification_list.xml new file mode 100644 index 00000000000..bcf65da985d --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationList" owl="1"> + <div class="o_NotificationList" t-att-class="{ 'o-empty': notifications.length === 0 }"> + <t t-if="notifications.length === 0"> + <div class="o_NotificationList_noConversation"> + No conversation yet... + </div> + </t> + <t t-else=""> + <t t-foreach="notifications" t-as="notification" t-key="notification.uniqueId"> + <t t-if="notification.type === 'thread' and notification.thread"> + <ThreadPreview + class="o_NotificationList_preview" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + threadLocalId="notification.thread.localId" + /> + </t> + <t t-if="notification.type === 'thread_needaction' and notification.thread"> + <ThreadNeedactionPreview + class="o_NotificationList_preview" + t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" + threadLocalId="notification.thread.localId" + /> + </t> + <t t-if="notification.notificationGroup"> + <NotificationGroup + class="o_NotificationList_group" + notificationGroupLocalId="notification.notificationGroup.localId" + /> + </t> + <t name="separatorCondition" t-if="!notification_last"> + <div class="o_NotificationList_separator"/> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/notification_list/notification_list_item.scss b/addons/mail/static/src/components/notification_list/notification_list_item.scss new file mode 100644 index 00000000000..265e908a894 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_item.scss @@ -0,0 +1,174 @@ +// ----------------------------------------------------------------------------- +// Layout +// ----------------------------------------------------------------------------- + +@mixin o-mail-notification-list-item-layout { + display: flex; + align-items: center; + padding: map-get($spacers, 1); + + &.o-mobile { + padding: map-get($spacers, 2); + } +} + +@mixin o-mail-notification-list-item-content-layout { + display: flex; + flex-flow: column; + flex: 1 1 auto; + align-self: flex-start; + min-width: 0; // needed for flex to work correctly + margin: map-get($spacers, 2); +} + +@mixin o-mail-notification-list-item-core-layout { + display: flex; +} + +@mixin o-mail-notification-list-item-core-item-layout { + margin: map-get($spacers, 0) map-get($spacers, 2); + + &:first-child { + margin-inline-start: map-get($spacers, 0); + } + + &:last-child { + margin-inline-end: map-get($spacers, 0); + } +} + +@mixin o-mail-notification-list-item-counter-layout() { + margin: map-get($spacers, 0) map-get($spacers, 2); +} + +@mixin o-mail-notification-list-item-date-layout() { + flex: 0 0 auto; +} + +@mixin o-mail-notification-list-item-header-layout { + display: flex; + margin-bottom: map-get($spacers, 1); +} + +@mixin o-mail-notification-list-item-image-layout { + width: map-get($sizes, 100); + height: map-get($sizes, 100); +} + +@mixin o-mail-notification-list-item-image-container-layout { + position: relative; + width: 40px; + height: 40px; +} + +@mixin o-mail-notification-list-item-inline-text-layout { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.o-empty::before { + content: '\00a0'; // keep line-height as if it had content + } +} + +@mixin o-mail-notification-list-item-mark-as-read-layout() { + display: flex; + flex: 0 0 auto; +} + +@mixin o-mail-notification-list-item-name-layout { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.o-mobile { + font-size: 1.1em; + } +} + +@mixin o-mail-notification-list-item-partner-im-status-icon-layout { + @include o-position-absolute($bottom: 0, $right: 0); + display: flex; + align-items: center; + justify-content: center; +} + +@mixin o-mail-notification-list-item-sidebar-layout { + margin: map-get($spacers, 1); +} + +// ----------------------------------------------------------------------------- +// Style +// ----------------------------------------------------------------------------- + +$o-mail-notification-list-item-background-color: $white !default; +$o-mail-notification-list-item-hover-background-color: + darken($o-mail-notification-list-item-background-color, 7%) !default; + +$o-mail-notification-list-item-muted-background-color: gray('100') !default; +$o-mail-notification-list-item-muted-hover-background-color: + darken($o-mail-notification-list-item-muted-background-color, 7%) !default; + +@mixin o-mail-notification-list-item-style { + cursor: pointer; + user-select: none; + background-color: $o-mail-notification-list-item-background-color; + + &:hover { + background-color: $o-mail-notification-list-item-hover-background-color; + } + + &.o-muted { + background-color: $o-mail-notification-list-item-muted-background-color; + + &:hover { + background-color: $o-mail-notification-list-item-muted-hover-background-color; + } + } +} + +@mixin o-mail-notification-list-item-bold-style { + font-weight: bold; + + &.o-muted { + font-weight: initial; + } +} + +@mixin o-mail-notification-list-item-core-style { + color: gray('500'); +} + +@mixin o-mail-notification-list-item-date-style() { + @include o-mail-notification-list-item-bold-style(); + font-size: x-small; + color: $o-brand-primary; +} + +@mixin o-mail-notification-list-item-mark-as-read-style() { + opacity: 0; + + &:hover { + color: gray('600'); + } +} + +@mixin o-mail-notification-list-item-hover-partner-im-status-icon-style { + color: $o-mail-notification-list-item-hover-background-color; +} + +@mixin o-mail-notification-list-item-muted-hover-partner-im-status-icon-style { + color: $o-mail-notification-list-item-muted-hover-background-color; +} + +@mixin o-mail-notification-list-item-partner-im-status-icon-style { + color: $o-mail-notification-list-item-background-color; + + &:not(.o-mobile) { + font-size: x-small; + } + + &.o-muted { + color: $o-mail-notification-list-item-muted-background-color; + } +} diff --git a/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js new file mode 100644 index 00000000000..f76fd920d82 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_notification_group_tests.js @@ -0,0 +1,563 @@ +odoo.define('mail/static/src/components/notification_list/notification_list_notification_group_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const Bus = require('web.Bus'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_notification_group_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' } = {}) => { + const NotificationListComponent = components.NotificationList; + NotificationListComponent.env = this.env; + this.component = new NotificationListComponent(null, { filter }); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.NotificationList.env; + }, +}); + +QUnit.test('notification group basic layout', async function (assert) { + assert.expect(10); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Channel", + }]; + await this.start(); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_name', + "should have 1 group name" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_name').textContent, + "Channel", + "should have model name as group name" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have only 1 notification in the group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_date', + "should have 1 group date" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_date').textContent, + "a few seconds ago", + "should have the group date corresponding to now" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_inlineText', + "should have 1 group text" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_inlineText').textContent.trim(), + "An error occurred when sending an email.", + "should have the group text corresponding to email" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); +}); + +QUnit.test('mark as read', async function (assert) { + assert.expect(6); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Channel", + }]; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action, + 'mail.mail_resend_cancel_action', + "action should be the one to cancel email" + ); + assert.strictEqual( + payload.options.additional_context.default_model, + 'mail.channel', + "action should have the group model as default_model" + ); + assert.strictEqual( + payload.options.additional_context.unread_counter, + 1, + "action should have the group notification length as unread_counter" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup_markAsRead', + "should have 1 mark as read button" + ); + + document.querySelector('.o_NotificationGroup_markAsRead').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the cancel email dialog" + ); +}); + +QUnit.test('grouped notifications by document', async function (assert) { + // If some failures linked to a document refers to a same document, a single + // notification should group all those failures. + assert.expect(9); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Partner", + }, { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 31, + res_model_name: "Partner", + }]; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should have the type act_window" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "action should have the group model as res_model" + ); + assert.strictEqual( + JSON.stringify(payload.action.views), + JSON.stringify([[false, 'form']]), + "action should have form view" + ); + assert.strictEqual( + payload.action.res_id, + 31, + "action should have the group res_id as res_id" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + + document.querySelector('.o_NotificationGroup').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the related record" + ); +}); + +QUnit.test('grouped notifications by document model', async function (assert) { + // If all failures linked to a document model refers to different documents, + // a single notification should group all failures that are linked to this + // document model. + assert.expect(12); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, // key element of this test: a different res_id + res_model_name: "Partner", + }, { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'res.partner', // key element of this test: same model + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 32, // key element of this test: a different res_id + res_model_name: "Partner", + }]; + const bus = new Bus(); + bus.on('do-action', null, payload => { + assert.step('do_action'); + assert.strictEqual( + payload.action.name, + "Mail Failures", + "action should have 'Mail Failures' as name", + ); + assert.strictEqual( + payload.action.type, + 'ir.actions.act_window', + "action should have the type act_window" + ); + assert.strictEqual( + payload.action.view_mode, + 'kanban,list,form', + "action should have 'kanban,list,form' as view_mode" + ); + assert.strictEqual( + JSON.stringify(payload.action.views), + JSON.stringify([[false, 'kanban'], [false, 'list'], [false, 'form']]), + "action should have correct views" + ); + assert.strictEqual( + payload.action.target, + 'current', + "action should have 'current' as target" + ); + assert.strictEqual( + payload.action.res_model, + 'res.partner', + "action should have the group model as res_model" + ); + assert.strictEqual( + JSON.stringify(payload.action.domain), + JSON.stringify([['message_has_error', '=', true]]), + "action should have 'message_has_error' as domain" + ); + }); + + await this.start({ env: { bus } }); + await this.createNotificationListComponent(); + + assert.containsOnce( + document.body, + '.o_NotificationGroup', + "should have 1 notification group" + ); + assert.containsOnce( + document.body, + '.o_NotificationGroup_counter', + "should have 1 group counter" + ); + assert.strictEqual( + document.querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(2)", + "should have 2 notifications in the group" + ); + + document.querySelector('.o_NotificationGroup').click(); + assert.verifySteps( + ['do_action'], + "should do an action to display the related records" + ); +}); + +QUnit.test('different mail.channel are not grouped', async function (assert) { + // `mail.channel` is a special case where notifications are not grouped when + // they are linked to different channels, even though the model is the same. + assert.expect(6); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'mail.channel', // key element of this test: `mail.channel` + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, // key element of this test: a different res_id + res_model_name: "Channel", + }, { + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'mail.channel', // key element of this test: `mail.channel` + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 32, // key element of this test: a different res_id + res_model_name: "Channel", + }]; + await this.start({ + hasChatWindow: true, // needed to assert thread.open + }); + await this.createNotificationListComponent(); + + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); + + await afterNextRender(() => groups[0].click()); + assert.containsOnce( + document.body, + '.o_ChatWindow', + "should have opened the channel related to the first group in a chat window" + ); +}); + +QUnit.test('multiple grouped notifications by document model, sorted by date desc', async function (assert) { + assert.expect(9); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', // key element of this test: different model + notifications: [{ + failure_type: 'SMTP', + id: 21, + notification_status: 'exception', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Partner", + }, { + date: moment.utc().add(1, 'days').format("YYYY-MM-DD HH:mm:ss"), + id: 12, + message_type: 'email', + model: 'res.company', // key element of this test: different model + notifications: [{ + failure_type: 'SMTP', + id: 22, + notification_status: 'bounce', + notification_type: 'email', + partner_id: [42, "Someone else"], + }], + res_id: 32, + res_model_name: "Company", + }]; + await this.start(); + await this.createNotificationListComponent(); + + assert.containsN( + document.body, + '.o_NotificationGroup', + 2, + "should have 2 notifications group" + ); + const groups = document.querySelectorAll('.o_NotificationGroup'); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_name', + "should have 1 group name in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_name').textContent, + "Company", + "should have first model name as group name" + ); + assert.containsOnce( + groups[0], + '.o_NotificationGroup_counter', + "should have 1 group counter in first group" + ); + assert.strictEqual( + groups[0].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in first group" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_name', + "should have 1 group name in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_name').textContent, + "Partner", + "should have second model name as group name" + ); + assert.containsOnce( + groups[1], + '.o_NotificationGroup_counter', + "should have 1 group counter in second group" + ); + assert.strictEqual( + groups[1].querySelector('.o_NotificationGroup_counter').textContent.trim(), + "(1)", + "should have 1 notification in second group" + ); +}); + +QUnit.test('non-failure notifications are ignored', async function (assert) { + assert.expect(1); + + this.data.initMessaging.mail_failures = [{ + date: moment.utc().format("YYYY-MM-DD HH:mm:ss"), + id: 11, + message_type: 'email', + model: 'res.partner', + notifications: [{ + failure_type: false, + id: 21, + // key element of this test: status is not bounce or exception + notification_status: 'ready', + notification_type: 'email', + partner_id: [41, "Someone"], + }], + res_id: 31, + res_model_name: "Partner", + }]; + await this.start(); + await this.createNotificationListComponent(); + + assert.containsNone( + document.body, + '.o_NotificationGroup', + "should have 0 notification group" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/notification_list/notification_list_tests.js b/addons/mail/static/src/components/notification_list/notification_list_tests.js new file mode 100644 index 00000000000..cc71ee2d0f2 --- /dev/null +++ b/addons/mail/static/src/components/notification_list/notification_list_tests.js @@ -0,0 +1,210 @@ +odoo.define('mail/static/src/components/notification_list/notification_list_tests.js', function (require) { +'use strict'; + +const components = { + NotificationList: require('mail/static/src/components/notification_list/notification_list.js'), +}; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('notification_list', {}, function () { +QUnit.module('notification_list_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + /** + * @param {Object} param0 + * @param {string} [param0.filter='all'] + */ + this.createNotificationListComponent = async ({ filter = 'all' }) => { + const NotificationListComponent = components.NotificationList; + NotificationListComponent.env = this.env; + this.component = new NotificationListComponent(null, { filter }); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 100, + name: "Channel 2019", + message_unread_counter: 0, + }, { + channel_type: 'channel', + id: 200, + name: "Channel 2020", + message_unread_counter: 0, + }], + }, + }); + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + this.env = undefined; + delete components.NotificationList.env; + }, +}); + +QUnit.test('marked as read thread notifications are ordered by last message date', async function (assert) { + assert.expect(3); + + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 100, + message_unread_counter: 0, + name: "Channel 2019", + }, { + channel_type: 'channel', + id: 200, + message_unread_counter: 0, + name: "Channel 2020", + }]; + this.data['mail.message'].records = [{ + author_id: [10, "Author A"], + body: "<p>Message A</p>", + channel_ids: [100], + date: "2019-01-01 00:00:00", + id: 42, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }, { + author_id: [20, "Author B"], + body: "<p>Message B</p>", + channel_ids: [200], + date: "2020-01-01 00:00:00", + id: 43, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2020', + res_id: 200, + }]; + await this.start(); + await this.createNotificationListComponent({ filter: 'all' }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should be two thread previews" + ); + const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview'); + assert.strictEqual( + threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2020', + "First channel in the list should be the channel of 2020 (more recent last message)" + ); + assert.strictEqual( + threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2019', + "Second channel in the list should be the channel of 2019 (least recent last message)" + ); +}); + +QUnit.test('thread notifications are re-ordered on receiving a new message', async function (assert) { + assert.expect(4); + + this.data['mail.channel'].records = [{ + channel_type: 'channel', + id: 100, + message_unread_counter: 0, + name: "Channel 2019", + }, { + channel_type: 'channel', + id: 200, + message_unread_counter: 0, + name: "Channel 2020", + }]; + this.data['mail.message'].records = [{ + author_id: [10, "Author A"], + body: "<p>Message A</p>", + channel_ids: [100], + date: "2019-01-01 00:00:00", + id: 42, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }, { + author_id: [20, "Author B"], + body: "<p>Message B</p>", + channel_ids: [200], + date: "2020-01-01 00:00:00", + id: 43, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2020', + res_id: 200, + }]; + await this.start(); + await this.createNotificationListComponent({ filter: 'all' }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should be two thread previews" + ); + + await afterNextRender(() => { + const messageData = { + author_id: [7, "Demo User"], + body: "<p>New message !</p>", + channel_ids: [100], + date: "2020-03-23 10:00:00", + id: 44, + message_type: 'comment', + model: 'mail.channel', + record_name: 'Channel 2019', + res_id: 100, + }; + this.widget.call('bus_service', 'trigger', 'notification', [ + [['my-db', 'mail.channel', 100], messageData] + ]); + }); + assert.containsN( + document.body, + '.o_ThreadPreview', + 2, + "there should still be two thread previews" + ); + const threadPreviewElList = document.querySelectorAll('.o_ThreadPreview'); + assert.strictEqual( + threadPreviewElList[0].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2019', + "First channel in the list should now be 'Channel 2019'" + ); + assert.strictEqual( + threadPreviewElList[1].querySelector(':scope .o_ThreadPreview_name').textContent, + 'Channel 2020', + "Second channel in the list should now be 'Channel 2020'" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.js b/addons/mail/static/src/components/notification_popover/notification_popover.js new file mode 100644 index 00000000000..0f3fce77a58 --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.js @@ -0,0 +1,89 @@ +odoo.define('mail/static/src/components/notification_popover/notification_popover.js', function (require) { +'use strict'; + +const { Component } = owl; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +class NotificationPopover extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const notifications = props.notificationLocalIds.map( + notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId) + ); + return { + notifications: notifications.map(notification => notification ? notification.__state : undefined), + }; + }, { + compareDepth: { + notifications: 1, + }, + }); + } + + /** + * @returns {string} + */ + get iconClass() { + switch (this.notification.notification_status) { + case 'sent': + return 'fa fa-check'; + case 'bounce': + return 'fa fa-exclamation'; + case 'exception': + return 'fa fa-exclamation'; + case 'ready': + return 'fa fa-send-o'; + case 'canceled': + return 'fa fa-trash-o'; + } + return ''; + } + + /** + * @returns {string} + */ + get iconTitle() { + switch (this.notification.notification_status) { + case 'sent': + return this.env._t("Sent"); + case 'bounce': + return this.env._t("Bounced"); + case 'exception': + return this.env._t("Error"); + case 'ready': + return this.env._t("Ready"); + case 'canceled': + return this.env._t("Canceled"); + } + return ''; + } + + /** + * @returns {mail.notification[]} + */ + get notifications() { + return this.props.notificationLocalIds.map( + notificationLocalId => this.env.models['mail.notification'].get(notificationLocalId) + ); + } + +} + +Object.assign(NotificationPopover, { + props: { + notificationLocalIds: { + type: Array, + element: String, + }, + }, + template: 'mail.NotificationPopover', +}); + +return NotificationPopover; + +}); diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.scss b/addons/mail/static/src/components/notification_popover/notification_popover.scss new file mode 100644 index 00000000000..06b4201ca2c --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.scss @@ -0,0 +1,7 @@ +// ----------------------------------------------------------------------------- +// Layout +// ----------------------------------------------------------------------------- + +.o_NotificationPopover_notificationIcon { + margin-inline-end: map-get($spacers, 2); +} diff --git a/addons/mail/static/src/components/notification_popover/notification_popover.xml b/addons/mail/static/src/components/notification_popover/notification_popover.xml new file mode 100644 index 00000000000..cf5aa027672 --- /dev/null +++ b/addons/mail/static/src/components/notification_popover/notification_popover.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.NotificationPopover" owl="1"> + <div class="o_NotificationPopover"> + <t t-foreach="notifications" t-as="notification" t-key="notification.localId"> + <div class="o_NotificationPopover_notification"> + <i class="o_NotificationPopover_notificationIcon" t-att-class="iconClass" t-att-title="iconTitle" role="img"/> + <t t-if="notification.partner"> + <span class="o_NotificationPopover_notificationPartnerName" t-esc="notification.partner.nameOrDisplayName"/> + </t> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js new file mode 100644 index 00000000000..7150c2914a9 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js @@ -0,0 +1,52 @@ +odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class PartnerImStatusIcon extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const partner = this.env.models['mail.partner'].get(props.partnerLocalId); + return { + partner: partner ? partner.__state : undefined, + partnerRoot: this.env.messaging.partnerRoot + ? this.env.messaging.partnerRoot.__state + : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.partner} + */ + get partner() { + return this.env.models['mail.partner'].get(this.props.partnerLocalId); + } + +} + +Object.assign(PartnerImStatusIcon, { + defaultProps: { + hasBackground: true + }, + props: { + partnerLocalId: String, + hasBackground: Boolean, + }, + template: 'mail.PartnerImStatusIcon', +}); + +return PartnerImStatusIcon; + +}); diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss new file mode 100644 index 00000000000..d5791c2f0c6 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.scss @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_PartnerImStatusIcon { + display: flex; + flex-flow: column; + + width: 1.2em; + height: 1.2em; + line-height: 1.3em; +} + +.o_PartnerImStatusIcon_outerBackground { + transform: scale(1.5); +} + +.o-background { + transform: scale(1); + margin-inline-end: map-get($spacers, 1); + margin-top: 2px; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_PartnerImStatusIcon_innerBackground { + color: white; +} + +.o_PartnerImStatusIcon_icon { + + &.o-away { + color: theme-color('warning'); + } + + &.o-bot { + color: $o-enterprise-primary-color; + } + + &.o-offline { + color: gray('700'); + } + + &.o-online { + color: $o-enterprise-primary-color; + } +} + + + + diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml new file mode 100644 index 00000000000..23879cc6b39 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.PartnerImStatusIcon" owl="1"> + <span class="o_PartnerImStatusIcon fa-stack" + t-att-class="{ + 'o-away': partner and partner.im_status === 'away', + 'o-background': !props.hasBackground, + 'o-bot': partner and env.messaging.partnerRoot === partner, + 'o-offline': partner and partner.im_status === 'offline', + 'o-online': partner and partner.im_status === 'online', + }" + t-att-data-partner-local-id="partner ? partner.localId : undefined" + > + <t t-if="partner" name="rootCondition"> + <t t-if="props.hasBackground"> + <i class="o_PartnerImStatusIcon_outerBackground fa fa-circle fa-stack-1x"/> + <i class="o_PartnerImStatusIcon_innerBackground fa fa-circle fa-stack-1x"/> + </t> + <t t-if="partner.im_status === 'online'"> + <i class="o_PartnerImStatusIcon_icon o-online fa fa-circle fa-stack-1x" title="Online" role="img" aria-label="User is online"/> + </t> + <t t-if="partner.im_status === 'away'"> + <i class="o_PartnerImStatusIcon_icon o-away fa fa-circle fa-stack-1x" title="Idle" role="img" aria-label="User is idle"/> + </t> + <t t-if="partner.im_status === 'offline'"> + <i class="o_PartnerImStatusIcon_icon o-offline fa fa-circle-o fa-stack-1x" title="Offline" role="img" aria-label="User is offline"/> + </t> + <t t-if="partner === env.messaging.partnerRoot"> + <i class="o_PartnerImStatusIcon_icon o-bot fa fa-heart fa-stack-1x" title="Bot" role="img" aria-label="User is a bot"/> + </t> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js new file mode 100644 index 00000000000..01246ddcdb3 --- /dev/null +++ b/addons/mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js @@ -0,0 +1,152 @@ +odoo.define('mail/static/src/components/partner_im_status_icon/partner_im_status_icon_tests.js', function (require) { +'use strict'; + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('partner_im_status_icon', {}, function () { +QUnit.module('partner_im_status_icon_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createPartnerImStatusIcon = async partner => { + const PartnerImStatusIconComponent = components.PartnerImStatusIcon; + PartnerImStatusIconComponent.env = this.env; + this.component = new PartnerImStatusIconComponent(null, { partnerLocalId: partner.localId }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.PartnerImStatusIcon.env; + }, +}); + +QUnit.test('initially online', async function (assert) { + assert.expect(3); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'online', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon`).length, + 1, + "should have partner IM status icon" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerImStatusIcon`).dataset.partnerLocalId, + partner.localId, + "partner IM status icon should be linked to partner with ID 7" + ); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering" + ); +}); + +QUnit.test('initially offline', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'offline', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length, + 1, + "partner IM status icon should have offline status rendering" + ); +}); + +QUnit.test('initially away', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'away', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length, + 1, + "partner IM status icon should have away status rendering" + ); +}); + +QUnit.test('change icon on change partner im_status', async function (assert) { + assert.expect(4); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + name: "Demo User", + im_status: 'online', + }); + await this.createPartnerImStatusIcon(partner); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'offline' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-offline`).length, + 1, + "partner IM status icon should have offline status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'away' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-away`).length, + 1, + "partner IM status icon should have away status rendering" + ); + + await afterNextRender(() => partner.update({ im_status: 'online' })); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon.o-online`).length, + 1, + "partner IM status icon should have online status rendering in the end" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js new file mode 100644 index 00000000000..7c39f148264 --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js @@ -0,0 +1,79 @@ +odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js', function (require) { +'use strict'; + +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const components = { + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; + +const { Component } = owl; + +class PartnerMentionSuggestion extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const partner = this.env.models['mail.partner'].get(props.partnerLocalId); + return { + partner: partner ? partner.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.partner} + */ + get partner() { + return this.env.models['mail.partner'].get(this.props.partnerLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onClick(ev) { + // avoid following dummy href + ev.preventDefault(); + this.trigger('o-partner-mention-suggestion-clicked', { + partner: this.partner, + }); + } + + /** + * @private + * @param {Event} ev + */ + _onMouseOver(ev) { + this.trigger('o-partner-mention-suggestion-mouse-over', { + partner: this.partner, + }); + } +} + +Object.assign(PartnerMentionSuggestion, { + components, + defaultProps: { + isActive: false, + }, + props: { + isActive: Boolean, + partnerLocalId: String, + }, + template: 'mail.PartnerMentionSuggestion', +}); + +return PartnerMentionSuggestion; + +}); diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss new file mode 100644 index 00000000000..b93d245972c --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.scss @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_PartnerMentionSuggestion { + display: flex; + width: map-get($sizes, 100); + padding: map-get($spacers, 2) map-get($spacers, 4); +} + +.o_PartnerMentionSuggestion_name { + padding-inline-end: map-get($spacers, 2); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_PartnerMentionSuggestion_email { + font-style: italic; + color: $gray-600; +} + +.o_PartnerMentionSuggestion_name { + font-weight: $font-weight-bold; +} diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml new file mode 100644 index 00000000000..a45e991ab19 --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.PartnerMentionSuggestion" owl="1"> + <a class="o_PartnerMentionSuggestion dropdown-item" t-att-class="{ 'active': props.isActive }" href="#" role="menuitem" t-on-click="_onClick" t-on-mouseover="_onMouseOver"> + <t t-if="partner"> + <PartnerImStatusIcon + hasBackground="false" + partnerLocalId="partner.localId" + /> + <span class="o_PartnerMentionSuggestion_name"><t t-esc="partner.nameOrDisplayName"/></span> + <t t-if="partner.email"> + <span class="o_PartnerMentionSuggestion_email">(<t t-esc="partner.email"/>)</span> + </t> + </t> + </a> + </t> + +</templates> diff --git a/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js new file mode 100644 index 00000000000..e9b31a264ef --- /dev/null +++ b/addons/mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js @@ -0,0 +1,141 @@ +odoo.define('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion_tests.js', function (require) { +'use strict'; + +const components = { + PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'), +}; +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('partner_mention_suggestion', {}, function () { +QUnit.module('partner_mention_suggestion_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.createPartnerMentionSuggestion = async partner => { + const PartnerMentionSuggestionComponent = components.PartnerMentionSuggestion; + PartnerMentionSuggestionComponent.env = this.env; + this.component = new PartnerMentionSuggestionComponent( + null, + { + isActive: true, + partnerLocalId: partner.localId, + }); + await this.component.mount(this.widget.el); + }; + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.PartnerMentionSuggestion.env; + }, +}); + +QUnit.test('partner mention suggestion displayed', async function (assert) { + assert.expect(1); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createPartnerMentionSuggestion(partner); + + assert.containsOnce( + document.body, + `.o_PartnerMentionSuggestion`, + "Partner mention suggestion should be present" + ); +}); + +QUnit.test('partner mention suggestion correct data', async function (assert) { + assert.expect(6); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + email: "demo_user@odoo.com", + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createPartnerMentionSuggestion(partner); + + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "Partner mention suggestion should be present" + ); + assert.strictEqual( + document.querySelectorAll(`.o_PartnerImStatusIcon`).length, + 1, + "Partner's im_status should be displayed" + ); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion_name', + "Partner's name should be present" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerMentionSuggestion_name`).textContent, + "Demo User", + "Partner's name should be displayed" + ); + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion_email', + "Partner's email should be present" + ); + assert.strictEqual( + document.querySelector(`.o_PartnerMentionSuggestion_email`).textContent, + "(demo_user@odoo.com)", + "Partner's email should be displayed" + ); +}); + +QUnit.test('partner mention suggestion active', async function (assert) { + assert.expect(2); + + await this.start(); + const partner = this.env.models['mail.partner'].create({ + email: "demo_user@odoo.com", + id: 7, + im_status: 'online', + name: "Demo User", + }); + await this.createPartnerMentionSuggestion(partner); + + assert.containsOnce( + document.body, + '.o_PartnerMentionSuggestion', + "Partner mention suggestion should be displayed" + ); + assert.hasClass( + document.querySelector('.o_PartnerMentionSuggestion'), + 'active', + "should be active initially" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.js b/addons/mail/static/src/components/thread_icon/thread_icon.js new file mode 100644 index 00000000000..0b82d0c9510 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.js @@ -0,0 +1,54 @@ +odoo.define('mail/static/src/components/thread_icon/thread_icon.js', function (require) { +'use strict'; + +const components = { + ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ThreadIcon extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const correspondent = thread ? thread.correspondent : undefined; + return { + correspondent: correspondent ? correspondent.__state : undefined, + partnerRoot: this.env.messaging.partnerRoot + ? this.env.messaging.partnerRoot.__state + : undefined, + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(ThreadIcon, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadIcon', +}); + +return ThreadIcon; + +}); diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.scss b/addons/mail/static/src/components/thread_icon/thread_icon.scss new file mode 100644 index 00000000000..f27b48d0a8b --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.scss @@ -0,0 +1,26 @@ + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadIcon { + display: flex; + width: 13px; + justify-content: center; +} + +.o_ThreadIcon_typing { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadIcon_away { + color: theme-color('warning'); +} + +.o_ThreadIcon_online { + color: $o-enterprise-primary-color; +} diff --git a/addons/mail/static/src/components/thread_icon/thread_icon.xml b/addons/mail/static/src/components/thread_icon/thread_icon.xml new file mode 100644 index 00000000000..95c4694a661 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadIcon" owl="1"> + <div class="o_ThreadIcon"> + <t t-if="thread" name="rootCondition"> + <t t-if="thread.channel_type === 'channel'"> + <t t-if="thread.public === 'private'"> + <!-- AKU TODO: channel of type 'groups' should maybe also have lock icon --> + <div class="o_ThreadIcon_channelPrivate fa fa-lock" title="Private channel"/> + </t> + <t t-else=""> + <div class="o_ThreadIcon_channelPublic fa fa-hashtag" title="Public channel"/> + </t> + </t> + <t t-elif="thread.channel_type === 'chat' and thread.correspondent"> + <t t-if="thread.orderedOtherTypingMembers.length > 0"> + <ThreadTypingIcon + class="o_ThreadIcon_typing" + animation="'pulse'" + title="thread.typingStatusText" + /> + </t> + <t t-elif="thread.correspondent.im_status === 'online'"> + <div class="o_ThreadIcon_online fa fa-circle" title="Online"/> + </t> + <t t-elif="thread.correspondent.im_status === 'offline'"> + <div class="o_ThreadIcon_offline fa fa-circle-o" title="Offline"/> + </t> + <t t-elif="thread.correspondent.im_status === 'away'"> + <div class="o_ThreadIcon_away fa fa-circle" title="Away"/> + </t> + <t t-elif="thread.correspondent === env.messaging.partnerRoot"> + <div class="o_ThreadIcon_online fa fa-heart" title="Bot"/> + </t> + <t t-else="" name="noImStatusCondition"> + <div class="o_ThreadIcon_noImStatus fa fa-question-circle" title="No IM status available"/> + </t> + </t> + <t t-elif="thread.model === 'mail.box'"> + <t t-if="thread === env.messaging.inbox"> + <div class="o_ThreadIcon_mailboxInbox fa fa-inbox"/> + </t> + <t t-elif="thread === env.messaging.starred"> + <div class="o_ThreadIcon_mailboxStarred fa fa-star-o"/> + </t> + <t t-elif="thread === env.messaging.history"> + <div class="o_ThreadIcon_mailboxHistory fa fa-history"/> + </t> + <t t-elif="thread === env.messaging.moderation"> + <div class="o_ThreadIcon_mailboxModeration fa fa-envelope"/> + </t> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_icon/thread_icon_tests.js b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js new file mode 100644 index 00000000000..c21aef4c393 --- /dev/null +++ b/addons/mail/static/src/components/thread_icon/thread_icon_tests.js @@ -0,0 +1,146 @@ +odoo.define('mail/static/src/components/thread_icon/thread_icon_tests.js', function (require) { +'use strict'; + +const components = { + ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_icon', {}, function () { +QUnit.module('thread_icon_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createThreadIcon = async thread => { + const ThreadIconComponent = components.ThreadIcon; + ThreadIconComponent.env = this.env; + this.component = new ThreadIconComponent(null, { threadLocalId: thread.localId }); + await this.component.mount(this.widget.el); + }; + + this.start = async params => { + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.ThreadIcon.env; + }, +}); + +QUnit.test('chat: correspondent is typing', async function (assert) { + assert.expect(5); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_direct_message: [{ + channel_type: 'chat', + direct_partner: [{ + email: 'demo@odoo.com', + id: 7, + im_status: 'online', + name: "Demo", + }], + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + im_status: 'online', + name: 'Demo', + }], + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadIcon(thread); + + assert.containsOnce( + document.body, + '.o_ThreadIcon', + "should have thread icon" + ); + assert.containsOnce( + document.body, + '.o_ThreadIcon_online', + "should have thread icon with partner im status icon 'online'" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.containsOnce( + document.body, + '.o_ThreadIcon_typing', + "should have thread icon with partner currently typing" + ); + assert.strictEqual( + document.querySelector('.o_ThreadIcon_typing').title, + "Demo is typing...", + "title of icon should tell demo is currently typing" + ); + + // simulate receive typing notification from demo "no longer is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: false, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.containsOnce( + document.body, + '.o_ThreadIcon_online', + "should have thread icon with partner im status icon 'online' (no longer typing)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js new file mode 100644 index 00000000000..efcd8f5d86e --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js @@ -0,0 +1,131 @@ +odoo.define('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) { +'use strict'; + +const components = { + MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadNeedactionPreview extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const mainThreadCache = thread ? thread.mainCache : undefined; + let lastNeedactionMessageAuthor; + let lastNeedactionMessage; + let threadCorrespondent; + if (thread) { + lastNeedactionMessage = mainThreadCache.lastNeedactionMessage; + threadCorrespondent = thread.correspondent; + } + if (lastNeedactionMessage) { + lastNeedactionMessageAuthor = lastNeedactionMessage.author; + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + lastNeedactionMessage: lastNeedactionMessage ? lastNeedactionMessage.__state : undefined, + lastNeedactionMessageAuthor: lastNeedactionMessageAuthor + ? lastNeedactionMessageAuthor.__state + : undefined, + thread: thread ? thread.__state : undefined, + threadCorrespondent: threadCorrespondent + ? threadCorrespondent.__state + : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the image route of the thread. + * + * @returns {string} + */ + image() { + if (this.thread.moduleIcon) { + return this.thread.moduleIcon; + } + if (this.thread.correspondent) { + return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`; + } + if (this.thread.model === 'mail.channel') { + return `/web/image/mail.channel/${this.thread.id}/image_128`; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastNeedactionMessageBody() { + if (!this.thread.lastNeedactionMessage) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastNeedactionMessage.prettyBody); + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.trigger('o-select-thread', { thread: this.thread }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.thread.markNeedactionMessagesAsRead(); + } + +} + +Object.assign(ThreadNeedactionPreview, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadNeedactionPreview', +}); + +return ThreadNeedactionPreview; + +}); diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss new file mode 100644 index 00000000000..08219402ab4 --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.scss @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadNeedactionPreview { + @include o-mail-notification-list-item-layout(); + + &:hover .o_ThreadNeedactionPreview_markAsRead { + opacity: 1; + } +} + +.o_ThreadNeedactionPreview_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_ThreadNeedactionPreview_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_ThreadNeedactionPreview_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_ThreadNeedactionPreview_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_ThreadNeedactionPreview_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_ThreadNeedactionPreview_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_ThreadNeedactionPreview_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_ThreadNeedactionPreview_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_ThreadNeedactionPreview_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_ThreadNeedactionPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_ThreadNeedactionPreview_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_ThreadNeedactionPreview_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadNeedactionPreview { + @include o-mail-notification-list-item-style(); + background-color: rgba($o-brand-primary, 0.1); + + &:hover { + background-color: rgba($o-brand-primary, 0.2); + + .o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } +} + +.o_ThreadNeedactionPreview_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_ThreadNeedactionPreview_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadNeedactionPreview_date { + @include o-mail-notification-list-item-date-style(); +} + +.o_ThreadNeedactionPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_ThreadNeedactionPreview_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadNeedactionPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml new file mode 100644 index 00000000000..82612ed233c --- /dev/null +++ b/addons/mail/static/src/components/thread_needaction_preview/thread_needaction_preview.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadNeedactionPreview" owl="1"> + <!-- + The preview template is used by the discuss in mobile, and by the systray + menu in order to show preview of threads. + --> + <div class="o_ThreadNeedactionPreview" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined"> + <t t-if="thread"> + <div class="o_ThreadNeedactionPreview_sidebar"> + <div class="o_ThreadNeedactionPreview_imageContainer o_ThreadNeedactionPreview_sidebarItem"> + <img class="o_ThreadNeedactionPreview_image" t-att-src="image()" alt="Thread Image"/> + <t t-if="thread.correspondent and thread.correspondent.im_status"> + <PartnerImStatusIcon + class="o_ThreadNeedactionPreview_partnerImStatusIcon" + t-att-class="{ + 'o-mobile': env.messaging.device.isMobile, + }" + partnerLocalId="thread.correspondent.localId" + /> + </t> + </div> + </div> + <div class="o_ThreadNeedactionPreview_content"> + <div class="o_ThreadNeedactionPreview_header"> + <span class="o_ThreadNeedactionPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }"> + <t t-esc="thread.displayName"/> + </span> + <span class="o_ThreadNeedactionPreview_counter"> + (<t t-esc="thread.needactionMessages.length"/>) + </span> + <span class="o-autogrow"/> + <t t-if="thread.lastNeedactionMessage"> + <span class="o_ThreadNeedactionPreview_date"> + <t t-esc="thread.lastNeedactionMessage.date.fromNow()"/> + </span> + </t> + </div> + <div class="o_ThreadNeedactionPreview_core"> + <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_inlineText" t-att-class="{ 'o-empty': inlineLastNeedactionMessageBody.length === 0 }"> + <t t-if="thread.lastMessage and thread.lastMessage.author"> + <MessageAuthorPrefix + messageLocalId="thread.lastMessage.localId" + threadLocalId="thread.localId" + /> + </t> + <t t-raw="inlineLastNeedactionMessageBody"/> + </span> + <span class="o-autogrow"/> + <span class="o_ThreadNeedactionPreview_coreItem o_ThreadNeedactionPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.js b/addons/mail/static/src/components/thread_preview/thread_preview.js new file mode 100644 index 00000000000..f2bbfabe3f9 --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.js @@ -0,0 +1,124 @@ +odoo.define('mail/static/src/components/thread_preview/thread_preview.js', function (require) { +'use strict'; + +const components = { + MessageAuthorPrefix: require('mail/static/src/components/message_author_prefix/message_author_prefix.js'), + PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const mailUtils = require('mail.utils'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadPreview extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + const mainThreadCache = thread ? thread.mainCache : undefined; + let lastMessageAuthor; + let lastMessage; + if (thread) { + const orderedMessages = mainThreadCache.orderedMessages; + lastMessage = orderedMessages[orderedMessages.length - 1]; + } + if (lastMessage) { + lastMessageAuthor = lastMessage.author; + } + return { + isDeviceMobile: this.env.messaging.device.isMobile, + lastMessage: lastMessage ? lastMessage.__state : undefined, + lastMessageAuthor: lastMessageAuthor + ? lastMessageAuthor.__state + : undefined, + thread: thread ? thread.__state : undefined, + threadCorrespondent: thread && thread.correspondent + ? thread.correspondent.__state + : undefined, + }; + }); + /** + * Reference of the "mark as read" button. Useful to disable the + * top-level click handler when clicking on this specific button. + */ + this._markAsReadRef = useRef('markAsRead'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the image route of the thread. + * + * @returns {string} + */ + image() { + if (this.thread.correspondent) { + return `/web/image/res.partner/${this.thread.correspondent.id}/image_128`; + } + return `/web/image/mail.channel/${this.thread.id}/image_128`; + } + + /** + * Get inline content of the last message of this conversation. + * + * @returns {string} + */ + get inlineLastMessageBody() { + if (!this.thread.lastMessage) { + return ''; + } + return mailUtils.htmlToTextContentInline(this.thread.lastMessage.prettyBody); + } + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClick(ev) { + const markAsRead = this._markAsReadRef.el; + if (markAsRead && markAsRead.contains(ev.target)) { + // handled in `_onClickMarkAsRead` + return; + } + this.trigger('o-select-thread', { thread: this.thread }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickMarkAsRead(ev) { + this.thread.markAsSeen(); + } + +} + +Object.assign(ThreadPreview, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadPreview', +}); + +return ThreadPreview; + +}); diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.scss b/addons/mail/static/src/components/thread_preview/thread_preview.scss new file mode 100644 index 00000000000..7e7f8abdf1a --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.scss @@ -0,0 +1,113 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadPreview { + @include o-mail-notification-list-item-layout(); + + &:hover .o_ThreadPreview_markAsRead { + opacity: 1; + } +} + +.o_ThreadPreview_content { + @include o-mail-notification-list-item-content-layout(); +} + +.o_ThreadPreview_core { + @include o-mail-notification-list-item-core-layout(); +} + +.o_ThreadPreview_coreItem { + @include o-mail-notification-list-item-core-item-layout(); +} + +.o_ThreadPreview_counter { + @include o-mail-notification-list-item-counter-layout(); +} + +.o_ThreadPreview_date { + @include o-mail-notification-list-item-date-layout(); +} + +.o_ThreadPreview_header { + @include o-mail-notification-list-item-header-layout(); +} + +.o_ThreadPreview_image { + @include o-mail-notification-list-item-image-layout(); +} + +.o_ThreadPreview_imageContainer { + @include o-mail-notification-list-item-image-container-layout(); +} + +.o_ThreadPreview_inlineText { + @include o-mail-notification-list-item-inline-text-layout(); +} + +.o_ThreadPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-layout(); +} + +.o_ThreadPreview_name { + @include o-mail-notification-list-item-name-layout(); +} + +.o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-layout(); +} + +.o_ThreadPreview_sidebar { + @include o-mail-notification-list-item-sidebar-layout(); +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadPreview { + @include o-mail-notification-list-item-style(); + + &:hover { + .o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-hover-partner-im-status-icon-style(); + } + } + + &.o-muted { + &:hover { + .o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-muted-hover-partner-im-status-icon-style(); + } + } + } +} + +.o_ThreadPreview_core { + @include o-mail-notification-list-item-core-style(); +} + +.o_ThreadPreview_counter { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadPreview_date { + @include o-mail-notification-list-item-date-style(); + + &.o-muted { + color: gray('500'); + } +} + +.o_ThreadPreview_markAsRead { + @include o-mail-notification-list-item-mark-as-read-style(); +} + +.o_ThreadPreview_name { + @include o-mail-notification-list-item-bold-style(); +} + +.o_ThreadPreview_partnerImStatusIcon { + @include o-mail-notification-list-item-partner-im-status-icon-style(); +} diff --git a/addons/mail/static/src/components/thread_preview/thread_preview.xml b/addons/mail/static/src/components/thread_preview/thread_preview.xml new file mode 100644 index 00000000000..ded9c6fadac --- /dev/null +++ b/addons/mail/static/src/components/thread_preview/thread_preview.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadPreview" owl="1"> + <!-- + The preview template is used by the discuss in mobile, and by the systray + menu in order to show preview of threads. + --> + <div class="o_ThreadPreview" t-att-class="{ 'o-muted': thread and thread.message_unread_counter === 0 }" t-on-click="_onClick" t-att-data-thread-local-id="thread ? thread.localId : undefined"> + <t t-if="thread"> + <div class="o_ThreadPreview_sidebar"> + <div class="o_ThreadPreview_imageContainer o_ThreadPreview_sidebarItem"> + <img class="o_ThreadPreview_image rounded-circle" t-att-src="image()" alt="Thread Image"/> + <t t-if="thread.correspondent and thread.correspondent.im_status"> + <PartnerImStatusIcon + class="o_ThreadPreview_partnerImStatusIcon" + t-att-class="{ + 'o-mobile': env.messaging.device.isMobile, + 'o-muted': thread.message_unread_counter === 0, + }" + partnerLocalId="thread.correspondent.localId" + /> + </t> + </div> + </div> + <div class="o_ThreadPreview_content"> + <div class="o_ThreadPreview_header"> + <span class="o_ThreadPreview_name" t-att-class="{ 'o-mobile': env.messaging.device.isMobile, 'o-muted': thread.message_unread_counter === 0 }"> + <t t-esc="thread.displayName"/> + </span> + <t t-if="thread.message_unread_counter > 0"> + <span class="o_ThreadPreview_counter" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }"> + (<t t-esc="thread.message_unread_counter"/>) + </span> + </t> + <span class="o-autogrow"/> + <t t-if="thread.lastMessage"> + <span class="o_ThreadPreview_date" t-att-class="{ 'o-muted': thread.message_unread_counter === 0 }"> + <t t-esc="thread.lastMessage.date.fromNow()"/> + </span> + </t> + </div> + <div class="o_ThreadPreview_core"> + <span class="o_ThreadPreview_coreItem o_ThreadPreview_inlineText" t-att-class="{ 'o-empty': inlineLastMessageBody.length === 0 }"> + <t t-if="thread.lastMessage and thread.lastMessage.author"> + <MessageAuthorPrefix + messageLocalId="thread.lastMessage.localId" + threadLocalId="thread.localId" + /> + </t> + <t t-raw="inlineLastMessageBody"/> + </span> + <span class="o-autogrow"/> + <t t-if="thread.message_unread_counter > 0"> + <span class="o_ThreadPreview_coreItem o_ThreadPreview_markAsRead fa fa-check" title="Mark as Read" t-on-click="_onClickMarkAsRead"/> + </t> + </div> + </div> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js new file mode 100644 index 00000000000..94f8c2b95bb --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js @@ -0,0 +1,49 @@ +odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js', function (require) { +'use strict'; + +const components = { + ThreadTypingIcon: require('mail/static/src/components/thread_typing_icon/thread_typing_icon.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; + +class ThreadTextualTypingStatus extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useStore(props => { + const thread = this.env.models['mail.thread'].get(props.threadLocalId); + return { + thread: thread ? thread.__state : undefined, + }; + }); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {mail.thread} + */ + get thread() { + return this.env.models['mail.thread'].get(this.props.threadLocalId); + } + +} + +Object.assign(ThreadTextualTypingStatus, { + components, + props: { + threadLocalId: String, + }, + template: 'mail.ThreadTextualTypingStatus', +}); + +return ThreadTextualTypingStatus; + +}); diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss new file mode 100644 index 00000000000..4cb9e1cfc3f --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.scss @@ -0,0 +1,12 @@ + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadTextualTypingStatus { + display: flex; +} + +.o_ThreadTextualTypingStatus_separator { + width: map-get($spacers, 1); +} diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml new file mode 100644 index 00000000000..722d07389f8 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadTextualTypingStatus" owl="1"> + <div class="o_ThreadTextualTypingStatus"> + <t t-if="thread and thread.orderedOtherTypingMembers.length > 0"> + <ThreadTypingIcon animation="'pulse'" size="'medium'"/> + <span class="o_ThreadTextualTypingStatus_separator"/> + <span t-esc="thread.typingStatusText"/> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js new file mode 100644 index 00000000000..2d4665e4920 --- /dev/null +++ b/addons/mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js @@ -0,0 +1,480 @@ +odoo.define('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) { +'use strict'; + +const components = { + ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + nextAnimationFrame, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_textual_typing_status', {}, function () { +QUnit.module('thread_textual_typing_status_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.createThreadTextualTypingStatusComponent = async thread => { + const ThreadTextualTypingStatusComponent = components.ThreadTextualTypingStatus; + ThreadTextualTypingStatusComponent.env = this.env; + this.component = new ThreadTextualTypingStatusComponent(null, { + threadLocalId: thread.localId, + }); + await afterNextRender(() => this.component.mount(this.widget.el)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + async afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.ThreadTextualTypingStatus.env; + }, +}); + +QUnit.test('receive other member typing status "is typing"', async function (assert) { + assert.expect(2); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); +}); + +QUnit.test('receive other member typing status "is typing" then "no longer is typing"', async function (assert) { + assert.expect(3); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + // simulate receive typing notification from demo "is no longer typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: false, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing" + ); +}); + +QUnit.test('assume other member typing status becomes "no longer is typing" after 60 seconds without any updated typing status', async function (assert) { + assert.expect(3); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + await afterNextRender(() => this.env.testUtils.advanceTime(60 * 1000)); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing" + ); +}); + +QUnit.test ('other member typing status "is typing" refreshes 60 seconds timer of assuming no longer typing', async function (assert) { + assert.expect(4); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'demo@odoo.com', + id: 7, + name: 'Demo', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + hasTimeControl: true, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from demo "is typing" + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should display that demo user is typing" + ); + + // simulate receive typing notification from demo "is typing" again after 50s. + await this.env.testUtils.advanceTime(50 * 1000); + const typingData = { + info: 'typing_status', + partner_id: 7, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + await this.env.testUtils.advanceTime(50 * 1000); + await nextAnimationFrame(); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Demo is typing...", + "Should still display that demo user is typing after 100 seconds (refreshed is typing status at 50s => (100 - 50) = 50s < 60s after assuming no-longer typing)" + ); + + await afterNextRender(() => this.env.testUtils.advanceTime(11 * 1000)); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should no longer display that demo user is typing after 111 seconds (refreshed is typing status at 50s => (111 - 50) = 61s > 60s after assuming no-longer typing)" + ); +}); + +QUnit.test('receive several other members typing status "is typing"', async function (assert) { + assert.expect(6); + + Object.assign(this.data.initMessaging, { + channel_slots: { + channel_channel: [{ + channel_type: 'channel', + id: 20, + members: [{ + email: 'admin@odoo.com', + id: 3, + name: 'Admin', + }, { + email: 'other10@odoo.com', + id: 10, + name: 'Other10', + }, { + email: 'other11@odoo.com', + id: 11, + name: 'Other11', + }, { + email: 'other12@odoo.com', + id: 12, + name: 'Other12', + }], + name: "General", + }], + }, + }); + await this.start({ + env: { + session: { + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + }, + }, + }); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ); + await this.createThreadTextualTypingStatusComponent(thread); + + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "", + "Should display no one is currently typing" + ); + + // simulate receive typing notification from other10 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 10, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10 is typing...", + "Should display that 'Other10' member is typing" + ); + + // simulate receive typing notification from other11 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 11, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10 and Other11 are typing...", + "Should display that members 'Other10' and 'Other11' are typing (order: longer typer named first)" + ); + + // simulate receive typing notification from other12 (is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 12, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other10, Other11 and more are typing...", + "Should display that members 'Other10', 'Other11' and more (at least 1 extra member) are typing (order: longer typer named first)" + ); + + // simulate receive typing notification from other10 (no longer is typing) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 10, + is_typing: false, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other11 and Other12 are typing...", + "Should display that members 'Other11' and 'Other12' are typing ('Other10' stopped typing)" + ); + + // simulate receive typing notification from other10 (is typing again) + await afterNextRender(() => { + const typingData = { + info: 'typing_status', + partner_id: 10, + is_typing: true, + }; + const notification = [[false, 'mail.channel', 20], typingData]; + this.widget.call('bus_service', 'trigger', 'notification', [notification]); + }); + assert.strictEqual( + document.querySelector('.o_ThreadTextualTypingStatus').textContent, + "Other11, Other12 and more are typing...", + "Should display that members 'Other11' and 'Other12' and more (at least 1 extra member) are typing (order by longer typer, 'Other10' just recently restarted typing)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js new file mode 100644 index 00000000000..95caee984d4 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.js @@ -0,0 +1,32 @@ +odoo.define('mail/static/src/components/thread_typing_icon/thread_typing_icon.js', function (require) { +'use strict'; + +const { Component } = owl; + +class ThreadTypingIcon extends Component {} + +Object.assign(ThreadTypingIcon, { + defaultProps: { + animation: 'none', + size: 'small', + }, + props: { + animation: { + type: String, + validate: prop => ['bounce', 'none', 'pulse'].includes(prop), + }, + size: { + type: String, + validate: prop => ['small', 'medium'].includes(prop), + }, + title: { + type: String, + optional: true, + } + }, + template: 'mail.ThreadTypingIcon', +}); + +return ThreadTypingIcon; + +}); diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss new file mode 100644 index 00000000000..ac3c5b2fb83 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.scss @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------ +// Variables +// ------------------------------------------------------------------ + +$o-thread-typing-icon-size-medium: 5px; +$o-thread-typing-icon-size-small: 3px; + +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon { + display: flex; + align-items: center; +} + +.o_ThreadTypingIcon_dot { + display: flex; + flex: 0 0 auto; +} + +.o_ThreadTypingIcon_separator { + min-width: 1px; + flex: 1 0 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon_dot { + border-radius: 50%; + background: gray('500'); + + &.o-sizeMedium { + width: $o-thread-typing-icon-size-medium; + height: $o-thread-typing-icon-size-medium; + } + + &.o-sizeSmall { + width: $o-thread-typing-icon-size-small; + height: $o-thread-typing-icon-size-small; + } +} + +// ------------------------------------------------------------------ +// Animation +// ------------------------------------------------------------------ + +.o_ThreadTypingIcon_dot.o-animationBounce { + + // Note: duplicated animation because dependent on size, and current SASS version doesn't support var() + &.o-sizeMedium { + animation: o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation 1.5s linear infinite; + } + + &.o-sizeSmall { + animation: o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation 1.5s linear infinite; + } + + &.o_ThreadTypingIcon_dot2 { + animation-delay: -1.35s; + } + + &.o_ThreadTypingIcon_dot3 { + animation-delay: -1.2s; + } +} + +.o_ThreadTypingIcon_dot.o-animationPulse { + animation: o_ThreadTypingIcon_dot_animationPulse_animation 1.5s linear infinite; + + &.o_ThreadTypingIcon_dot2 { + animation-delay: -1.35s; + } + + &.o_ThreadTypingIcon_dot3 { + animation-delay: -1.2s; + } +} + +@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeMedium_animation { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-$o-thread-typing-icon-size-medium); + } +} + +@keyframes o_ThreadTypingIcon_dot_animationBounce_sizeSmall_animation { + 0%, 40%, 100% { + transform: initial; + } + 20% { + transform: translateY(-$o-thread-typing-icon-size-small); + } +} + + +@keyframes o_ThreadTypingIcon_dot_animationPulse_animation { + 0%, 40%, 100% { + opacity: initial; + } + 20% { + opacity: 25%; + } +} diff --git a/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml new file mode 100644 index 00000000000..1bdb4adaf92 --- /dev/null +++ b/addons/mail/static/src/components/thread_typing_icon/thread_typing_icon.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadTypingIcon" owl="1"> + <div class="o_ThreadTypingIcon" t-att-title="props.title"> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot1" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + <span class="o_ThreadTypingIcon_separator"/> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot2" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + <span class="o_ThreadTypingIcon_separator"/> + <span class="o_ThreadTypingIcon_dot o_ThreadTypingIcon_dot3" t-att-class="{ + 'o-animationBounce': props.animation === 'bounce', + 'o-animationPulse': props.animation === 'pulse', + 'o-sizeMedium': props.size === 'medium', + 'o-sizeSmall': props.size === 'small', + }"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.js b/addons/mail/static/src/components/thread_viewer/thread_viewer.js new file mode 100644 index 00000000000..98c3c6edfb6 --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.js @@ -0,0 +1,199 @@ +odoo.define('mail/static/src/components/thread_viewer/thread_viewer.js', function (require) { +'use strict'; + +const components = { + Composer: require('mail/static/src/components/composer/composer.js'), + MessageList: require('mail/static/src/components/message_list/message_list.js'), +}; +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ThreadViewer extends Component { + + /** + * @param {...any} args + */ + constructor(...args) { + super(...args); + useStore((...args) => this._useStoreSelector(...args)); + /** + * Reference of the composer. Useful to set focus on composer when + * thread has the focus. + */ + this._composerRef = useRef('composer'); + /** + * Reference of the message list. Useful to determine scroll positions. + */ + this._messageListRef = useRef('messageList'); + } + + mounted() { + this._update(); + } + + patched() { + this._update(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focus the thread. If it has a composer, focus it. + */ + focus() { + if (!this._composerRef.comp) { + return; + } + this._composerRef.comp.focus(); + } + + /** + * Focusout the thread. + */ + focusout() { + if (!this._composerRef.comp) { + return; + } + this._composerRef.comp.focusout(); + } + + /** + * Get the scroll position in the message list. + * + * @returns {integer|undefined} + */ + getScrollTop() { + if (!this._messageListRef.comp) { + return undefined; + } + return this._messageListRef.comp.getScrollTop(); + } + + /** + * @returns {mail.thread_viewer} + */ + get threadViewer() { + return this.env.models['mail.thread_viewer'].get(this.props.threadViewerLocalId); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when thread component is mounted or patched. + * + * @private + */ + async _update() { + const messageList = this._messageListRef.comp; + this.trigger('o-rendered'); + /** + * Control panel may offset scrolling position of message list due to + * height of buttons. To prevent this, control panel re-render is + * triggered before message list. Correct way should be to adjust + * scroll positions after everything has been rendered, but OWL doesn't + * have such an API for the moment. + */ + if (messageList) { + messageList.adjustFromComponentHints(); + } + } + + /** + * Returns data selected from the store. + * + * @private + * @param {Object} props + * @returns {Object} + */ + _useStoreSelector(props) { + const threadViewer = this.env.models['mail.thread_viewer'].get(props.threadViewerLocalId); + const thread = threadViewer ? threadViewer.thread : undefined; + const threadCache = threadViewer ? threadViewer.threadCache : undefined; + return { + isDeviceMobile: this.env.messaging.device.isMobile, + thread: thread ? thread.__state : undefined, + threadCache: threadCache ? threadCache.__state : undefined, + threadViewer: threadViewer ? threadViewer.__state : undefined, + }; + } + +} + +Object.assign(ThreadViewer, { + components, + defaultProps: { + composerAttachmentsDetailsMode: 'auto', + hasComposer: false, + hasMessageCheckbox: false, + hasSquashCloseMessages: false, + haveMessagesAuthorRedirect: false, + haveMessagesMarkAsReadIcon: false, + haveMessagesReplyIcon: false, + order: 'asc', + showComposerAttachmentsExtensions: true, + showComposerAttachmentsFilenames: true, + }, + props: { + composerAttachmentsDetailsMode: { + type: String, + validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop), + }, + hasComposer: Boolean, + hasComposerCurrentPartnerAvatar: { + type: Boolean, + optional: true, + }, + hasComposerSendButton: { + type: Boolean, + optional: true, + }, + /** + * If set, determines whether the composer should display status of + * members typing on related thread. When this prop is not provided, + * it defaults to composer component default value. + */ + hasComposerThreadTyping: { + type: Boolean, + optional: true, + }, + hasMessageCheckbox: Boolean, + hasSquashCloseMessages: Boolean, + haveMessagesAuthorRedirect: Boolean, + haveMessagesMarkAsReadIcon: Boolean, + haveMessagesReplyIcon: Boolean, + /** + * Set the initial scroll position of message list on mount. Note that + * this prop is not directly passed to message list as props because + * it may compute scroll top without composer, and then composer may alter + * them on mount. To solve this issue, thread handles setting initial scroll + * positions, so that this is always done after composer has been mounted. + * (child `mounted()` are called before parent `mounted()`) + */ + messageListInitialScrollTop: { + type: Number, + optional: true + }, + order: { + type: String, + validate: prop => ['asc', 'desc'].includes(prop), + }, + selectedMessageLocalId: { + type: String, + optional: true, + }, + showComposerAttachmentsExtensions: Boolean, + showComposerAttachmentsFilenames: Boolean, + threadViewerLocalId: String, + }, + template: 'mail.ThreadViewer', +}); + +return ThreadViewer; + +}); diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.scss b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss new file mode 100644 index 00000000000..9b39a493979 --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.scss @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_ThreadViewer { + display: flex; + position: relative; + flex-flow: column; + overflow: auto; +} + +.o_ThreadViewer_composer { + flex: 0 0 auto; +} + +.o_ThreadViewer_loading { + display: flex; + align-self: center; + flex: 1 1 auto; + align-items: center; +} + +.o_ThreadViewer_messageList { + flex: 1 1 auto; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_ThreadViewer { + background-color: gray('100'); + +} + +.o_ThreadViewer_loadingIcon { + margin-right: 3px; +} diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.xml b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml new file mode 100644 index 00000000000..e292ac4e4ec --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.ThreadViewer" owl="1"> + <div class="o_ThreadViewer" t-att-data-thread-local-id="threadViewer and threadViewer.thread and threadViewer.thread.localId"> + <t t-if="threadViewer"> + <t t-if="threadViewer.isShowingLoading" name="loadingCondition"> + <div class="o_ThreadViewer_loading"> + <span><i class="o_ThreadViewer_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span> + </div> + </t> + <t t-elif="threadViewer.threadCache.isLoaded"> + <MessageList + class="o_ThreadViewer_messageList" + hasMessageCheckbox="props.hasMessageCheckbox" + hasSquashCloseMessages="props.hasSquashCloseMessages" + haveMessagesAuthorRedirect="props.haveMessagesAuthorRedirect" + haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon" + haveMessagesReplyIcon="props.haveMessagesReplyIcon" + order="props.order" + selectedMessageLocalId="props.selectedMessageLocalId" + threadViewerLocalId="threadViewer.localId" + t-ref="messageList" + /> + </t> + <t t-if="props.hasComposer"> + <Composer + class="o_ThreadViewer_composer" + attachmentsDetailsMode="props.composerAttachmentsDetailsMode" + composerLocalId="threadViewer.thread.composer.localId" + hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar" + hasSendButton="props.hasComposerSendButton" + hasThreadTyping="props.hasComposerThreadTyping" + isFocusOnMount="!env.messaging.device.isMobile" + showAttachmentsExtensions="props.showComposerAttachmentsExtensions" + showAttachmentsFilenames="props.showComposerAttachmentsFilenames" + t-ref="composer" + /> + </t> + </t> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js new file mode 100644 index 00000000000..1c7d24a86e1 --- /dev/null +++ b/addons/mail/static/src/components/thread_viewer/thread_viewer_tests.js @@ -0,0 +1,364 @@ +odoo.define('mail/static/src/components/thread_viewer/thread_viewer_tests.js', function (require) { +'use strict'; + +const components = { + ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'), +}; +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + dragenterFiles, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('components', {}, function () { +QUnit.module('thread_viewer', {}, function () { +QUnit.module('thread_viewer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + /** + * @param {mail.thread_viewer} threadViewer + * @param {Object} [otherProps={}] + * @param {Object} [param2={}] + * @param {boolean} [param2.isFixedSize=false] + */ + this.createThreadViewerComponent = async (threadViewer, otherProps = {}, { isFixedSize = false } = {}) => { + const ThreadViewerComponent = components.ThreadViewer; + ThreadViewerComponent.env = this.env; + this.component = new ThreadViewerComponent( + null, + Object.assign({ threadViewerLocalId: threadViewer.localId }, otherProps) + ); + let target; + if (isFixedSize) { + // needed to allow scrolling in some tests + const div = document.createElement('div'); + Object.assign(div.style, { + display: 'flex', + 'flex-flow': 'column', + height: '300px', + }); + this.widget.el.append(div); + target = div; + } else { + target = this.widget.el; + } + await afterNextRender(() => this.component.mount(target)); + }; + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + if (this.component) { + this.component.destroy(); + } + if (this.widget) { + this.widget.destroy(); + } + this.env = undefined; + delete components.ThreadViewer.env; + }, +}); + +QUnit.test('dragover files on thread with composer', async function (assert) { + assert.expect(1); + + await this.start(); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer, { hasComposer: true }); + await afterNextRender(() => + dragenterFiles(document.querySelector('.o_ThreadViewer')) + ); + assert.ok( + document.querySelector('.o_Composer_dropZone'), + "should have dropzone when dragging file over the thread" + ); +}); + +QUnit.test('message list desc order', async function (assert) { + assert.expect(8); + + let lastId = 10000; + let amountOfCalls = 0; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + amountOfCalls ++; + assert.step(`message_fetch_${amountOfCalls}`); + // Just return 30 different messages + const messagesData = [...Array(30).keys()].reduce(function (acc, i) { + acc.push({ + author_id: [i + 1, `Author #${i}`], + body: `<p>The message</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: lastId - i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + return acc; + }, []); + lastId -= 30; + return messagesData; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer, { order: 'desc' }, { isFixedSize: true }); + const messageItems = document.querySelectorAll(`.o_MessageList_item`); + assert.notOk( + messageItems[0].classList.contains("o_MessageList_loadMore"), + "load more link should NOT be before messages" + ); + assert.ok( + messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"), + "load more link should be after messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "should have 30 messages at the beginning" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = + document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "should have 60 messages after scrolled to bottom" + ); + + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "scrolling to top should not trigger any message fetching" + ); + assert.verifySteps(['message_fetch_1', 'message_fetch_2']); +}); + +QUnit.test('message list asc order', async function (assert) { + assert.expect(8); + + let lastId = 10000; + let amountOfCalls = 0; + await this.start({ + async mockRPC(route, args) { + if (args.method === 'message_fetch') { + amountOfCalls ++; + assert.step(`message_fetch_${amountOfCalls}`); + // Just return 30 different messages + const messagesData = [...Array(30).keys()].reduce(function (acc, i) { + acc.push({ + author_id: [i + 1, `Author #${i}`], + body: `<p>The message</p>`, + channel_ids: [20], + date: "2019-04-20 10:00:00", + id: lastId - i, + message_type: 'comment', + model: 'mail.channel', + record_name: 'General', + res_id: 20, + }); + return acc; + }, []); + lastId -= 30; + return messagesData; + } + return this._super(...arguments); + }, + }); + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [ + { + email: "john@example.com", + id: 9, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + name: "General", + public: 'public', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer, { order: 'asc' }, { isFixedSize: true }); + const messageItems = document.querySelectorAll(`.o_MessageList_item`); + assert.notOk( + messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"), + "load more link should be before messages" + ); + assert.ok( + messageItems[0].classList.contains("o_MessageList_loadMore"), + "load more link should NOT be after messages" + ); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 30, + "should have 30 messages at the beginning" + ); + + // scroll to top + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = 0; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "should have 60 messages after scrolled to top" + ); + + // scroll to bottom + await afterNextRender(() => { + document.querySelector(`.o_ThreadViewer_messageList`).scrollTop = + document.querySelector(`.o_ThreadViewer_messageList`).scrollHeight; + }); + assert.strictEqual( + document.querySelectorAll(`.o_Message`).length, + 60, + "scrolling to bottom should not trigger any message fetching" + ); + assert.verifySteps(['message_fetch_1', 'message_fetch_2']); +}); + +QUnit.test('mark channel as fetched when a new message is loaded and as seen when message is visible', async function (assert) { + assert.expect(7); + + await this.start({ + mockRPC(route, args) { + if (args.method === 'channel_fetched') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_fetched is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_fetched is called on the right channel model' + ); + assert.step('rpc_channel_fetched:called'); + } else if (args.method === 'channel_seen') { + assert.strictEqual( + args.args[0][0], + 100, + 'channel_seen is called on the right channel id' + ); + assert.strictEqual( + args.model, + 'mail.channel', + 'channel_seeb is called on the right channel model' + ); + assert.step('rpc_channel_seen:called'); + } + return this._super(...arguments); + } + }); + const thread = this.env.models['mail.thread'].create({ + id: 100, + isPinned: true, // just to avoid joinChannel to be called + members: [['insert', [ + { + email: "john@example.com", + id: this.env.session.partner_id, + name: "John", + }, + { + email: "fred@example.com", + id: 10, + name: "Fred", + }, + ]]], + model: 'mail.channel', + }); + const threadViewer = this.env.models['mail.thread_viewer'].create({ thread: [['link', thread]] }); + await this.createThreadViewerComponent(threadViewer); + const notifications = [ + [['myDB', 'mail.channel', 100], { + channelId: 100, + id: 1, + body: "<p>fdsfsd</p>", + author_id: [10, "Fred"], + model: "mail.channel", + channel_ids: [100], + }] + ]; + await afterNextRender(() => { + this.widget.call('bus_service', 'trigger', 'notification', notifications); + }); + assert.verifySteps( + ['rpc_channel_fetched:called', 'rpc_channel_seen:called'], + "Channel should have been fetched before having been seen" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/env/env.js b/addons/mail/static/src/env/env.js new file mode 100644 index 00000000000..ea760ba6e11 --- /dev/null +++ b/addons/mail/static/src/env/env.js @@ -0,0 +1,104 @@ +odoo.define('mail/static/src/env/env.js', function (require) { +'use strict'; + +const ModelManager = require('mail/static/src/model/model_manager.js'); + +const env = require('web.commonEnv'); + +const { Store } = owl; +const { EventBus } = owl.core; + +/** + * Messaging store + */ +const store = new Store({ + env, + state: {}, +}); + +/** + * Registry of models. + */ +env.models = {}; +/** + * Environment keys used in messaging. + */ +Object.assign(env, { + autofetchPartnerImStatus: true, + destroyMessaging() { + if (env.modelManager) { + env.modelManager.deleteAll(); + env.messaging = undefined; + } + }, + disableAnimation: false, + isMessagingInitialized() { + if (!this.messaging) { + return false; + } + return this.messaging.isInitialized; + }, + loadingBaseDelayDuration: 400, + messaging: undefined, + messagingBus: new EventBus(), + modelManager: new ModelManager(), + store, +}); + +/** + * Components cannot use web.bus, because they cannot use + * EventDispatcherMixin, and webclient cannot easily access env. + * Communication between webclient and components by core.bus + * (usable by webclient) and messagingBus (usable by components), which + * the messaging service acts as mediator since it can easily use both + * kinds of buses. + */ +env.bus.on( + 'hide_home_menu', + null, + () => env.messagingBus.trigger('hide_home_menu') +); +env.bus.on( + 'show_home_menu', + null, + () => env.messagingBus.trigger('show_home_menu') +); +env.bus.on( + 'will_hide_home_menu', + null, + () => env.messagingBus.trigger('will_hide_home_menu') +); +env.bus.on( + 'will_show_home_menu', + null, + () => env.messagingBus.trigger('will_show_home_menu') +); + +(async function start() { + await new Promise(resolve => { + /** + * Called when all JS resources are loaded. This is useful in order + * to do some processing after other JS files have been parsed, for + * example new models or patched models that are coming from + * other modules, because some of those patches might need to be + * applied before messaging initialization. + */ + window.addEventListener('load', resolve); + }); + /** + * All JS resources are loaded, but not necessarily processed. + * We assume no messaging-related modules return any Promise, + * therefore they should be processed *at most* asynchronously at + * "Promise time". + */ + await new Promise(resolve => setTimeout(resolve)); + + env.modelManager.start(env); + /** + * Create the messaging singleton record. + */ + env.messaging = env.models['mail.messaging'].create(); + await env.messaging.start(); +})(); + +}); diff --git a/addons/mail/static/src/env/test_env.js b/addons/mail/static/src/env/test_env.js new file mode 100644 index 00000000000..75e921c27c7 --- /dev/null +++ b/addons/mail/static/src/env/test_env.js @@ -0,0 +1,152 @@ +odoo.define('mail/static/src/env/test_env.js', function (require) { +'use strict'; + +const ModelManager = require('mail/static/src/model/model_manager.js'); +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); + +const { Store } = owl; +const { EventBus } = owl.core; + +/** + * @param {Object} [providedEnv={}] + * @returns {Object} + */ +function addMessagingToEnv(providedEnv = {}) { + + const defaultEnv = Object.assign({}, providedEnv, { + session: Object.assign({ + is_bound: Promise.resolve(), + name: 'Admin', + partner_display_name: 'Your Company, Admin', + partner_id: 3, + uid: 2, + url: s => s, + }, providedEnv.session) + }); + + let env = Object.assign({ + loadingBaseDelayDuration: defaultEnv.loadingBaseDelayDuration || 0, + }, defaultEnv); + + /** + * Messaging store + */ + const store = new Store({ + env, + state: {}, + }); + + /** + * Registry of models. + */ + env.models = {}; + /** + * Environment keys used in messaging. + */ + Object.assign(env, { + autofetchPartnerImStatus: false, + browser: Object.assign({ + innerHeight: 1080, + innerWidth: 1920, + Notification: Object.assign({ + permission: 'denied', + async requestPermission() { + return this.permission; + }, + }, (env.browser && env.browser.Notification) || {}), + }, env.browser), + destroyMessaging() { + if (env.modelManager) { + env.modelManager.deleteAll(); + env.messaging = undefined; + } + }, + disableAnimation: true, + isMessagingInitialized() { + if (!this.messaging) { + return false; + } + return this.messaging.isInitialized; + }, + messaging: undefined, + messagingInitializedDeferred: makeDeferred(), + messagingBus: new EventBus(), + modelManager: new ModelManager(), + store, + }); + + return env; +} + +/** + * @param {Object} [providedEnv={}] + * @returns {Object} + */ +function addTimeControlToEnv(providedEnv = {}) { + + let env = Object.assign({}, providedEnv); + + if (!env.browser) { + env.browser = {}; + } + // list of timeout ids that have timed out. + let timedOutIds = []; + // key: timeoutId, value: func + remaining duration + const timeouts = new Map(); + Object.assign(env.browser, { + clearTimeout: id => { + timeouts.delete(id); + timedOutIds = timedOutIds.filter(i => i !== id); + }, + setTimeout: (func, duration) => { + const timeoutId = _.uniqueId('timeout_'); + const timeout = { + id: timeoutId, + isTimedOut: false, + func, + duration, + }; + timeouts.set(timeoutId, timeout); + if (duration === 0) { + timedOutIds.push(timeoutId); + timeout.isTimedOut = true; + } + return timeoutId; + }, + }); + if (!env.testUtils) { + env.testUtils = {}; + } + Object.assign(env.testUtils, { + advanceTime: async duration => { + await nextTick(); + for (const id of timeouts.keys()) { + const timeout = timeouts.get(id); + if (timeout.isTimedOut) { + continue; + } + timeout.duration = Math.max(timeout.duration - duration, 0); + if (timeout.duration === 0) { + timedOutIds.push(id); + } + } + while (timedOutIds.length > 0) { + const id = timedOutIds.shift(); + const timeout = timeouts.get(id); + timeouts.delete(id); + timeout.func(); + await nextTick(); + } + await nextTick(); + }, + }); + return env; +} + +return { + addMessagingToEnv, + addTimeControlToEnv, +}; + +}); diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 8a59c976ba3..d7a8f6bf9b2 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -213,7 +213,6 @@ var BasicActivity = AbstractField.extend({ /** * @private * @param {integer} id - * @param {integer} previousActivityTypeID * @param {function} callback * @return {Promise} */ @@ -544,6 +543,7 @@ var BasicActivity = AbstractField.extend({ // ----------------------------------------------------------------------------- // Activities Widget for Form views ('mail_activity' widget) // ----------------------------------------------------------------------------- +// FIXME seems to still be needed in some cases like systray var Activity = BasicActivity.extend({ className: 'o_mail_activity', events: _.extend({}, BasicActivity.prototype.events, { @@ -859,7 +859,6 @@ var ActivityException = AbstractField.extend({ }); field_registry - .add('mail_activity', Activity) .add('kanban_activity', KanbanActivity) .add('list_activity', ListActivity) .add('activity_exception', ActivityException); diff --git a/addons/mail/static/src/js/attachment_box.js b/addons/mail/static/src/js/attachment_box.js deleted file mode 100644 index 507bb38ce49..00000000000 --- a/addons/mail/static/src/js/attachment_box.js +++ /dev/null @@ -1,141 +0,0 @@ -odoo.define('mail.AttachmentBox', function (require) { -"use strict"; - -var core = require('web.core'); -var Widget = require('web.Widget'); - -var DocumentViewer = require('mail.DocumentViewer'); - -var QWeb = core.qweb; -var _t = core._t; - -var AttachmentBox = Widget.extend({ - template: 'mail.chatter.AttachmentBox', - events: { - "click .o_attachment_download": "_onAttachmentDownload", - "click .o_attachment_view": "_onAttachmentView", - "click .o_attachment_delete_cross": "_onDeleteAttachment", - "click .o_upload_attachments_button": "_onUploadAttachments", - "change .o_chatter_attachment_form .o_form_binary_form": "_onAddAttachment", - }, - /** - * @override - * @param {string} record.model - * @param {Number} record.res_id - * @param {Object[]} attachments - */ - init: function (parent, record, attachments) { - this._super.apply(this, arguments); - this.fileuploadId = _.uniqueId('oe_fileupload'); - $(window).on(this.fileuploadId, this._onUploaded.bind(this)); - this.currentResID = record.res_id; - this.currentResModel = record.model; - this.attachmentIDs = attachments; - this.imageList = {}; - this.otherList = {}; - - _.each(attachments, function (attachment) { - // required for compatibility with the chatter templates. - attachment.url = '/web/content/' + attachment.id + '?download=true'; - attachment.filename = attachment.name || _t('unnamed'); - }); - var sortedAttachments = _.partition(attachments, function (att) { - return att.mimetype && att.mimetype.split('/')[0] === 'image'; - }); - this.imageList = sortedAttachments[0]; - this.otherList = sortedAttachments[1]; - }, - /** - * @override - */ - destroy: function () { - $(window).off(this.fileuploadId); - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object} record - */ - update: function (record) { - this.currentResID = record.res_id; - this.currentResModel = record.model; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Method triggered when user click on 'add attachment' and select a file - * - * @private - * @param {MouseEvent} ev - */ - _onAddAttachment: function (ev) { - var $input = $(ev.currentTarget).find('input.o_input_file'); - if ($input.val() !== '') { - var $binaryForm = this.$('form.o_form_binary_form'); - $binaryForm.submit(); - } - }, - /** - * @private - * @param {MouseEvent} ev - * used to prevent the click from opening the document viewer. - */ - _onAttachmentDownload: function (ev) { - ev.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAttachmentView: function (ev) { - ev.stopPropagation(); - ev.preventDefault(); - var activeAttachmentID = $(ev.currentTarget).data('id'); - if (activeAttachmentID) { - var attachmentViewer = new DocumentViewer(this, this.attachmentIDs, activeAttachmentID); - attachmentViewer.appendTo($('body')); - } - }, - /** - * Opens File Explorer dialog if all fields are valid and record is saved - * - * @private - * @param {MouseEvent} ev - */ - _onUploadAttachments: function (ev) { - this.$('input.o_input_file').click(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onDeleteAttachment: function (ev) { - ev.stopPropagation(); - var $target = $(ev.currentTarget); - this.trigger_up('delete_attachment', { - attachmentId: $target.data('id'), - attachmentName: $target.data('name') - }); - }, - /** - * @private - */ - _onUploaded: function(ev, response) { - if (response.error) { - this.do_warn(false, _t("Attachment upload not authorized here")); - } else { - this.trigger_up('reload_attachment_box'); - } - }, -}); - -return AttachmentBox; - -}); diff --git a/addons/mail/static/src/js/basic_model.js b/addons/mail/static/src/js/basic_model.js deleted file mode 100644 index 11139819a60..00000000000 --- a/addons/mail/static/src/js/basic_model.js +++ /dev/null @@ -1,33 +0,0 @@ -odoo.define('mail.basic_model', function (require) { -"use strict"; - -var BasicModel = require('web.BasicModel'); - -/** - * Include the BasicModel to update the datapoint on the model when a - * message is posted. - */ -BasicModel.include({ - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Update the message ids on a datapoint. - * - * Note that we directly update the res_ids on the datapoint as the message - * has already been posted ; this change can't be handled 'normally' with - * x2m commands because the change won't be saved as a normal field. - * - * @param {string} id - * @param {integer[]} msgIDs - */ - updateMessageIDs: function (id, msgIDs) { - var element = this.localData[id]; - element.res_ids = msgIDs; - element.count = msgIDs.length; - }, -}); - -}); diff --git a/addons/mail/static/src/js/basic_view.js b/addons/mail/static/src/js/basic_view.js index a7ce716b460..042e0ded8e8 100644 --- a/addons/mail/static/src/js/basic_view.js +++ b/addons/mail/static/src/js/basic_view.js @@ -1,17 +1,29 @@ odoo.define('mail.BasicView', function (require) { "use strict"; -var BasicView = require('web.BasicView'); +const BasicView = require('web.BasicView'); -var mailWidgets = ['mail_followers', 'mail_thread', 'mail_activity', 'kanban_activity']; +const mailWidgets = ['kanban_activity']; BasicView.include({ init: function () { this._super.apply(this, arguments); + const post_refresh = this._getFieldOption('message_ids', 'post_refresh', false); + this.chatterFields = { + hasActivityIds: this._hasField('activity_ids'), + hasMessageFollowerIds: this._hasField('message_follower_ids'), + hasMessageIds: this._hasField('message_ids'), + hasRecordReloadOnAttachmentsChanged: post_refresh === 'always', + hasRecordReloadOnMessagePosted: !!post_refresh, + isActivityBoxVisible: this._getFieldOption('message_ids', 'open_attachments', false), + }; + const fieldsInfo = this.fieldsInfo[this.viewType]; + this.rendererParams.chatterFields = this.chatterFields; + + // LEGACY for widget kanban_activity this.mailFields = {}; - var fieldsInfo = this.fieldsInfo[this.viewType]; - for (var fieldName in fieldsInfo) { - var fieldInfo = fieldsInfo[fieldName]; + for (const fieldName in fieldsInfo) { + const fieldInfo = fieldsInfo[fieldName]; if (_.contains(mailWidgets, fieldInfo.widget)) { this.mailFields[fieldInfo.widget] = fieldName; fieldInfo.__no_fetch = true; @@ -20,6 +32,32 @@ BasicView.include({ this.rendererParams.activeActions = this.controllerParams.activeActions; this.rendererParams.mailFields = this.mailFields; }, + /** + * Gets the option value of a field if present. + * + * @private + * @param {string} fieldName the desired field name + * @param {string} optionName the desired option name + * @param {*} defaultValue the default value if option or field is not found. + * @returns {*} + */ + _getFieldOption(fieldName, optionName, defaultValue) { + const field = this.fieldsInfo[this.viewType][fieldName]; + if (field && field.options && field.options[optionName] !== undefined) { + return field.options[optionName]; + } + return defaultValue; + }, + /** + * Checks whether the view has a given field. + * + * @private + * @param {string} fieldName the desired field name + * @returns {boolean} + */ + _hasField(fieldName) { + return !!this.fieldsInfo[this.viewType][fieldName]; + }, }); }); diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js deleted file mode 100644 index 2b7186a46e3..00000000000 --- a/addons/mail/static/src/js/chatter.js +++ /dev/null @@ -1,660 +0,0 @@ -odoo.define('mail.Chatter', function (require) { -"use strict"; - -var Activity = require('mail.Activity'); -var AttachmentBox = require('mail.AttachmentBox'); -var ChatterComposer = require('mail.composer.Chatter'); -var Dialog = require('web.Dialog'); -var Followers = require('mail.Followers'); -var ThreadField = require('mail.ThreadField'); -var mailUtils = require('mail.utils'); - -var concurrency = require('web.concurrency'); -var config = require('web.config'); -var core = require('web.core'); -var Widget = require('web.Widget'); - -var _t = core._t; -var QWeb = core.qweb; - -// The purpose of this widget is to display the chatter area below the form view -// -// It instantiates the optional mail_thread, mail_activity and mail_followers widgets. -// It Ensures that those widgets are appended at the right place, and allows them to communicate -// with each other. -// It synchronizes the rendering of those widgets (as they may be asynchronous), to mitigate -// the flickering when switching between records -var Chatter = Widget.extend({ - template: 'mail.Chatter', - custom_events: { - delete_attachment: '_onDeleteAttachment', - discard_record_changes: '_onDiscardRecordChanges', - reload_attachment_box: '_onReloadAttachmentBox', - reload_mail_fields: '_onReloadMailFields', - reset_suggested_partners: '_onResetSuggestedPartners', - }, - events: { - 'click .o_chatter_button_new_message': '_onOpenComposerMessage', - 'click .o_chatter_button_log_note': '_onOpenComposerNote', - 'click .o_chatter_button_attachment': '_onClickAttachmentButton', - 'click .o_chatter_button_schedule_activity': '_onScheduleActivity', - }, - supportedFieldTypes: ['one2many'], - - /** - * @override - * @param {widget} parent - * @param {Object} record - * @param {Object} mailFields - * @param {string} [mailFields.mail_activity] - * @param {string} [mailFields.mail_followers] - * @param {string} [mailFields.mail_thread] - * @param {Object} options - * @param {string} [options.viewType=record.viewType] current viewType in - * which the chatter is instantiated - */ - init: function (parent, record, mailFields, options) { - this._super.apply(this, arguments); - this._setState(record); - - this.attachments = {}; - this.fields = {}; - this._areAttachmentsLoaded = false; - this._disableAttachmentBox = !!options.disable_attachment_box; - this._dp = new concurrency.DropPrevious(); - this._isAttachmentBoxOpen = false; - this._isComposerOpen = false; - // mention: get the prefetched partners and use them as mention suggestions - // if there is a follower widget, the followers will be added to the - // suggestions as well once fetched - this._mentionPartnerSuggestions = this.call('mail_service', 'getMentionPartnerSuggestions'); - this._mentionSuggestions = this._mentionPartnerSuggestions; - /** - * List of fetched suggested partners. This is lazy-loaded on opening - * composer. `undefined` means it should be fetched again. - */ - this._suggestedPartners = undefined; - - if (mailFields.mail_activity) { - this.fields.activity = new Activity(this, mailFields.mail_activity, record, options); - } - if (mailFields.mail_followers) { - this.fields.followers = new Followers(this, mailFields.mail_followers, record, options); - } - if (mailFields.mail_thread) { - this.fields.thread = new ThreadField(this, mailFields.mail_thread, record, options); - var fieldsInfo = record.fieldsInfo[options.viewType || record.viewType]; - var nodeOptions = fieldsInfo[mailFields.mail_thread].options || {}; - this.hasLogButton = options.display_log_button || nodeOptions.display_log_button; - this.postRefresh = nodeOptions.post_refresh || 'never'; - this.reloadOnUploadAttachment = this.postRefresh === 'always'; - this.openAttachments = nodeOptions.open_attachments || false; - } - }, - /** - * @override - */ - start: function () { - this._$topbar = this.$('.o_chatter_topbar'); - if(!this._disableAttachmentBox) { - this.$('.o_topbar_right_area').append(QWeb.render('mail.chatter.Attachment.Button', { - count: this.record.data.message_attachment_count || 0, - })); - } - // render and append the buttons - this._$topbar.prepend(this._renderButtons()); - // start and append the widgets - var fieldDefs = _.invoke(this.fields, 'appendTo', $('<div>')); - var def = this._dp.add(Promise.all(fieldDefs)); - this._render(def) - .then(this._updateMentionSuggestions.bind(this)) - .then(() => { - if (this.openAttachments) { - this._fetchAttachments().then(() => this._openAttachmentBox()); - } - }); - - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object} record - * @param {integer} [record.res_id=undefined] - * @param {Object[]} [fieldNames=undefined] - */ - update: function (record, fieldNames) { - var self = this; - - // close the composer if we switch to another record as it is record dependent - if (this.record.res_id !== record.res_id) { - this._closeComposer(true); - this._closeAttachments(); - this._areAttachmentsLoaded = false; - this.attachments = {}; - } - - // update the state - this._setState(record); - - // detach the thread and activity widgets (temporarily force the height to prevent flickering) - // keep the followers in the DOM as it has a synchronous pre-rendering - this.$el.height(this.$el.height()); - if (this.fields.activity) { - this.fields.activity.$el.detach(); - } - if (this.fields.thread && this.fields.thread.$el ) { - this.fields.thread.$el.detach(); - } - - // reset and re-append the widgets (and reset 'height: auto' rule) - // if fieldNames is given, only reset those fields, otherwise reset all fields - var fieldsToReset; - if (fieldNames) { - fieldsToReset = _.filter(this.fields, function (field) { - return _.contains(fieldNames, field.name); - }); - } else { - fieldsToReset = this.fields; - } - var fieldDefs = _.invoke(fieldsToReset, 'reset', record); - var def = this._dp.add(Promise.all(fieldDefs)); - this._render(def).then(function () { - self.$el.height('auto'); - self._updateMentionSuggestions(); - }); - this._updateAttachmentCounter(); - }, - async updateSuggestedPartners() { - this._suggestedPartners = undefined; - if (this._composer && this._isComposerOpen) { - const suggestedPartners = await this._getSuggestedPartners(); - this._composer.updateSuggestedPartners(suggestedPartners); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _closeAttachments: function () { - if (this.fields.attachments) { - this.$('.o_chatter_button_attachment').removeClass('o_active_attach'); - this.fields.attachments.destroy(); - this._isAttachmentBoxOpen = false; - } - }, - /** - * @private - * @param {boolean} force - */ - _closeComposer: function (force) { - if (this._composer && (this._composer.isEmpty() || force)) { - this.$el.removeClass('o_chatter_composer_active'); - this.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active'); - this._composer.do_hide(); - this._composer.clearComposer(); - this._isComposerOpen = false; - } - }, - /** - * @private - */ - _disableChatter: function () { - this.$('.btn').prop('disabled', true); // disable buttons - }, - /** - * @private - */ - _disableComposer: function () { - this.$(".o_composer_button_send").prop('disabled', true); - }, - /** - * Discard changes on the record. - * - * @private - * @returns {Promise} resolved if successfully discarding changes on - * the record, rejected otherwise - */ - _discardChanges: function () { - var self = this; - return new Promise(function (resolve, reject) { - self.trigger_up('discard_changes', { - recordID: self.record.id, - onSuccess: resolve, - onFailure: reject, - }); - }); - }, - /** - * Discard changes on the record if the message will reload the record - * after posting it - * - * @private - * @param {Object} messageData - * @return {Promise} resolved if no reload or proceed to discard the - * changes on the record, rejected otherwise - */ - _discardOnReload: function (messageData) { - if (this._reloadAfterPost(messageData)) { - return this._discardChanges(); - } - return Promise.resolve(); - }, - /** - * @private - */ - _enableChatter: function () { - this.$('.btn').prop('disabled', false); // enable buttons - }, - /** - * @private - */ - _enableComposer: function () { - this.$(".o_composer_button_send").prop('disabled', false); - }, - /* - * @private - * @return {Deferred} - */ - _fetchAttachments: function () { - var self = this; - var domain = [ - ['res_id', '=', this.record.res_id], - ['res_model', '=', this.record.model], - ]; - return this._rpc({ - model: 'ir.attachment', - method: 'search_read', - domain: domain, - fields: ['id', 'name', 'mimetype'], - }).then(function (result) { - self._areAttachmentsLoaded = true; - self.attachments = result; - }); - - }, - /** - * @private - */ - async _getSuggestedPartners() { - if (this._suggestedPartners) { - return this._suggestedPartners; - } - const result = await this._rpc({ - route: '/mail/get_suggested_recipients', - params: { - model: this.record.model, - res_ids: [this.context.default_res_id], - }, - }); - const suggestedPartners = []; - const threadRecipients = result[this.context.default_res_id] || []; - for (const recipient of threadRecipients) { - const parsedEmail = recipient[1] && mailUtils.parseEmail(recipient[1]); - suggestedPartners.push({ - checked: true, - partner_id: recipient[0], - full_name: recipient[1], - name: parsedEmail[0], - email_address: parsedEmail[1], - reason: recipient[2], - }); - } - this._suggestedPartners = suggestedPartners; - return this._suggestedPartners; - }, - /** - * @private - */ - _openAttachmentBox: function () { - if (this.fields.attachments) { - this._closeAttachments(); - } - this.fields.attachments = new AttachmentBox(this, this.record, this.attachments); - var $anchor = this.$('.o_chatter_topbar'); - if (this._composer) { - var $anchor = this.$('.o_thread_composer'); - } else { - var $anchor = this.$('.o_chatter_topbar'); - } - this.fields.attachments.insertAfter($anchor); - this.$('.o_chatter_button_attachment').addClass('o_active_attach'); - - this._isAttachmentBoxOpen = true; - }, - /** - * @private - * @param {Object} options - * @param {Object[]} [options.suggestedPartners=[]] - * @param {boolean} [options.isLog] - */ - _openComposer: function (options) { - var self = this; - var oldComposer = this._composer; - // create the new composer - this._composer = new ChatterComposer(this, this.record.model, options.suggestedPartners || [], { - commandsEnabled: false, - context: this.context, - inputMinHeight: 50, - isLog: options && options.isLog, - recordName: this.recordName, - defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(), - defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(), - attachmentIds: (oldComposer && oldComposer.get('attachment_ids')) || [], - }); - this._composer.on('input_focused', this, function () { - this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []); - }); - this._composer.insertAfter(this.$('.o_chatter_topbar')).then(function () { - // destroy existing composer - if (oldComposer) { - oldComposer.destroy(); - } - self._composer.focus(); - self._composer.on('post_message', self, function (messageData) { - self._discardOnReload(messageData).then(function () { - self._disableComposer(); - self.fields.thread.postMessage(messageData).then(function () { - self._closeComposer(true); - if (self._reloadAfterPost(messageData)) { - self.trigger_up('reload'); - } else if (messageData.attachment_ids.length) { - self._reloadAttachmentBox(); - self.trigger_up('reload', {fieldNames: ['message_attachment_count'], keepChanges: true}); - } - }).guardedCatch(function () { - self._enableComposer(); - }); - }); - }); - self._composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload')); - self._composer.on('close_composer', null, self._closeComposer.bind(self, true)); - - self._isComposerOpen = true; - self.$el.addClass('o_chatter_composer_active'); - self.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active'); - self.$('.o_chatter_button_new_message').toggleClass('o_active', !self._composer.options.isLog); - self.$('.o_chatter_button_log_note').toggleClass('o_active', self._composer.options.isLog); - }); - }, - /** - * State if the record will be reloaded after posting a message. - * Useful to warn the user of unsaved changes if the record is dirty. - * - * @private - * @param {Object} messageData - * @param {Array} [messageData.partner_ids] list of recipients of a message - * @return {boolean} true if record will be reloaded after posting the - * message, false otherwise - */ - _reloadAfterPost: function (messageData) { - return this.postRefresh === 'always' || - ( - this.postRefresh === 'recipients' && - messageData.partner_ids && - messageData.partner_ids.length - ); - }, - /** - * @private - */ - _reloadAttachmentBox: function () { - this._areAttachmentsLoaded = false; - if (this._isAttachmentBoxOpen) { - this._fetchAttachments().then(this._openAttachmentBox.bind(this)); - } - if (!this._disableAttachmentBox) { - this.trigger_up('reload', { fieldNames: ['message_attachment_count'], keepChanges: true }); - } - }, - /** - * @private - * @param {Deferred} def - * @returns {Deferred} - */ - _render: function (def) { - // the rendering of the chatter is aynchronous: relational data of its fields needs to be - // fetched (in some case, it might be synchronous as they hold an internal cache). - // this function takes a promise as argument, which is resolved once all fields have - // fetched their data - // this function appends the fields where they should be once the given promise is resolved - // and if it takes more than 500ms, displays a spinner to indicate that it is loading - var self = this; - - var $spinner = $(QWeb.render('Spinner')); - concurrency.rejectAfter(concurrency.delay(500), def).then(function () { - $spinner.appendTo(self.$el); - }); - var always = function () { - // disable widgets in create mode, otherwise enable - self._isCreateMode ? self._disableChatter() : self._enableChatter(); - $spinner.remove(); - }; - - return def.then(function () { - if (self.fields.activity) { - self.fields.activity.$el.appendTo(self.$el); - } - if (self.fields.followers) { - if (self._disableAttachmentBox) { - self.fields.followers.$el.appendTo(self.$('.o_topbar_right_area')); - } else { - self.fields.followers.$el.insertBefore(self.$('.o_chatter_button_attachment')); - } - } - if (self.fields.thread && self.fields.thread.$el) { - self.fields.thread.$el.appendTo(self.$el); - } - }).then(always).guardedCatch(always); - }, - _renderButtons: function () { - return QWeb.render('mail.chatter.Buttons', { - newMessageButton: !!this.fields.thread, - logNoteButton: this.hasLogButton, - scheduleActivityButton: !!this.fields.activity, - isMobile: config.device.isMobile, - }); - }, - /** - * @private - * @param {Object} record - * @param {integer} [record.res_id] - * @param {string} [record.model] - * @param {string} record.data.display_name - */ - _setState: function (record) { - - this._isCreateMode = !record.res_id; - - if (!this.record || this.record.res_id !== record.res_id) { - this.context = { - default_res_id: record.res_id || false, - default_model: record.model || false, - }; - // ensure a reload of the suggested partners when - // opening the composer on another record - this._suggestedPartners = undefined; - } - this.record = record; - this.recordName = record.data.display_name; - }, - /** - * @private - */ - _updateAttachmentCounter: function () { - var count = this.record.data.message_attachment_count || 0; - var $element = this.$('.o_chatter_attachment_button_count'); - if (Number($element.html()) !== count) { - this._areAttachmentsLoaded = false; - $element.html(count); - } - }, - /** - * @private - */ - _updateMentionSuggestions: function () { - if (!this.fields.followers) { - return; - } - var self = this; - - this._mentionSuggestions = []; - - // add the followers to the mention suggestions - var followerSuggestions = []; - var followers = this.fields.followers.getFollowers(); - _.each(followers, function (follower) { - if (follower.res_model === 'res.partner') { - followerSuggestions.push({ - id: follower.res_id, - name: follower.name, - email: follower.email, - }); - } - }); - if (followerSuggestions.length) { - this._mentionSuggestions.push(followerSuggestions); - } - - // add the partners (followers filtered out) to the mention suggestions - _.each(this._mentionPartnerSuggestions, function (partners) { - self._mentionSuggestions.push(_.filter(partners, function (partner) { - return !_.findWhere(followerSuggestions, { id: partner.id }); - })); - }); - }, - - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {OdooEvent} ev - * @param {integer} ev.data.attachmentId - * @param {String} ev.data.attachmentName - */ - _onDeleteAttachment: function (ev) { - ev.stopPropagation(); - var self = this; - var options = { - confirm_callback: function () { - self._rpc({ - model: 'ir.attachment', - method: 'unlink', - args: [parseInt(ev.data.attachmentId, 10)], - }) - .then(function () { - self._reloadAttachmentBox(); - if (self.fields.thread) { - self.fields.thread.removeAttachments([ev.data.attachmentId]); - } - self.trigger_up('reload'); - }); - } - }; - var promptText = _.str.sprintf(_t("Do you really want to delete %s?"), _.escape(ev.data.attachmentName)); - Dialog.confirm(this, promptText, options); - }, - /** - * @private - */ - _onClickAttachmentButton: function () { - if(this._disableAttachmentBox) { - return; - } - if (this._isAttachmentBoxOpen) { - this._closeAttachments(); - } else { - var def; - if (!this._areAttachmentsLoaded) { - def = this._fetchAttachments(); - } - Promise.resolve(def).then(this._openAttachmentBox.bind(this)); - } - }, - /** - * Discard changes on the record. - * This is notified by the composer, when opening the full-composer. - * - * @private - * @param {OdooEvent} ev - * @param {function} ev.data.proceed callback to tell to proceed - */ - _onDiscardRecordChanges: function (ev) { - this._discardChanges().then(ev.data.proceed); - }, - async _onOpenComposerMessage() { - const suggestedPartners = await this._getSuggestedPartners(); - this._openComposer({ - isLog: false, - suggestedPartners, - }); - }, - /** - * @private - */ - _onOpenComposerNote: function () { - this._openComposer({ isLog: true }); - }, - /** - * @private - */ - _onReloadAttachmentBox: function () { - if (this.reloadOnUploadAttachment) { - this.trigger_up('reload'); - } - this._reloadAttachmentBox(); - }, - /** - * @private - * @param {OdooEvent} event - * @param {string} event.name - * @param {Object} event.data - * @param {boolean} [event.data.activity] - * @param {boolean} [event.data.followers] - * @param {boolean} [event.data.thread] - */ - _onReloadMailFields: function (event) { - var fieldNames = []; - if (this.fields.activity && event.data.activity) { - fieldNames.push(this.fields.activity.name); - } - if (this.fields.followers && event.data.followers) { - fieldNames.push(this.fields.followers.name); - } - if (this.fields.thread && event.data.thread) { - fieldNames.push(this.fields.thread.name); - } - this.trigger_up('reload', { - fieldNames: fieldNames, - keepChanges: true, - }); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onResetSuggestedPartners(ev) { - ev.stopPropagation(); - this._suggestedPartners = undefined; - }, - /** - * @private - */ - _onScheduleActivity: function () { - this.fields.activity.scheduleActivity(); - }, -}); - -return Chatter; - -}); diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js deleted file mode 100644 index ffde77eac9a..00000000000 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ /dev/null @@ -1,864 +0,0 @@ -odoo.define('mail.composer.Basic', function (require) { -"use strict"; - -var emojis = require('mail.emojis'); -var MentionManager = require('mail.composer.MentionManager'); -var DocumentViewer = require('mail.DocumentViewer'); -var utils = require('web.utils'); - -var config = require('web.config'); -var core = require('web.core'); -var data = require('web.data'); -var dom = require('web.dom'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var BasicComposer = Widget.extend({ - template: "mail.Composer", - events: { - 'change input.o_input_file': '_onAttachmentChange', - 'click .o_attachment_delete': '_onAttachmentDelete', - 'click .o_attachment_download': '_onAttachmentDownload', - 'click .o_attachment_view': '_onAttachmentView', - 'click .o_composer_button_add_attachment': '_onClickAddAttachment', - 'click .o_composer_button_emoji': '_onEmojiButtonClick', - 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout', - 'mousedown .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick', - 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus', - 'dragover .o_file_drop_zone_container': '_onFileDragover', - 'drop .o_file_drop_zone_container': '_onFileDrop', - 'input .o_input': '_onInput', - 'keydown .o_composer_input textarea': '_onKeydown', - 'keyup .o_composer_input': '_onKeyup', - 'click .o_composer_button_send': '_sendMessage', - }, - // RPCs done to fetch the mention suggestions are throttled with the - // following value - MENTION_THROTTLE: 200, - - init: function (parent, options) { - this._super.apply(this, arguments); - this.options = _.defaults(options || {}, { - commandsEnabled: true, - context: {}, - inputMinHeight: 28, - mentionFetchLimit: 8, - // set to true to only suggest prefetched partners - mentionPartnersRestricted: false, - sendText: _t("Send"), - defaultBody: '', - defaultMentionSelections: {}, - isMobile: config.device.isMobile - }); - this.context = this.options.context; - - // Attachments - this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); - this.fileuploadID = _.uniqueId('o_chat_fileupload'); - this.set('attachment_ids', options.attachmentIds || []); - - // Mention - this._mentionManager = new MentionManager(this); - this._mentionManager.register({ - delimiter: '@', - fetchCallback: this._mentionFetchPartners.bind(this), - generateLinks: true, - model: 'res.partner', - redirectClassname: 'o_mail_redirect', - selection: this.options.defaultMentionSelections['@'], - suggestionTemplate: 'mail.MentionSuggestions.Partner', - }); - this._mentionManager.register({ - delimiter: '#', - fetchCallback: this._mentionFetchChannels.bind(this), - generateLinks: true, - model: 'mail.channel', - redirectClassname: 'o_channel_redirect', - selection: this.options.defaultMentionSelections['#'], - suggestionTemplate: 'mail.MentionSuggestions.Channel', - }); - this._mentionManager.register({ - delimiter: ':', - fetchCallback: this._mentionGetCannedResponses.bind(this), - selection: this.options.defaultMentionSelections[':'], - suggestionTemplate: 'mail.MentionSuggestions.CannedResponse', - }); - if (this.options.commandsEnabled) { - this._mentionManager.register({ - beginningOnly: true, - delimiter: '/', - fetchCallback: this._mentionGetCommands.bind(this), - selection: this.options.defaultMentionSelections['/'], - suggestionTemplate: 'mail.MentionSuggestions.Command', - }); - } - - this.isMini = options.isMini; - - this.avatarURL = session.uid > 0 ? session.url('/web/image', { - model: 'res.users', - field: 'image_128', - id: session.uid, - }) : '/web/static/src/img/user_menu_avatar.png'; - }, - start: function () { - var self = this; - - this._$attachmentButton = this.$('.o_composer_button_add_attachment'); - this._$attachmentsList = this.$('.o_composer_attachments_list'); - this.$input = this.$('.o_composer_input textarea'); - this.$input.focus(function () { - self.trigger('input_focused'); - }); - this.$input.val(this.options.defaultBody); - dom.autoresize(this.$input, { - parent: this, - min_height: this.options.inputMinHeight - }); - - // Attachments - this._renderAttachments(); - $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this)); - this.on('change:attachment_ids', this, this._renderAttachments); - - this.call('mail_service', 'getMailBus') - .on('update_typing_partners', this, this._onUpdateTypingPartners); - - // Mention - var prependPromise = this._mentionManager.prependTo(this.$('.o_composer')); - - // Drag-Drop files - // Allowing body to detect dragenter and dragleave for display - var $body = $('body'); - this._dropZoneNS = _.uniqueId('o_dz_'); // For event namespace used when multiple chat window is open - $body.on('dragleave.' + this._dropZoneNS, this._onBodyFileDragLeave.bind(this)); - $body.on("dragover." + this._dropZoneNS, this._onBodyFileDragover.bind(this)); - $body.on("drop." + this._dropZoneNS, this._onBodyFileDrop.bind(this)); - return Promise.all([this._super(), prependPromise]); - }, - destroy: function () { - $("body").off('dragleave.' + this._dropZoneNS); - $("body").off('dragover.' + this._dropZoneNS); - $("body").off('drop.' + this._dropZoneNS); - $(window).off(this.fileuploadID); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Empty input, selected partners and attachments - */ - clearComposer: function () { - this.$input.val(''); - this._mentionManager.resetSelections(); - this.set('attachment_ids', []); - }, - /** - * Set the focus on the input - */ - focus: function () { - this.$input.focus(); - }, - /** - * Get cursor position and selection - * - * @returns {Object} a current cursor position as { start: {integer}, end: {integer} } - */ - getSelectionPositions: function () { - var InputElement = this.$input.get(0); - return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 }; - }, - /** - * Get the state of the composer. - * - * This is useful in order to (re)store its state when switchng to another - * thread with a composer, and coming back to the thread with this composer. - * - * @returns {Object} - */ - getState: function () { - return { - attachments: this.get('attachment_ids'), - text: this.$input.val(), - }; - }, - /** - * @returns {integer} it returns length of attachment and trim content of - * input box to check for empty. - */ - isEmpty: function () { - return !this.$input.val().trim() && - !this.$('.o_attachments').children().length; - }, - /** - * Set the state of the composer. - * - * This is useful in order to (re)store its state when switching to another - * thread with a composer, and coming back to the thread with this composer. - * - * @param {Object} state - * @param {Array} state.attachments - * @param {string} state.text - */ - setState: function (state) { - this.set('attachment_ids', state.attachments); - this.$input.val(state.text); - }, - /** - * Set the thread that this composer refers to. - * - * @param {mail.model.Thread} thread - */ - setThread: function (thread) { - this.options.thread = thread; - }, - /** - * Set the list of command suggestions on the thread. - * - * When the user partially types a command, matching commands are displayed - * to the user (@see _mentionGetCommands). - * - * @param {Object[]} commands - */ - mentionSetCommands: function (commands) { - this._mentionCommands = commands; - }, - /** - * Set the list of partner mentions suggestions that has been prefetched. - * - * When the user partially types a mention, matching prefetched partners are - * displayed to the user. If none of them match, then it will fetch for more - * partner suggestions (@see _mentionFetchPartners). - * - * @param {Promise<Object[]>} prefetchedPartners list of list of - * prefetched partners. - */ - mentionSetPrefetchedPartners: function (prefetchedPartners) { - this._mentionPrefetchedPartners = prefetchedPartners; - }, - /** - * @returns {Object} containing listener selections, grouped by delimiter - * as key of the object. - */ - getMentionListenerSelections: function () { - return this._mentionManager.getListenerSelections(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _clearComposerOnSend: function () { - this.clearComposer(); - }, - /** - * @private - */ - _doCheckAttachmentUpload: function () { - if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) { - this.do_warn(false, _t("Please wait while the file is uploading...")); - return false; - } - return true; - }, - /** - * Hides the emojis container. - * - * @private - */ - _hideEmojis: function () { - this._$emojisContainer.remove(); - }, - /** - * Making sure that dragging content is external files. - * Ignoring other content draging like text. - * - * @private - * @param {DataTransfer} dataTransfer - * @returns {boolean} - */ - _isDragSourceExternalFile: function (dataTransfer) { - var DragDataType = dataTransfer.types; - if (DragDataType.constructor === DOMStringList) { - return DragDataType.contains('Files'); - } - if (DragDataType.constructor === Array) { - return DragDataType.indexOf('Files') !== -1; - } - return false; - }, - /** - * @private - * @param {string} search - * @returns {Promise<Object[]>} - */ - _mentionGetCannedResponses: function (search) { - var self = this; - var def = new Promise(function (resolve, reject) { - clearTimeout(self._cannedTimeout); - self._cannedTimeout = setTimeout(function () { - var cannedResponses = self.call('mail_service', 'getCannedResponses'); - var matches = fuzzy.filter(utils.unaccent(search), _.pluck(cannedResponses, 'source')); - var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index'); - resolve(_.map(indexes, function (index) { - return cannedResponses[index]; - })); - }, 500); - }); - return def; - }, - /** - * @private - * @param {string} search - * @returns {Array} - */ - _mentionGetCommands: function (search) { - var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); - return _.filter(this._mentionCommands, function (command) { - return searchRegexp.test(command.name); - }).slice(0, this.options.mentionFetchLimit); - }, - /** - * @private - * @param {string} search - */ - _mentionFetchChannels: function (search) { - return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', { - limit: this.options.mentionFetchLimit, - search: search, - }).then(function (suggestions) { - return _.partition(suggestions, function (suggestion) { - return _.contains(['public', 'groups'], suggestion.public); - }); - }); - }, - /** - * @private - * @param {string} search - */ - _mentionFetchPartners: function (search) { - var self = this; - return Promise.resolve(this._mentionPrefetchedPartners).then(function (prefetchedPartners) { - // filter prefetched partners with the given search string - var suggestions = []; - var limit = self.options.mentionFetchLimit; - var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); - _.each(prefetchedPartners, function (partners) { - if (limit > 0) { - var filteredPartners = _.filter(partners, function (partner) { - return partner.email && searchRegexp.test(partner.email) || - partner.name && searchRegexp.test(utils.unaccent(partner.name)); - }); - if (filteredPartners.length) { - suggestions.push(filteredPartners.slice(0, limit)); - limit -= filteredPartners.length; - } - } - }); - if (!suggestions.length && !self.options.mentionPartnersRestricted) { - // no result found among prefetched partners, fetch other suggestions - suggestions = self._mentionFetchThrottled( - 'res.partner', - 'get_mention_suggestions', - { limit: limit, search: search } - ); - } - return Promise.resolve(suggestions).then(function (suggestions) { - //add im_status on suggestions - _.each(suggestions, function (suggestionsSet) { - _.each(suggestionsSet, function (suggestion) { - suggestion.im_status = self.call('mail_service', 'getImStatus', { partnerID: suggestion.id }); - }); - }); - return suggestions; - }); - }); - }, - /** - * @private - * @param {string} model - * @param {string} method - * @param {Object} kwargs - * @return {Promise} - */ - _mentionFetchThrottled: function (model, method, kwargs) { - var self = this; - // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing - return new Promise(function (resolve, reject) { - clearTimeout(self.mentionFetchTimer); - self.mentionFetchTimer = setTimeout(function () { - return self._rpc({model: model, method: method, kwargs: kwargs}) - .then(function (results) { - resolve(results); - }); - }, self.MENTION_THROTTLE); - }); - }, - /** - * @private - * @returns {Promise} - */ - _preprocessMessage: function () { - // Return a promise as this function is extended with asynchronous - // behavior for the chatter composer - - //Removing unwanted extra spaces from message - var value = _.escape(this.$input.val()).trim(); - value = value.replace(/(\r|\n){2,}/g, '<br/><br/>'); - value = value.replace(/(\r|\n)/g, '<br/>'); - - // prevent html space collapsing - value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); - var commands = this.options.commandsEnabled ? - this._mentionManager.getListenerSelection('/') : - []; - return Promise.resolve({ - content: this._mentionManager.generateLinks(value), - attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), - partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')), - canned_response_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelections()[':'], 'id')), - channel_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('#'), 'id')), - command: commands.length > 0 ? commands[0].name : undefined, - }); - }, - /** - * Allowing to upload attachment with file selector as well drag drop feature. - * - * @private - * @param {Array<File>} params.files - * @param {boolean} params.submitForm [optional] - */ - _processAttachmentChange: function (params) { - var self = this; - var attachments = this.get('attachment_ids'); - var files = params.files; - var submitForm = params.submitForm; - var $form = this.$('form.o_form_binary_form'); - - /** - * makes a new formData as formData.delete() is not supported by IE or Safari Mobile. - * - * @return {FormData} - */ - function makeFormDataWithoutUfile() { - var newFormData = new window.FormData(); - $form.find('input').each(function (index, input) { - if (input.name !== 'ufile') { - newFormData.append(input.name, input.value); - } - }); - return newFormData; - } - - _.each(files, function (file) { - var attachment = _.findWhere(attachments, { - name: file.name, - size: file.size - }); - // if the files already exits, delete the file before upload - if (attachment) { - self._attachmentDataSet.unlink([attachment.id]); - attachments = _.without(attachments, attachment); - } - }); - if (submitForm) { - $form.submit(); - this._$attachmentButton.prop('disabled', true); - } else { - _.each(files, function (file) { - var formData = makeFormDataWithoutUfile(); - formData.append("ufile", file, file.name); - $.ajax({ - url: $form.attr("action"), - type: "POST", - enctype: 'multipart/form-data', - processData: false, - contentType: false, - data: formData, - success: function (result) { - var $el = $(result); - $.globalEval($el.contents().text()); - } - }); - }); - } - var uploadAttachments = _.map(files, function (file){ - return { - id: 0, - name: file.name, - filename: file.name, - url: '', - upload: true, - mimetype: '', - }; - }); - attachments = attachments.concat(uploadAttachments); - this.set('attachment_ids', attachments); - }, - /** - * @private - */ - _renderAttachments: function () { - this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', { - attachments: this.get('attachment_ids'), - })); - }, - /** - * @private - */ - _sendMessage: function () { - if (this.isEmpty() || !this._doCheckAttachmentUpload()) { - return; - } - - clearTimeout(this._cannedTimeout); - var self = this; - this._preprocessMessage().then(function (message) { - self.trigger('post_message', message); - self._clearComposerOnSend(); - self.$input.focus(); - }); - }, - /** - * Send the message on ENTER, but go to new line on SHIFT+ENTER - * - * @private - */ - _shouldSend: function (ev) { - return !ev.shiftKey; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {jQuery.Event} ev - */ - _onAttachmentChange: function (ev) { - this._processAttachmentChange({ - files: ev.currentTarget.files, - submitForm: true - }); - ev.target.value = ""; - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAttachmentDelete: function (ev){ - ev.stopPropagation(); - var self = this; - var attachmentID = $(ev.currentTarget).data('id'); - if (attachmentID) { - var attachments = []; - _.each(this.get('attachment_ids'), function (attachment){ - if (attachmentID !== attachment.id) { - attachments.push(attachment); - } else { - self._attachmentDataSet.unlink([attachmentID]); - } - }); - this.set('attachment_ids', attachments); - this.$('input.o_input_file').val(''); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAttachmentDownload: function (ev) { - ev.stopPropagation(); - }, - /** - * @private - */ - _onAttachmentLoaded: function () { - var attachments = this.get('attachment_ids'); - var files = Array.prototype.slice.call(arguments, 1); - - _.each(files, function (file) { - if (file.error || !file.id){ - this.do_warn(file.error); - attachments = _.filter(attachments, function (attachment) { - return !attachment.upload; - }); - } else { - var attachment = _.findWhere(attachments, { filename: file.filename, upload: true }); - if (attachment) { - attachments = _.without(attachments, attachment); - attachments.push({ - id: file.id, - name: file.name || file.filename, - filename: file.filename, - mimetype: file.mimetype, - size: file.size, - url: session.url('/web/content', { id: file.id, download: true }), - }); - } - } - }.bind(this)); - this.set('attachment_ids', attachments); - this._$attachmentButton.prop('disabled', false); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAttachmentView: function (ev) { - var activeAttachmentID = $(ev.currentTarget).data('id'); - var attachments = this.get('attachment_ids'); - if (activeAttachmentID) { - var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID); - attachmentViewer.appendTo($('body')); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onBodyFileDragLeave: function (ev) { - // On every dragenter chain created with parent child element - // That's why dragleave is fired every time when a child elemnt is hovered - // so here we hide dropzone based on mouse position - if (ev.originalEvent.clientX <= 0 - || ev.originalEvent.clientY <= 0 - || ev.originalEvent.clientX >= window.innerWidth - || ev.originalEvent.clientY >= window.innerHeight - ) { - this.$(".o_file_drop_zone_container").addClass("d-none"); - } - }, - /** - * When user start dragging on element drop area will be visible to drop selected files. - * - * @private - * @param {MouseEvent} ev - */ - _onBodyFileDragover: function (ev) { - ev.preventDefault(); - if (this._isDragSourceExternalFile(ev.originalEvent.dataTransfer)) { - this.$(".o_file_drop_zone_container").removeClass("d-none"); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onBodyFileDrop: function (ev) { - ev.preventDefault(); - this.$(".o_file_drop_zone_container").addClass("d-none"); - }, - /** - * @private - */ - _onClickAddAttachment: function () { - this.$('input.o_input_file').click(); - this.$input.focus(); - }, - /** - * Called when the emoji button is clicked -> opens/hides the emoji panel. - * Also, this method is in charge of the rendering of this panel the first - * time it is opened. - * - * @private - */ - _onEmojiButtonClick: function () { - if (!this._$emojisContainer) { // lazy rendering - this._$emojisContainer = $(QWeb.render('mail.Composer.emojis', { - emojis: emojis, - })); - } - if (this._$emojisContainer.parent().length) { - this._hideEmojis(); - } else { - this._$emojisContainer.appendTo(this.$('.o_composer')); - } - }, - /** - * Called when the emoji button is blurred -> closes the emoji panel. The - * closing is scheduled to be done at the end of the current execution - * stack to allow stoping this closing if the button was focusout to select - * an emoji (for example). - * - * @private - */ - _onEmojiButtonFocusout: function () { - if (this._$emojisContainer) { - this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0); - } - }, - /** - * Called when an emoji is clicked from the emoji panel. - * Emoji is inserted in the composer based on the position of the cursor, - * and it automatically focuses the composer and set the cursor position - * just after the newly inserted emoji. - * - * @private - * @param {Event} ev - */ - _onEmojiImageClick: function (ev) { - ev.preventDefault(); - var cursorPosition = this.getSelectionPositions(); - var inputVal = this.$input.val(); - var leftSubstring = inputVal.substring(0, cursorPosition.start); - var rightSubstring = inputVal.substring(cursorPosition.end); - var newInputVal = [leftSubstring , $(ev.currentTarget).data('emoji'), rightSubstring].join(" "); - var newCursorPosition = newInputVal.length - rightSubstring.length; - this.$input.val(newInputVal); - this.$input.focus(); - this.$input[0].setSelectionRange(newCursorPosition, newCursorPosition); - this._hideEmojis(); - }, - /** - * Called when an emoji is focused -> @see _onEmojiButtonFocusout - * - * @private - */ - _onEmojiImageFocus: function () { - clearTimeout(this._hideEmojisTimeout); - }, - /** - * Setting drop Effect to copy so when mouse pointer on dropzone - * cursor icon changed to copy ('+') - * - * @private - * @param {MouseEvent} ev - */ - _onFileDragover: function (ev) { - ev.originalEvent.dataTransfer.dropEffect = "copy"; - }, - /** - * Called when user drop selected files on drop area - * - * @private - * @param {MouseEvent} ev - */ - _onFileDrop: function (ev) { - ev.preventDefault(); - // FIX: In case multiple chat windows are opened, and file droped in one of them - // at that time, other chat windows are still displaing drop areas so here hide them all with $ selector - $(".o_file_drop_zone_container").addClass("d-none"); - if (this._isDragSourceExternalFile(ev.originalEvent.dataTransfer)) { - var files = ev.originalEvent.dataTransfer.files; - this._processAttachmentChange({ files: files }); - } - }, - /** - * Called when the input in the composer changes - * - * @private - */ - _onInput: function () { - if (this.options.thread && this.options.thread.hasTypingNotification()) { - var isTyping = this.$input.val().length > 0; - this.options.thread.setMyselfTyping({ typing: isTyping }); - } - }, - /** - * _onKeydown event is triggered when is key is pressed - * - on UP and DOWN arrow is pressed then event prevents it's default - * behaviour if mention manager is open else it break it. - * - on ENTER key is pressed and mention manager is open then event - * prevents it's default behaviour else check if ControlKey is - * pressed or not if yes then it send a message - * - * @private - * @param {KeyboardEvent} ev - */ - _onKeydown: function (ev) { - switch (ev.which) { - // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.TAB: - if (this._mentionManager.isOpen()) { - ev.preventDefault(); - } - break; - // ENTER: submit the message only if the dropdown mention proposition is not displayed - case $.ui.keyCode.ENTER: - if (this._mentionManager.isOpen()) { - ev.preventDefault(); - } else { - var sendMessage = ev.ctrlKey || this._shouldSend(ev); - if (sendMessage) { - ev.preventDefault(); - this._sendMessage(); - } - } - break; - } - }, - /** - * _onKeyup event is triggered when key is released. - * on ESCAP key it close the mention suggestion dropdown menu. - * on ENTER key it selects that mention and add to a content editable div. - * on UP or DOWN key it highlights previous or next mention respectively. - * - * @private - * @param {KeyboardEvent} ev - */ - _onKeyup: function (ev) { - switch (ev.which) { - // ESCAPED KEYS: do nothing - case $.ui.keyCode.END: - case $.ui.keyCode.PAGE_UP: - case $.ui.keyCode.PAGE_DOWN: - break; - // ESCAPE: close mention propositions - case $.ui.keyCode.ESCAPE: - if (this._mentionManager.isOpen()) { - ev.stopPropagation(); - this._mentionManager.resetSuggestions(); - } else { - this.trigger_up('escape_pressed'); - } - break; - // ENTER, UP, DOWN, TAB: check if navigation in mention propositions - case $.ui.keyCode.ENTER: - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.TAB: - if (this._mentionManager.isOpen()) { - this._mentionManager.propositionNavigation(ev.which); - } - break; - // Otherwise, check if a mention is typed - default: - this._mentionManager.detectDelimiter(); - } - }, - /** - * @private - * @param {integer|string} threadID - */ - _onUpdateTypingPartners: function (threadID) { - if (!this.options.showTyping) { - return; - } - if (!this.options.thread) { - return; - } - if (this.options.thread.getID() !== threadID) { - return; - } - this.$('.o_composer_thread_typing').html(QWeb.render('mail.Composer.ThreadTyping', { - thread: this.options.thread, - })); - }, -}); - -return BasicComposer; - -}); diff --git a/addons/mail/static/src/js/composers/chatter_composer.js b/addons/mail/static/src/js/composers/chatter_composer.js deleted file mode 100644 index 509212019c9..00000000000 --- a/addons/mail/static/src/js/composers/chatter_composer.js +++ /dev/null @@ -1,331 +0,0 @@ -odoo.define('mail.composer.Chatter', function (require) { -"use strict"; - -var BasicComposer = require('mail.composer.Basic'); -var mailUtils = require('mail.utils'); -var session = require('web.session'); - -var core = require('web.core'); -var viewDialogs = require('web.view_dialogs'); - -var _t = core._t; -const Qweb = core.qweb; - -/** - * Chat Composer for the Chatter - * - * Extends the basic Composer Widget to add 'suggested partner' layer (open - * popup when suggested partner is selected without email, or other - * informations), and the button to open the full composer wizard. - */ -var ChatterComposer = BasicComposer.extend({ - template: 'mail.chatter.Composer', - events: _.extend({}, BasicComposer.prototype.events, { - 'click .o_composer_button_full_composer': '_onOpenFullComposer', - 'click .o_suggested_show_more_less': '_onClickPartnerSuggestionsReadMoreLess', - }), - init: function (parent, model, suggestedPartners, options) { - this._super(parent, options); - this._model = model; - this.suggestedPartners = suggestedPartners; - this.options = _.defaults(this.options, { - display_mode: 'textarea', - recordName: false, - isLog: false, - }); - if (this.options.isLog) { - this.options.sendText = _t("Log"); - } - this.notInline = true; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Chatter don't clear message on sent but after successful sent - * - * @override - */ - _clearComposerOnSend: function () {}, - /** - * Updates suggested partners in DOM - * - * @param {Array} suggestedPartners list of 'recipient' partners - */ - updateSuggestedPartners(suggestedPartners) { - this.suggestedPartners = suggestedPartners; - if (!this.options.isLog) { - const $suggestedPartners = $(Qweb.render('mail.chatter.ComposerSuggestedPartners', { - widget: this, - })); - this.$('.o_composer_suggested_partners').replaceWith($suggestedPartners); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Check the additional partners (not necessary registered partners), and - * open a popup form view for the ones who informations is missing. - * - * @private - * @param {Array} checkedSuggestedPartners list of 'recipient' partners to - * complete informations or validate - * @returns {Promise} resolved with the list of checked suggested partners - * (real partner) - **/ - _checkSuggestedPartners: function (checkedSuggestedPartners) { - var self = this; - var checkDone = new Promise(function (resolve, reject) { - var recipients = _.filter(checkedSuggestedPartners, function (recipient) { - return recipient.checked; - }); - var recipientsToFind = _.filter(recipients, function (recipient) { - return (! recipient.partner_id); - }); - var namesToFind = _.pluck(recipientsToFind, 'full_name'); - var recipientsToCheck = _.filter(recipients, function (recipient) { - return (recipient.partner_id && ! recipient.email_address); - }); - var recipientIDs = _.pluck(_.filter(recipients, function (recipient) { - return recipient.partner_id && recipient.email_address; - }), 'partner_id'); - - var namesToRemove = []; - var recipientIDsToRemove = []; - - // have unknown names - // -> call message_get_partner_info_from_emails to try to find - // partner_id - var def; - if (namesToFind.length > 0) { - def = self._rpc({ - route: '/mail/get_partner_info', - params: { - model: self._model, - res_ids: [self.context.default_res_id], - emails: namesToFind, - }, - }); - } - - // for unknown names + incomplete partners - // -> open popup - cancel = remove from recipients - Promise.resolve(def).then(function (result) { - result = result || []; - var emailDefs = []; - var recipientPopups = result.concat(recipientsToCheck); - - _.each(recipientPopups, function (partnerInfo) { - var prom = new Promise(function(innerResolve, innerReject) { - var partnerName = partnerInfo.full_name; - var partnerID = partnerInfo.partner_id; - var parsedEmail = mailUtils.parseEmail(partnerName); - - var dialog = new viewDialogs.FormViewDialog(self, { - res_model: 'res.partner', - res_id: partnerID, - context: { - active_model: self._model, - active_id: self.context.default_res_id, - force_email: true, - ref: 'compound_context', - default_name: parsedEmail[0], - default_email: parsedEmail[1], - }, - title: _t("Please complete customer's informations"), - disable_multiple_selection: true, - }).open(); - dialog.on('closed', self, function () { - innerResolve(); - }); - dialog.opened().then(function () { - dialog.form_view.on('on_button_cancel', self, function () { - namesToRemove.push(partnerName); - if (partnerID) { - recipientIDsToRemove.push(partnerID); - } - }); - }); - }); - - emailDefs.push(prom); - - }); - Promise.all(emailDefs).then(function () { - var newNamesToFind = _.difference(namesToFind, namesToRemove); - var def; - if (newNamesToFind.length > 0) { - def = self._rpc({ - route: '/mail/get_partner_info', - params: { - model: self._model, - res_ids: [self.context.default_res_id], - emails: namesToFind, - link_mail: true, - }, - }); - } - Promise.resolve(def).then(function (result) { - result = result || []; - var recipientPopups = result.concat(recipientsToCheck); - _.each(recipientPopups, function (partnerInfo) { - if ( - partnerInfo.partner_id && - _.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1 - ) { - recipientIDs.push(partnerInfo.partner_id); - } - }); - }).then(function () { - resolve(recipientIDs); - }); - }); - }); - }); - - return checkDone; - }, - /** - * Get the list of selected suggested partners - * - * @private - * @returns {Array} list of 'recipient' selected partners (may not be - * created in db) - **/ - _getCheckedSuggestedPartners: function () { - var self = this; - var checkedPartners = []; - this.$('.o_composer_suggested_partners input:checked').each(function () { - var fullName = $(this).data('fullname'); - checkedPartners = checkedPartners.concat( - _.filter(self.suggestedPartners, function (item) { - return fullName === item.full_name; - }) - ); - }); - return checkedPartners; - }, - /** - * @override - * @private - * @returns {Promise} - */ - _preprocessMessage: function () { - var self = this; - return new Promise(function (resolve, reject) { - self._super().then(function (message) { - message = _.extend(message, { - subtype_xmlid: 'mail.mt_comment', - message_type: 'comment', - context: _.defaults({}, self.context, session.user_context), - }); - - // Subtype - if (self.options.isLog) { - message.subtype_xmlid = 'mail.mt_note'; - } - - // Partner_ids - if (!self.options.isLog) { - var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); - self._checkSuggestedPartners(checkedSuggestedPartners).then(function (partnerIDs) { - message.partner_ids = (message.partner_ids || []).concat(partnerIDs); - // update context - message.context = _.defaults({}, message.context, { - mail_post_autofollow: true, - }); - if (partnerIDs.length) { - self.trigger_up('reset_suggested_partners'); - } - resolve(message); - }); - } else { - resolve(message); - } - - }); - }); - }, - /** - * @override - * @private - */ - _shouldSend: function () { - return false; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} ev - */ - _onClickPartnerSuggestionsReadMoreLess: function (ev) { - ev.preventDefault(); - this.$('.o_suggested_toggle').toggleClass('o_hidden'); - }, - /** - * @private - */ - _onOpenFullComposer: function () { - if (!this._doCheckAttachmentUpload()){ - return false; - } - - var self = this; - var recipientDoneDef = new Promise(function (resolve, reject) { - // any operation on the full-composer will reload the record, so - // warn the user that any unsaved changes on the record will be lost. - self.trigger_up('discard_record_changes', { - proceed: function () { - if (self.options.isLog) { - resolve([]); - } else { - var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); - self._checkSuggestedPartners(checkedSuggestedPartners) - .then(resolve); - } - }, - }); - }); - - recipientDoneDef.then(function (partnerIDs) { - var context = { - default_parent_id: self.id, - default_body: mailUtils.getTextToHTML(self.$input.val()), - default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'), - default_partner_ids: partnerIDs, - default_is_log: self.options.isLog, - mail_post_autofollow: true, - }; - - if (self.context.default_model && self.context.default_res_id) { - context.default_model = self.context.default_model; - context.default_res_id = self.context.default_res_id; - } - - var action = { - type: 'ir.actions.act_window', - res_model: 'mail.compose.message', - view_mode: 'form', - views: [[false, 'form']], - target: 'new', - context: context, - }; - self.do_action(action, { - on_close: self.trigger.bind(self, 'need_refresh'), - }).then(self.trigger.bind(self, 'close_composer')); - }); - } -}); - -return ChatterComposer; - -}); diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js deleted file mode 100644 index 1acfba1058c..00000000000 --- a/addons/mail/static/src/js/composers/extended_composer.js +++ /dev/null @@ -1,138 +0,0 @@ -odoo.define('mail.composer.Extended', function (require) { -"use strict"; - -var BasicComposer = require('mail.composer.Basic'); - -var core = require('web.core'); - -var QWeb = core.qweb; - -var ExtendedComposer = BasicComposer.extend({ - events: _.extend({}, BasicComposer.prototype.events, { - 'click .o_composer_button_discard': '_onClickDiscard', - }), - /** - * @override - */ - init: function (parent, options) { - options = _.defaults(options || {}, { - inputMinHeight: 120, - }); - this._super(parent, options); - this.extended = true; - }, - /** - * @override - */ - start: function () { - this._$subjectInput = this.$('.o_composer_subject input'); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - clearComposer: function () { - this._super.apply(this, arguments); - this._$subjectInput.val(''); - }, - /** - * @override - * @param {string} target - */ - focus: function (target) { - if (target === 'body') { - this.$input.focus(); - } else { - this._$subjectInput.focus(); - } - }, - /** - * @override - */ - getState: function () { - var state = this._super.apply(this, arguments); - state.subject = this._$subjectInput.val(); - return state; - }, - /** - * @override - * @param {Object} state - * @param {string} state.subject - */ - setState: function (state) { - this._super.apply(this, arguments); - this.setSubject(state.subject); - }, - /** - * @param {string} subject - */ - setSubject: function (subject) { - this.$('.o_composer_subject input').val(subject); - }, - /** - * Show the button 'discard' next to the send button. This is useful while - * replying to a message, when in the end we do not want to reply. - */ - showDiscardButton: function () { - var $discard = this.$('.o_composer_button_discard'); - $discard.removeClass('d-none'); - $discard.addClass('d-md-inline-block'); - }, - /** - * Hide the button 'discard' next to the send button. This is useful when - * we were replying to a message from a certain thread, then we discard it - * with some other actions (i.e. switch thread). The discard button only - * makes sense during the selection of a message. - */ - hideDiscardButton: function () { - var $discard = this.$('.o_composer_button_discard'); - $discard.removeClass('d-md-inline-block'); - $discard.addClass('d-none'); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - * @private - * @returns {Promise} - */ - _preprocessMessage: function () { - var self = this; - return this._super().then(function (message) { - var subject = self._$subjectInput.val(); - self._$subjectInput.val(""); - message.subject = subject; - return message; - }); - }, - /** - * @override - * @private - */ - _shouldSend: function () { - return false; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onClickDiscard: function () { - this.trigger_up('discard_extended_composer'); - }, -}); - -return ExtendedComposer; - -}); diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js deleted file mode 100644 index 8aa8b251343..00000000000 --- a/addons/mail/static/src/js/composers/mention_manager.js +++ /dev/null @@ -1,439 +0,0 @@ -odoo.define('mail.composer.MentionManager', function (require) { -"use strict"; - -var core = require('web.core'); -var dom = require('web.dom'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; - -var NON_BREAKING_SPACE = '\u00a0'; - -// The MentionManager allows the Composer to register listeners. For each -// listener, it detects if the user is currently typing a mention (starting by a -// given delimiter). If so, if fetches mention suggestions and renders them. On -// suggestion clicked, it updates the selection for the corresponding listener. -var MentionManager = Widget.extend({ - className: 'dropup o_composer_mention_dropdown', - events: { - 'click .o_mention_proposition': '_onClickMentionItem', - 'mouseover .o_mention_proposition': '_onHoverMentionProposition', - }, - init: function (parent) { - this._super.apply(this, arguments); - - this._composer = parent; - - this._open = false; - this._listeners = []; - this.set('mention_suggestions', []); - this.on('change:mention_suggestions', this, this._renderSuggestions); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Returns true if the mention suggestions dropdown is open, false otherwise - * - * @returns {boolean} - */ - isOpen: function () { - return this._open; - }, - /** - * Returns the mentions of the given listener that haven't been erased from - * the composer's input - * - * @returns {Array} - */ - getListenerSelection: function (delimiter) { - var listener = _.findWhere(this._listeners, { delimiter: delimiter }); - if (listener) { - var escapedVal = _.escape(this._composer.$input.val()); - var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | |$)', 'g')); - return this._validateSelection(listener.selection, inputMentions); - } - return []; - }, - /** - * @returns {Object} containing listener selections, grouped by delimiter - * as key of the object. - */ - getListenerSelections: function () { - var selections = {}; - _.each(this._listeners, function (listener) { - selections[listener.delimiter] = listener.selection; - }); - return selections; - }, - /** - * Detects if the user is currently typing a mention word - */ - detectDelimiter: function () { - var self = this; - var textVal = this._composer.$input.val(); - var cursorPosition = this._getComposerSelectionPositions().start; - var leftString = textVal.substring(0, cursorPosition); - - function validateKeyword(delimiter, beginningOnly) { - // use position before delimiter because there should be whitespaces - // or line feed/carriage return before the delimiter - var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; - if (beginningOnly && beforeDelimiterPosition > 0) { - return false; - } - var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition); - // regex string start with delimiter or whitespace then delimiter - var pattern = "^"+delimiter+"|^\\s"+delimiter; - var regexStart = new RegExp(pattern, 'g'); - // trim any left whitespaces or the left line feed/ carriage return - // at the beginning of the string - searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); - if (regexStart.test(searchStr) && searchStr.length) { - searchStr = searchStr.replace(pattern, ''); - return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ? - searchStr.replace(delimiter, '') : - false; - } - return false; - } - - this._activeListener = undefined; - for (var i = 0; i < this._listeners.length; i++) { - var listener = this._listeners[i]; - this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly); - - if (this._mentionWord !== false) { - this._activeListener = listener; - break; - } - } - - if (this._activeListener) { - var mentionWord = this._mentionWord; - Promise.resolve(this._activeListener.fetchCallback(mentionWord)) - .then(function (suggestions) { - if (mentionWord === self._mentionWord) { - // update suggestions only if mentionWord didn't change - // in the meantime - self.set('mention_suggestions', suggestions); - } - }); - } else { - this.set('mention_suggestions', []); // close the dropdown - } - }, - /** - * Replaces mentions appearing in the string 's' by html links with proper - * redirection - * - * @param {string} s - * @returns {string} - */ - generateLinks: function (s) { - var self = this; - var baseHREF = session.url('/web'); - var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'"; - var mentionLink = "<a " + linkParams + " >" + - "%s%s" + - "</a>"; - _.each(this._listeners, function (listener) { - if (!listener.generateLinks) { - return; - } - var selection = listener.selection; - if (selection.length) { - var matches = self._getMatch(s, listener); - var substrings = []; - var startIndex = 0; - for (var i = 0; i < matches.length; i++) { - var match = matches[i]; - var endIndex = match.index + match[0].length; - var selectionID = self._getSelectionID(match, selection); - // put back white spaces instead of non-breaking spaces in - // mention's name - var matchName = match[0].substring(1) - .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' '); - var href = baseHREF + - _.str.sprintf("#model=%s&id=%s", listener.model, selectionID); - var processedText = _.str.sprintf(mentionLink, - href, - listener.redirectClassname, - selectionID, - listener.model, - listener.delimiter, - matchName); - substrings.push(s.substring(startIndex, match.index)); - substrings.push(processedText); - startIndex = endIndex; - } - substrings.push(s.substring(startIndex, s.length)); - s = substrings.join(''); - } - }); - return s; - }, - /** - * @param {integer} keycode - */ - propositionNavigation: function (keycode) { - var $active = this.$('.o_mention_proposition.active'); - if (keycode === $.ui.keyCode.ENTER) { - // selecting proposition - $active.click(); - } else { - // navigation in propositions - var $to; - if (keycode === $.ui.keyCode.DOWN) { - $to = $active.nextAll('.o_mention_proposition').first(); - } else if (keycode === $.ui.keyCode.UP) { - $to = $active.prevAll('.o_mention_proposition').first(); - } else if (keycode === $.ui.keyCode.TAB) { - $to = $active.nextAll('.o_mention_proposition').first(); - if (!$to.length) { - $to = $active.prevAll('.o_mention_proposition').last(); - } - } - if ($to && $to.length) { - $active.removeClass('active'); - $to.addClass('active'); - } - } - }, - /** - * Registers a new listener, described by an object containing the following - * keys - * - * @param {Object} listener - * @param {boolean} [listener.beginningOnly] true to enable autocomplete - * only at first position of input - * @param {char} [listener.delimiter] the mention delimiter - * @param {function} [listener.fetchCallback] the callback to fetch mention - * suggestions - * @param {boolean} [listener.generateLinks] true to wrap mentions in <a> - * links - * @param {string} [listener.model=''] (optional) the model used for - * redirection - * @param {string} [listener.redirectClassname=''] (optional) the classname - * of the <a> wrapping the mention - * @param {Array} [listener.selection=[]] (optional) initial mentions for - * each listener - * @param {string} [listener.suggestionTemplate] the template of - * suggestions' dropdown - */ - register: function (listener) { - this._listeners.push(_.defaults(listener, { - model: '', - redirectClassname: '', - selection: [], - })); - }, - resetSuggestions: function () { - this.set('mention_suggestions', []); - }, - resetSelections: function () { - _.each(this._listeners, function (listener) { - listener.selection = []; - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Returns the matches (as RexExp.exec does) for the mention in the input - * text - * - * @private - * @param {string} inputText: the text to search matches - * @param {Object} listener: the listener for which we want to find a match - * @returns {Object[]} matches in the same format as RexExp.exec() - */ - _getMatch: function (inputText, listener) { - // create the regex of all mention's names - var names = _.pluck(listener.selection, 'name'); - var escapedNames = _.map(names, function (str) { - return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= | |$)"; - }); - var regexStr = escapedNames.join('|'); - // extract matches - var result = []; - if (regexStr.length) { - var myRegexp = new RegExp(regexStr, 'g'); - var match = myRegexp.exec(inputText); - while (match !== null) { - result.push(match); - match = myRegexp.exec(inputText); - } - } - return result; - }, - /** - * @private - * @param {string[]} match - * @param {Object} selection - * @returns {Object} - */ - _getSelectionID: function (match, selection) { - return _.findWhere(selection, { name: match[0].slice(1) }).id; - }, - /** - * Get cursor position and selection - * - * @private - * @returns a current cursor position - */ - _getComposerSelectionPositions: function () { - return this._composer.getSelectionPositions(); - }, - /** - * @private - */ - _renderSuggestions: function () { - var suggestions = []; - if (_.find(this.get('mention_suggestions'), _.isArray)) { - // Array of arrays -> Flatten and insert dividers between groups - var insertDivider = false; - _.each(this.get('mention_suggestions'), function (suggestionGroup) { - if (suggestionGroup.length > 0) { - if (insertDivider) { - suggestions.push({ divider: true }); - } - suggestions = suggestions.concat(suggestionGroup); - insertDivider = true; - } - }); - } else { - suggestions = this.get('mention_suggestions'); - } - if (suggestions.length) { - this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, { - suggestions: _.map(suggestions, function (suggestion) { - // mention manager stores escaped suggestion names - return _.extend({}, suggestion, { name: _.unescape(suggestion.name) }); - }) - })); - this.$el - .addClass('show') - .find('.dropdown-menu') - .addClass('show') - .css('max-width', this._composer.$input.width()) - .find('.o_mention_proposition') - .first() - .addClass('active'); - this._open = true; - } else { - this.$el.removeClass('show') - .find('.dropdown-menu') - .removeClass('show'); - this.$el.empty(); - this._open = false; - } - }, - /* - * Set cursor position - * - * @private - * @param {integer} pos - */ - _setCursorPosition: function (pos) { - this._composer.$input.each(function (index, elem) { - dom.setSelectionRange(elem, { start: pos, end: pos }); - }); - }, - /** - * @private - * @param {Object} selection - * @param {Array} inputMentions - * @returns {Array} - */ - _validateSelection: function (selection, inputMentions) { - var validatedSelection = []; - _.each(inputMentions, function (mention) { - var validatedMention = _.findWhere(selection, { name: mention.slice(1) }); - if (validatedMention) { - validatedSelection.push(validatedMention); - } - }); - return validatedSelection; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {KeyboardEvent} ev - to get a current selected mention from list - * and eventlistener. - */ - _onClickMentionItem: function (ev) { - ev.preventDefault(); - - var textInput = this._composer.$input.val(); - var id = $(ev.currentTarget).data('id'); - var suggestions = _.flatten(this.get('mention_suggestions')); - var selectedSuggestion = _.clone(_.find(suggestions, function (s) { - return s.id === id; - })); - var substitution = selectedSuggestion.substitution; - if (!substitution) { - // no substitution string given, so use the mention name instead - // replace white spaces with non-breaking spaces to facilitate - // mentions detection in text. - // mention manager stores escaped suggestion names - selectedSuggestion.name = _.unescape(selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE)); - substitution = this._activeListener.delimiter + selectedSuggestion.name; - } - var getMentionIndex = function (matches, cursorPosition) { - for (var i = 0; i < matches.length; i++) { - if (cursorPosition <= matches[i].index) { - return i; - } - } - return i; - }; - - // add the selected suggestion to the list - if (this._activeListener.selection.length) { - // get mention matches (ordered by index in the text) - var matches = this._getMatch(textInput, this._activeListener); - var index = getMentionIndex(matches, this._getComposerSelectionPositions().start); - this._activeListener.selection.splice(index, 0, selectedSuggestion); - } else { - this._activeListener.selection.push(selectedSuggestion); - } - - // update input text, and reset dropdown - var cursorPosition = this._getComposerSelectionPositions().start; - var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1)); - var textRight = textInput.substring(cursorPosition, textInput.length); - var textInputNew = textLeft + substitution + ' ' + textRight; - this._composer.$input.val(textInputNew); - this._setCursorPosition(textLeft.length+substitution.length+2); - this.set('mention_suggestions', []); - this._composer.focus('body'); // to trigger autoresize - // suggestion after inserting will be used with escaped content - if (selectedSuggestion.name) { - selectedSuggestion.name = _.escape(selectedSuggestion.name); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onHoverMentionProposition: function (ev) { - var $elem = $(ev.currentTarget); - this.$('.o_mention_proposition').removeClass('active'); - $elem.addClass('active'); - }, - -}); - -return MentionManager; - -}); diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js deleted file mode 100644 index 5e17ce86d67..00000000000 --- a/addons/mail/static/src/js/discuss.js +++ /dev/null @@ -1,1642 +0,0 @@ -odoo.define('mail.Discuss', function (require) { -"use strict"; - -var BasicComposer = require('mail.composer.Basic'); -var ExtendedComposer = require('mail.composer.Extended'); -var ThreadWidget = require('mail.widget.Thread'); - -var AbstractAction = require('web.AbstractAction'); -var config = require('web.config'); -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var dom = require('web.dom'); - -var QWeb = core.qweb; -var _t = core._t; - -/** - * Widget : Invite People to Channel Dialog - * - * Popup containing a 'many2many_tags' custom input to select multiple partners. - * Searches user according to the input, and triggers event when selection is - * validated. - */ -var PartnerInviteDialog = Dialog.extend({ - dialog_title: _t("Invite people"), - template: 'mail.PartnerInviteDialog', - - /** - * @override - * @param {integer|string} channelID id of the channel, - * a string for static channels (e.g. 'mailbox_inbox'). - */ - init: function (parent, title, channelID) { - this._channelID = channelID; - - this._super(parent, { - title: title, - size: 'medium', - buttons: [{ - text: _t("Invite"), - close: true, - classes: 'btn-primary', - click: this._addChannel.bind(this), - }], - }); - }, - /** - * @override - */ - start: function () { - var self = this; - this.$input = this.$('.o_mail_discuss_partner_invite_input'); - this.$input.select2({ - width: '100%', - allowClear: true, - multiple: true, - formatResult: function (item) { - var status = QWeb.render('mail.UserStatus', { - status: self.call('mail_service', 'getImStatus', { partnerID: item.id }), - partnerID: item.id, - }); - return $('<span>').text(item.text).prepend(status); - }, - query: function (query) { - self.call('mail_service', 'searchPartner', query.term, 20) - .then(function (partners) { - query.callback({ - results: _.map(partners, function (partner) { - return _.extend(partner, { text: partner.label }); - }), - }); - }); - } - }); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @returns {Promise} - */ - _addChannel: function () { - var self = this; - var data = this.$input.select2('data'); - if (data.length >= 1) { - return this._rpc({ - model: 'mail.channel', - method: 'channel_invite', - args: [this._channelID], - kwargs: {partner_ids: _.pluck(data, 'id')}, - }).then(function () { - var names = _.escape(_.pluck(data, 'text').join(', ')); - var notification = - _.str.sprintf( - _t("You added <b>%s</b> to the conversation"), - names - ); - self.do_notify(false, notification); - // Update list of members with the invited user, so that - // we can mention this user in this channel right away. - var channel = self.call('mail_service', 'getChannel', self._channelID); - channel.forceFetchMembers(); - }); - } - }, -}); - -/** - * Widget : Rename Conversation Dialog - */ -var RenameConversationDialog = Dialog.extend({ - dialog_title: _t("Rename conversation"), - template: 'mail.RenameConversationDialog', - /** - * @override - * @param {integer|string} channelID id of the channel - * @param {function} callback to call when successfully renaming - * conversation. - */ - init: function (parent, channelID, callback) { - this._channelID = channelID; - this._callback = callback; - - this._super(parent, { - title: _t('Rename conversation'), - size: 'medium', - buttons: [{ - text: _t("Rename"), - close: true, - classes: 'btn-primary o_mail_conversation_rename', - click: this._rename.bind(this), - }, { - text: _t("Discard"), - close: true, - }], - }); - }, - /** - * @override - * @returns {$.Promise} - */ - start: function () { - var channel = this.call('mail_service', 'getChannel', this._channelID); - this.$('input').val(channel.getName()); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @returns {$.Promise} - */ - _rename: function () { - var self = this; - var name = this.$('input').val(); - return this._rpc({ - model: 'mail.channel', - method: 'channel_set_custom_name', - args: [this._channelID], - kwargs: { - name: name, - } - }).then(function () { - var channel = self.call('mail_service', 'getThread', self._channelID); - channel.setName(name); - self._callback(); - }); - }, -}); - -/** - * Widget : Moderator reject message dialog - * - * Popup containing message title and reject message body. - * This let the moderator provide a reason for rejecting the messages. - */ -var ModeratorRejectMessageDialog = Dialog.extend({ - template: 'mail.ModeratorRejectMessageDialog', - - /** - * @override - * @param {web.Widget} parent - * @param {Object} params - * @param {integer[]} params.messageIDs list of message IDs to send - * 'reject' decision reason - * @param {function} params.proceedReject - * - * a function to call when the - * moderator confirms the reason for rejecting the - * messages. This function passes an object as the - * reason for reject, which is structured as follow:: - * - * { - * title: <string>, - * comment: <string>, - * } - */ - init: function (parent, params) { - this._messageIDs = params.messageIDs; - this._proceedReject = params.proceedReject; - this._super(parent, { - title: _t("Send explanation to author"), - size: 'medium', - buttons: [{ - text: _t("Send"), - close: true, - classes: 'btn-primary', - click: _.bind(this._onSendClicked, this), - }], - }); - }, - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called when the moderator would like to submit reason for rejecting the - * messages. - * - * @private - */ - _onSendClicked: function () { - var title = this.$('#message_title').val(); - var comment = this.$('#reject_message').val(); - if (title && comment) { - this._proceedReject({ - title: title, - comment: comment - }); - } - }, -}); - -var Discuss = AbstractAction.extend({ - contentTemplate: 'mail.discuss', - custom_events: _.extend({}, AbstractAction.prototype.custom_events, { - discard_extended_composer: '_onDiscardExtendedComposer', - message_moderation: '_onMessageModeration', - update_moderation_buttons: '_onUpdateModerationButtons', - }), - events: { - 'click .o_mail_sidebar_title .o_add': '_onAddThread', - 'blur .o_mail_add_thread input': '_onAddThreadBlur', - 'click .o_mail_channel_settings': '_onChannelSettingsClicked', - 'click .o_mail_discuss_item': '_onDiscussItemClicked', - 'keydown': '_onKeydown', - 'click .o_mail_open_channels': '_onPublicChannelsClick', - 'click .o_mail_partner_unpin': '_onUnpinChannel', - 'input .o_discuss_sidebar_quick_search input': '_onInputSidebarQuickSearchInput', - }, - hasControlPanel: true, - loadControlPanel: true, - withSearchBar: true, - searchMenuTypes: ['filter', 'favorite'], - - /** - * @override - * @param {Object} [options] - * @param {integer} [options.channelQuickSearchThreshold=20] amount of - * channels (dm inluded) for which a quick search appears in the sidebar. - */ - init: function (parent, action, options) { - this._super.apply(this, arguments); - - this.action = action; - this.context = action.context; - this.action_manager = parent; - this.domain = []; - this.options = options || {}; - - if (!('channelQuickSearchThreshold' in this.options)) { - this.options.channelQuickSearchThreshold = 20; - } - - this._isMessagingReady = this.call('mail_service', 'isReady'); - this._isStarted = false; - this._threadsScrolltop = {}; - this._composerStates = {}; - this._defaultThreadID = this.options.active_id || - this.action.context.active_id || - this.action.params.default_active_id || - 'mailbox_inbox'; - this._selectedMessage = null; - this._throttledUpdateThreads = _.throttle( - this._updateThreads.bind(this), 100, { leading: false }); - - this.controlPanelModelConfig.modelName = 'mail.message'; - this.call('mail_service', 'getMailBus').on('messaging_ready', this, this._onMessagingReady); - }, - /** - * @override - */ - start: function () { - var self = this; - this._isStarted = true; - return this._super.apply(this, arguments).then(function () { - if (!self._isMessagingReady) { - return; - } - return self._initRender(); - }); - }, - /** - * @override - */ - do_show: function () { - this._super.apply(this, arguments); - this.updateControlPanel(); - this.action_manager.do_push_state({ - action: this.action.id, - active_id: this._thread.getID(), - }); - }, - /** - * @override - */ - destroy: function () { - if (this.$buttons) { - this.$buttons.off().remove(); - } - this._super.apply(this, arguments); - }, - /** - * @override - */ - on_attach_callback: function () { - this._super(); - this.call('mail_service', 'getMailBus').trigger('discuss_open', true); - if (this._thread) { - this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]); - this._loadEnoughMessages(); - } - }, - /** - * @override - */ - on_detach_callback: function () { - this._super(); - this.call('mail_service', 'getMailBus').trigger('discuss_open', false); - if (this._thread) { - this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Ask confirmation to the user to unsubscribe, due to the user being the - * administrator of the channel. - * - * @private - * @param {mail.model.Channel} channel a channel for which the current user - * is administrator of. - */ - _askConfirmationAdminUnsubscribe: function (channel) { - Dialog.confirm(this, - _t("You are the administrator of this channel. Are you sure you want to unsubscribe?"), - { - buttons: [{ - text: _t("Unsubscribe"), - classes: 'btn-primary', - close: true, - click: function () { - channel.unsubscribe(); - } - }, { - text: _t("Discard"), - close: true, - }] - } - ); - }, - /** - * Ban the authors of the messages with ID in `messageIDs` - * Show a confirmation dialog to the moderator. - * - * @private - * @param {integer[]} messageIDs IDs of messages for which we should ban authors - */ - _banAuthorsFromMessageIDs: function (messageIDs) { - var self = this; - var emailList = _.map(messageIDs, function (messageID) { - return self.call('mail_service', 'getMessage', messageID).getEmailFrom(); - }).join(", "); - var text = _.str.sprintf( - _t("You are going to ban: %s. Do you confirm the action?"), - emailList - ); - var options = { - confirm_callback: function () { - self._moderateMessages(messageIDs, 'ban'); - } - }; - Dialog.confirm(this, text, options); - }, - /** - * Discard the messages with ID in `messageIDs` - * Show a confirmation dialog to the moderator. - * - * @private - * @param {integer[]]} messageIDs list of message IDs to discard - */ - _discardMessages: function (messageIDs) { - var self = this; - var num = messageIDs.length; - var text; - if (num > 1) { - text = _.str.sprintf( - _t("You are going to discard %s messages. Do you confirm the action?"), - num - ); - } else if (num === 1) { - text = _t("You are going to discard 1 message. Do you confirm the action?"); - } - var options = { - confirm_callback: function () { - self._moderateMessages(messageIDs, 'discard'); - } - }; - Dialog.confirm(this, text, options); - }, - /** - * @private - * @returns {Promise} - */ - _fetchAndRenderThread: function () { - var self = this; - return this._thread.fetchMessages(this.domain) - .then(function () { - self._threadWidget.render( - self._thread, - self._getThreadRenderingOptions() - ); - self._updateButtonStatus(!self._thread.hasMessages()); - return self._loadEnoughMessages(); - }); - }, - /** - * @private - * @returns {Object} - */ - _getThreadRenderingOptions: function () { - // Compute position of the 'New messages' separator, only once when - // joining a channel to keep it in the thread when new messages arrive - if (_.isUndefined(this.messagesSeparatorPosition)) { - if (!this._unreadCounter) { - // no unread message -> don't display separator - this.messagesSeparatorPosition = false; - } else { - var messageID = this._thread.getLastSeenMessageID(); - this.messagesSeparatorPosition = messageID || 'top'; - } - } - var hasThreadMessages = this._thread.hasMessages({domain: this.domain}); - return { - displayLoadMore: !this._thread.isAllHistoryLoaded(this.domain), - displayMarkAsRead: this._thread.getID() === 'mailbox_inbox', - domain: this.domain, - messagesSeparatorPosition: this.messagesSeparatorPosition, - squashCloseMessages: this._thread.getType() !== 'mailbox' && - !this._thread.isMassMailing(), - displayEmptyThread: !hasThreadMessages && !this.domain.length, - displayNoMatchFound: !hasThreadMessages && !!this.domain.length, - displaySubjectOnMessages: this._thread.isMassMailing() || - this._thread.getID() === 'mailbox_inbox' || - this._thread.getID() === 'mailbox_moderation', - displayNotificationIcons: false, - displayReplyIcons: true, - displayBottomThreadFreeSpace: true, - displayModerationCommands: true, - }; - }, - /** - * Determine the action to apply on messages with ID in `messageIDs` - * based on the moderation decision `decision`. - * - * @private - * @param {number[]} messageIDs list of message ids that are moderated - * @param {string} decision of the moderator, could be either 'reject', - * 'discard', 'ban', 'accept', 'allow'. - */ - _handleModerationDecision: function (messageIDs, decision) { - if (messageIDs) { - if (decision === 'reject') { - this._rejectMessages(messageIDs); - } else if (decision === 'discard') { - this._discardMessages(messageIDs); - } else if (decision === 'ban') { - this._banAuthorsFromMessageIDs(messageIDs); - } else { - // other decisions do not need more information, - // confirmation dialog, etc. - this._moderateMessages(messageIDs, decision); - } - } - }, - /** - * @private - */ - _initRender: function () { - var self = this; - this.$('.o_mail_discuss_loading').remove(); - this._basicComposer = new BasicComposer(this, { - mentionPartnersRestricted: true, - showTyping: true, - }); - this._extendedComposer = new ExtendedComposer(this, { - mentionPartnersRestricted: true, - showTyping: true, - }); - this._basicComposer - .on('post_message', this, this._onPostMessage) - .on('input_focused', this, this._onComposerFocused); - this._extendedComposer - .on('post_message', this, this._onPostMessage) - .on('input_focused', this, this._onComposerFocused); - this._renderButtons(); - - var defs = []; - defs.push( - this._renderThread() - ); - defs.push( - this._basicComposer.appendTo(this.$('.o_mail_discuss_content'))); - defs.push( - this._extendedComposer.appendTo(this.$('.o_mail_discuss_content'))); - - return Promise.all(defs) - .then(function () { - return self._setThread(self._defaultThreadID); - }) - .then(function () { - self._initThreads(); - self._startListening(); - self._threadWidget.$el.on('scroll', null, _.debounce(function () { - if ( - self._threadWidget.getScrolltop() < 20 && - !self._threadWidget.$('.o_mail_no_content').length && - !self._thread.isAllHistoryLoaded(self.domain) - ) { - self._loadMoreMessages(); - } - if ( - self._threadWidget.isAtBottom() && - self._thread.getType() !== 'mailbox' - ) { - self._thread.markAsRead(); - } - }, 100)); - }); - }, - /** - * Renders the mainside bar with current threads - * - * @private - */ - _initThreads: function () { - var self = this; - var $sidebar = this._renderSidebar(); - this.$('.o_mail_discuss_sidebar').html($sidebar.contents()); - _.each(['dm_chat', 'multi_user_channel'], function (type) { - var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input'); - self._prepareAddThreadInput($input, type); - }); - }, - /** - * Ensures that enough messages have been loaded to fill the entire screen - * (this is particularily important because remaining messages can only be - * loaded when scrolling to the top, so they can't be loaded if there is no - * scrollbar) - * - * @returns {Promise} resolved when there are enough messages to fill the - * screen, or when there is no more message to fetch - */ - _loadEnoughMessages: function () { - var loadMoreMessages = - this._threadWidget.el.clientHeight && - (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) && - !this._thread.isAllHistoryLoaded(this.domain); - if (loadMoreMessages) { - return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this)); - } - }, - /** - * Load more messages for the current thread - * - * @private - * @returns {Promise} - */ - _loadMoreMessages: function () { - var self = this; - var oldestMessageID = this.$('.o_thread_message').first().data('messageId'); - var oldestMessageSelector = '.o_thread_message[data-message-id="' + oldestMessageID + '"]'; - var offset = -dom.getPosition(document.querySelector(oldestMessageSelector)).top; - return this._thread.fetchMessages(this.domain, true) - .then(function () { - if (self.messagesSeparatorPosition === 'top') { - // reset value to re-compute separator position - self.messagesSeparatorPosition = undefined; - } - self._threadWidget.render( - self._thread, - self._getThreadRenderingOptions() - ); - offset += dom.getPosition(document.querySelector(oldestMessageSelector)).top; - self._threadWidget.scrollToPosition(offset); - }); - }, - /** - * Apply the moderation decision `decision` on the messages with ID in - * `messageIDs`. - * - * @private - * @param {integer[]} messageIDs list of message IDs to apply the - * moderation decision. - * @param {string} decision the moderation decision to apply on the - * messages. Could be either 'reject', 'discard', 'ban', 'accept', - * or 'allow'. - * @param {Object|undefined} [kwargs] optional data to pass on - * message moderation. This is provided when rejecting the messages - * for which title and comment give reason(s) for reject. - * @param {string} [kwargs.title] - * @param {string} [kwargs.comment] - * @return {undefined|$.Promise} - */ - _moderateMessages: function (messageIDs, decision, kwargs) { - if (messageIDs.length && decision) { - return this._rpc({ - model: 'mail.message', - method: 'moderate', - args: [messageIDs, decision], - kwargs: kwargs - }); - } - }, - /** - * Binds handlers on the given $input to make them autocomplete and/or - * create threads. - * - * @private - * @param {JQuery} $input the input to prepare - * @param {string} type the type of thread to create ('dm_chat', - * 'multi_user_channel') - */ - _prepareAddThreadInput: function ($input, type) { - var self = this; - if (type === 'multi_user_channel') { - $input.autocomplete({ - autoFocus: true, - source: function (request, response) { - self._lastSearchVal = _.escape(request.term); - self._searchChannel(self._lastSearchVal).then(function (result){ - result.push({ - label: _.str.sprintf( - '<strong>' + _t("Create %s (Public)") + '</strong>', - '<em>"#' + self._lastSearchVal + '"</em>' - ), - value: self._lastSearchVal, - special: 'public', - }, { - label: _.str.sprintf( - '<strong>' + _t("Create %s (Private)") + '</strong>', - '<em>"#' + self._lastSearchVal + '"</em>' - ), - value: self._lastSearchVal, - special: 'private', - }); - response(result); - }); - }, - select: function (ev, ui) { - if (self._lastSearchVal) { - if (ui.item.special) { - if (ui.item.special === 'public') { - self.call('mail_service', 'createChannel', self._lastSearchVal, 'public'); - } else { - self.call('mail_service', 'createChannel', self._lastSearchVal, 'private'); - } - } else { - self.call( 'mail_service', 'joinChannel', ui.item.id); - } - } - }, - focus: function (ev) { - ev.preventDefault(); - }, - html: true, - }); - } else if (type === 'dm_chat') { - $input.autocomplete({ - autoFocus: true, - source: function (request, response) { - self._lastSearchVal = _.escape(request.term); - self.call('mail_service', 'searchPartner', self._lastSearchVal, 10).then(response); - }, - select: function (ev, ui) { - var partnerID = ui.item.id; - var dmChat = self.call('mail_service', 'getDMChatFromPartnerID', partnerID); - if (dmChat) { - self._setThread(dmChat.getID()); - } else { - self.call('mail_service', 'createChannel', partnerID, 'dm_chat'); - } - // clear the input - $(this).val(''); - return false; - }, - focus: function (ev) { - ev.preventDefault(); - }, - }); - } - }, - /** - * Reject the messages - * - * The moderator must provide a reason for reject, and may also - * cancel his action. - * - * @private - * @param {number[]} messageIDs list of message IDs to reject - */ - _rejectMessages: function (messageIDs) { - var self = this; - new ModeratorRejectMessageDialog(this, { - messageIDs: messageIDs, - proceedReject: function (reason) { - self._moderateMessages(messageIDs, 'reject', reason); - } - }).open(); - }, - /** - * @private - */ - _renderButtons: function () { - this.$buttons = $(QWeb.render('mail.discuss.ControlButtons', { debug: config.isDebug() })); - this.$buttons.find('button').css({display:'inline-block'}); - this.$buttons - .on('click', '.o_mail_discuss_button_invite', this._onInviteButtonClicked.bind(this)) - .on('click', '.o_mail_discuss_button_mark_all_read', this._onMarkAllAsReadClicked.bind(this)) - .on('click', '.o_mail_discuss_button_unstar_all', this._onUnstarAllClicked.bind(this)) - .on('click', '.o_mail_discuss_button_moderate_all', this._onModerateAllClicked.bind(this)) - .on('click', '.o_mail_discuss_button_select_all', this._onSelectAllClicked.bind(this)) - .on('click', '.o_mail_discuss_button_unselect_all', this._onUnselectAllClicked.bind(this)); - }, - /** - * Render the sidebar of discuss app - * - * @private - * @returns {jQueryElement} - */ - _renderSidebar: function () { - var channels = this.call('mail_service', 'getChannels'); - channels = this._sortChannels(channels); - var $sidebar = $(QWeb.render('mail.discuss.Sidebar', { - activeThreadID: this._thread ? this._thread.getID() : undefined, - inbox: this.call('mail_service', 'getMailbox', 'inbox'), - starred: this.call('mail_service', 'getMailbox', 'starred'), - moderation: this.call('mail_service', 'getMailbox', 'moderation'), - channels: channels, - isMyselfModerator: this.call('mail_service', 'isMyselfModerator'), - displayQuickSearch: channels.length >= this.options.channelQuickSearchThreshold, - options: this.options, - })); - return $sidebar; - }, - /** - * @private - * @param {Object} options - * @param {string} [options.searchChannelVal=''] - */ - _renderSidebarChannels: function (options) { - options.searchChannelVal = options.searchChannelVal || ''; - var channels = this.call('mail_service', 'getChannels'); - var searchChannelValLowerCase = options.searchChannelVal.toLowerCase(); - channels = _.filter(channels, function (channel) { - var channelNameLowerCase = channel.getName().toLowerCase(); - return channelNameLowerCase.indexOf(searchChannelValLowerCase) !== -1; - }); - channels = this._sortChannels(channels); - this.$('.o_mail_discuss_sidebar_channels').html( - QWeb.render('mail.discuss.SidebarChannels', { - activeThreadID: this._thread ? this._thread.getID() : undefined, - channels: channels, - displayQuickSearch: channels.length >= this.options.channelQuickSearchThreshold, - }) - ); - }, - /** - * @private - */ - _renderSidebarMailboxes: function () { - this.$('.o_mail_discuss_sidebar_mailboxes').html( - QWeb.render('mail.discuss.SidebarMailboxes', { - activeThreadID: this._thread ? this._thread.getID() : undefined, - inbox: this.call('mail_service', 'getMailbox', 'inbox'), - starred: this.call('mail_service', 'getMailbox', 'starred'), - moderation: this.call('mail_service', 'getMailbox', 'moderation'), - isMyselfModerator: this.call('mail_service', 'isMyselfModerator'), - }) - ); - }, - /** - * @private - * @param {string} template - * @param {Object} context rendering context - * @param {integer} [timeout=20000] the delay before the snackbar disappears - */ - _renderSnackbar: function (template, context, timeout) { - if (this.$snackbar) { - this.$snackbar.remove(); - } - timeout = timeout || 20000; - this.$snackbar = $(QWeb.render(template, context)); - this.$('.o_mail_discuss_content').append(this.$snackbar); - // Hide snackbar after [timeout] milliseconds (by default, 20s) - var $snackbar = this.$snackbar; - setTimeout(function () { $snackbar.fadeOut(); }, timeout); - }, - /** - * Renders, binds events and appends a thread widget. - * - * @private - * @returns {Promise} - */ - _renderThread: function () { - this._threadWidget = new ThreadWidget(this, { - loadMoreOnScroll: true - }); - - this._threadWidget - .on('redirect', this, function (resModel, resID) { - this.call('mail_service', 'redirect', resModel, resID, this._setThread.bind(this)); - }) - .on('redirect_to_channel', this, function (channelID) { - this.call('mail_service', 'joinChannel', channelID).then(this._setThread.bind(this)); - }) - .on('load_more_messages', this, this._loadMoreMessages) - .on('mark_as_read', this, function (messageID) { - this.call('mail_service', 'markMessagesAsRead', [messageID]); - }) - .on('toggle_star_status', this, function (messageID) { - var message = this.call('mail_service', 'getMessage', messageID); - message.toggleStarStatus(); - }) - .on('select_message', this, this._selectMessage) - .on('unselect_message', this, this._unselectMessage); - - return this._threadWidget.appendTo(this.$('.o_mail_discuss_content')); - }, - /** - * @private - * @param {mail.model.Channel} channel - */ - _restoreComposerState: function (channel) { - var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer; - var composerState = this._composerStates[channel.getUUID()]; - if (composerState) { - composer.setState(composerState); - } - }, - /** - * Restores the scroll position and composer state of the current thread - * - * @private - */ - _restoreThreadState: function () { - var $newMessagesSeparator = this.$('.o_thread_new_messages_separator'); - if ($newMessagesSeparator.length) { - this._threadWidget.$el.scrollTo($newMessagesSeparator); - } else { - var newThreadScrolltop = this._threadsScrolltop[this._thread.getID()]; - this._threadWidget.scrollToPosition(newThreadScrolltop); - } - if (this._thread.getType() !== 'mailbox') { - this._restoreComposerState(this._thread); - } - }, - /** - * @private - * @param {string} searchVal - * @returns {Promise<Array>} - */ - _searchChannel: function (searchVal){ - return this._rpc({ - model: 'mail.channel', - method: 'channel_search_to_join', - args: [searchVal] - }) - .then(function (result){ - var values = []; - _.each(result, function (channel){ - var escapedName = _.escape(channel.name); - values.push(_.extend(channel, { - 'value': escapedName, - 'label': escapedName, - })); - }); - return values; - }); - }, - /** - * @private - * @param {integer} messageID - */ - _selectMessage: function (messageID) { - this.$el.addClass('o_mail_selection_mode'); - var message = this.call('mail_service', 'getMessage', messageID); - this._selectedMessage = message; - var subject = "Re: " + message.getDocumentName(); - this._extendedComposer.setSubject(subject); - this._extendedComposer.showDiscardButton(); - - if (this._thread.getType() !== 'mailbox') { - this._basicComposer.do_hide(); - } - this._extendedComposer.do_show(); - - this._threadWidget.scrollToMessage({ - messageID: messageID, - duration: 200, - onlyIfNecessary: true - }); - this._extendedComposer.focus('body'); - }, - /** - * Set the selected thread with ID `threadID`. - * If there is no thread with such ID, fallback on mailbox 'inbox'. - * - * @private - * @param {integer} threadID a thread with such ID - * @returns {Promise} - */ - _setThread: async function (threadID) { - this.$('.o_out_of_office').remove(); - // Store scroll position and composer state of the previous thread - this._storeThreadState(); - - this._thread = this.call('mail_service', 'getThread', threadID) || - this.call('mail_service', 'getMailbox', 'inbox'); - - // reset value on channel change - this.messagesSeparatorPosition = undefined; - this._unreadCounter = this._thread.getUnreadCounter(); - if (this.$snackbar) { - this.$snackbar.remove(); - } - - this.action.context.active_id = this._thread.getID(); - this.action.context.active_ids = [this._thread.getID()]; - - this._basicComposer.setThread(this._thread); - this._extendedComposer.setThread(this._thread); - - await this._fetchAndRenderThread(); - // Mark thread's messages as read and clear needactions - if (this._thread.getType() !== 'mailbox') { - this._thread.markAsRead(); - } - // Restore scroll position and composer of the new current thread - this._restoreThreadState(); - - // Update control panel before focusing the composer, otherwise - // focus is on the searchview - await this.updateControlPanel({ - title: '#' + this._thread.getName(), - cp_content: { - $buttons: this.$buttons, - }, - }); - this._updateControlPanelButtons(this._thread); - - // Display and focus the adequate composer, and unselect possibly - // selected message to prevent sending messages as reply to that - // message - this._unselectMessage(); - - if (this._thread.hasOutOfOffice()) { - const $outOfOffice = $(QWeb.render('mail.thread.OutOfOffice', { - thread: this._thread - })); - $outOfOffice.prependTo(this.$('.o_mail_discuss_content')); - } - - this.action_manager.do_push_state({ - action: this.action.id, - active_id: this._thread.getID(), - }); - }, - /** - * @private - * @param {mail.model.Channel[]} channels - * @returns {mail.model.Channel[]} - */ - _sortChannels: function (channels) { - return channels; - }, - /** - * Binds handlers on mail bus events - * - * @private - */ - _startListening: function () { - this.call('mail_service', 'getMailBus') - .on('open_thread_in_discuss', this, this._onOpenThreadInDiscuss) - .on('new_message', this, this._onNewMessage) - .on('update_message', this, this._onMessageUpdated) - .on('new_channel', this, this._onNewChannel) - .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) - .on('unsubscribe_from_channel', this, this._onChannelLeft) - .on('updated_im_status', this, this._onUpdatedImStatus) - .on('update_needaction', this, this._onUpdateNeedaction) - .on('update_starred', this, this._onUpdateStarred) - .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) - .on('activity_updated', this, this._onActivityUpdated) - .on('update_moderation_counter', this, this._onUpdateModerationCounter) - .on('update_typing_partners', this, this._onTypingPartnersUpdated) - .on('update_channel', this, this._onUpdateChannel); - }, - /** - * @private - * @param {mail.model.Channel} channel - */ - _storeComposerState: function (channel) { - var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer; - this._composerStates[channel.getUUID()] = composer.getState(); - composer.clearComposer(); - }, - /** - * Stores the scroll position of the current thread. - * For channels, also stores composer state. - * - * @private - */ - _storeThreadState: function () { - if (this._thread) { - this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); - if (this._thread.getType() !== 'mailbox') { - this._storeComposerState(this._thread); - } - } - }, - /** - * @private - */ - _unselectMessage: function () { - this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing()); - this._extendedComposer.do_toggle(this._thread.isMassMailing()); - this._extendedComposer.hideDiscardButton(); - - if (!config.device.isMobile) { - var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ? - this._extendedComposer : - this._basicComposer; - composer.focus(); - } - this.$el.removeClass('o_mail_selection_mode'); - this._threadWidget.unselectMessage(); - this._selectedMessage = null; - }, - /** - * @private - * @param {boolean} disabled - * @param {string} type - */ - _updateButtonStatus: function (disabled, type) { - if (this._thread.getID() === 'mailbox_inbox') { - this.$buttons - .find('.o_mail_discuss_button_mark_all_read') - .toggleClass('disabled', disabled); - // Display Rainbowman when all inbox messages are read through - // 'MARK ALL READ' or marking last inbox message as read - if (disabled && type === 'mark_as_read') { - this.trigger_up('show_effect', { - message: _t("Congratulations, your inbox is empty!"), - type: 'rainbow_man', - }); - } - } - if (this._thread.getID() === 'mailbox_starred') { - this.$buttons - .find('.o_mail_discuss_button_unstar_all') - .toggleClass('disabled', disabled); - } - if ( - this._thread.getID() === 'mailbox_moderation' || - ( - this._thread.isModerated() && - this._thread.isMyselfModerator() - ) - ) { - this._updateModerationButtons(); - } - }, - /** - * Updates the control panel buttons visibility based on thread type - * - * @private - * @param {mail.model.Thread} thread - */ - _updateControlPanelButtons: function (thread) { - // Hide 'unsubscribe' button in state channels and DM chats and channels with group-based subscription - // Invite - if (thread.getType() !== 'dm_chat' && thread.getType() !== 'mailbox') { - this.$buttons - .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings') - .removeClass('d-none d-md-inline-block') - .addClass('d-none d-md-inline-block'); - } else { - this.$buttons - .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings') - .removeClass('d-none d-md-inline-block') - .addClass('d-none'); - } - // Mark All Read - if (thread.getID() === 'mailbox_inbox') { - this.$buttons - .find('.o_mail_discuss_button_mark_all_read') - .removeClass('d-none d-md-inline-block') - .addClass('d-none d-md-inline-block'); - } else { - this.$buttons - .find('.o_mail_discuss_button_mark_all_read') - .removeClass('d-none d-md-inline-block') - .addClass('d-none'); - } - // Unstar All - if (thread.getID() === 'mailbox_starred') { - this.$buttons - .find('.o_mail_discuss_button_unstar_all') - .removeClass('d-none'); - } else { - this.$buttons - .find('.o_mail_discuss_button_unstar_all') - .addClass('d-none'); - } - // Select All & Unselect All - if ( - (thread.isModerated() && thread.isMyselfModerator()) || - thread.getID() === 'mailbox_moderation' - ) { - this.$buttons - .find('.o_mail_discuss_button_select_all') - .removeClass('d-none'); - this.$buttons - .find('.o_mail_discuss_button_unselect_all') - .removeClass('d-none'); - } else { - this.$buttons - .find('.o_mail_discuss_button_select_all') - .addClass('d-none'); - this.$buttons - .find('.o_mail_discuss_button_unselect_all') - .addClass('d-none'); - } - this.$buttons.find('.o_mail_discuss_button_moderate_all').addClass('d-none'); - - this.$('.o_mail_discuss_item') - .removeClass('o_active') - .filter('[data-thread-id=' + thread.getID() + ']') - .removeClass('o_unread_message') - .addClass('o_active'); - }, - /** - * Update the moderation buttons. - * - * @private - */ - _updateModerationButtons: function () { - this._updateSelectUnselectAllButtons(); - this._updateModerationDecisionButton(); - }, - /** - * Display/hide the "moderate all" button based on whether - * some moderation checkboxes are checked or not. - * If some checkboxes are checked, display this button, - * otherwise hide it. - * - * @private - */ - _updateModerationDecisionButton: function () { - if (this._threadWidget.$('.moderation_checkbox:checked').length) { - this.$buttons.find('.o_mail_discuss_button_moderate_all').removeClass('d-none'); - } else { - this.$buttons.find('.o_mail_discuss_button_moderate_all').addClass('d-none'); - } - }, - /** - * @private - */ - _updateSelectUnselectAllButtons: function () { - var buttonSelect = this.$buttons.find('.o_mail_discuss_button_select_all'); - var buttonUnselect = this.$buttons.find('.o_mail_discuss_button_unselect_all'); - var numCheckboxes = this._threadWidget.$('.moderation_checkbox').length; - var numCheckboxesChecked = this._threadWidget.$('.moderation_checkbox:checked').length; - if (numCheckboxes) { - if (numCheckboxesChecked === numCheckboxes) { - buttonSelect.toggleClass('disabled', true); - buttonUnselect.toggleClass('disabled', false); - } else if (numCheckboxesChecked === 0) { - buttonSelect.toggleClass('disabled', false); - buttonUnselect.toggleClass('disabled', true); - } else { - buttonSelect.toggleClass('disabled', false); - buttonUnselect.toggleClass('disabled', false); - } - } else { - buttonSelect.toggleClass('disabled', true); - buttonUnselect.toggleClass('disabled', true); - } - }, - /** - * Re-renders the mainside bar with current threads - * - * @private - * @param {Object} [options={}] - * @param {string} [options.searchChannelVal=''] - */ - _updateThreads: function (options) { - var self = this; - options = options || {}; - this._renderSidebarMailboxes(options); - this._renderSidebarChannels(options); - _.each(['dm_chat', 'multi_user_channel'], function (type) { - var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input'); - self._prepareAddThreadInput($input, type); - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onActivityUpdated: function () { - this._throttledUpdateThreads(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAddThread: function (ev) { - ev.preventDefault(); - var type = $(ev.target).data('type'); - this.$('.o_mail_add_thread[data-type=' + type + ']') - .show() - .find('input').focus(); - }, - /** - * @private - */ - _onAddThreadBlur: function () { - this.$('.o_mail_add_thread').hide(); - }, - /** - * @private - * @param {integer|string} channelID - */ - _onChannelLeft: function (channelID) { - if (this._thread.getID() === channelID) { - this._setThread('mailbox_inbox'); - } - this._updateThreads(); - delete this._threadsScrolltop[channelID]; - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onChannelSettingsClicked: function (ev) { - ev.stopPropagation(); - var threadID = $(ev.currentTarget).data('thread-id'); - var thread = this.call('mail_service', 'getThread', threadID); - if (thread.getType() === 'dm_chat') { - new RenameConversationDialog(this, threadID, this._updateThreads.bind(this)).open(); - return; - } - this.do_action({ - type: 'ir.actions.act_window', - res_model: 'mail.channel', - res_id: threadID, - views: [[false, 'form']], - target: 'current' - }); - }, - /** - * @private - */ - _onComposerFocused: function () { - var composer = this._thread.isMassMailing() ? this._extendedComposer : this._basicComposer; - var commands = this._thread.getCommands(); - var partners = this._thread.getMentionPartnerSuggestions(); - composer.mentionSetCommands(commands); - composer.mentionSetPrefetchedPartners(partners); - }, - /** - * @private - * @param {OdooEvent} ev - */ - _onDiscardExtendedComposer: function (ev) { - ev.stopPropagation(); - this._unselectMessage(); - }, - /** - * When clicking on an item in the sidebar - * - * @private - * @param {MouseEvent} ev - */ - _onDiscussItemClicked: function (ev) { - ev.preventDefault(); - var threadID = $(ev.currentTarget).data('thread-id'); - this._setThread(threadID); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onInputSidebarQuickSearchInput: function (ev) { - ev.preventDefault(); - this._updateThreads({ - searchChannelVal: $(ev.currentTarget).val(), - }); - }, - /** - * Invite button is only for channels (not mailboxes) - * - * @private - */ - _onInviteButtonClicked: function () { - var title = _.str.sprintf(_t("Invite people to #%s"), this._thread.getName()); - new PartnerInviteDialog(this, title, this._thread.getID()).open(); - }, - /** - * Called when someone asks discuss whether the bottom of `thread` is - * visible or not. An object `query` is provided in order to reponse on the - * key `isVisible`. - * - * @private - * @param {mail.model.Thread} thread - * @param {Object} query - * @param {boolean} query.isVisible the response to provide on whether the - * bottom of the thread is visible in discuss. - */ - _onIsThreadBottomVisible: function (thread, query) { - query.isVisible = query.isVisible || - ( - thread.getID() === this._thread.getID() && - this._threadWidget.isAtBottom() - ); - }, - /** - * @private - * @param {KeyEvent} ev - */ - _onKeydown: function (ev) { - if (ev.which === $.ui.keyCode.ESCAPE && this._selectedMessage) { - this._unselectMessage(); - } - }, - /** - * @private - */ - _onMarkAllAsReadClicked: function () { - this._thread.markAllMessagesAsRead(this.domain); - }, - /** - * @private - * @param {OdooEvent} ev - * @param {integer} ev.data.messageID ID of the moderated message - * @param {string} ev.data.decision can be 'reject', 'discard', 'ban', 'accept', 'allow'. - */ - _onMessageModeration: function (ev) { - var messageIDs = [ev.data.messageID]; - var decision = ev.data.decision; - this._handleModerationDecision(messageIDs, decision); - }, - /** - * @private - * @param {mail.model.Message} message - * @param {string} [type] the channel - */ - _onMessageUpdated: function (message, type) { - var self = this; - var currentThreadID = this._thread.getID(); - if ( - (currentThreadID === 'mailbox_starred' && !message.isStarred()) || - (currentThreadID === 'mailbox_inbox' && !message.isNeedaction()) || - (currentThreadID === 'mailbox_moderation' && !message.needsModeration()) - ) { - this._thread.fetchMessages(this.domain) - .then(function () { - var options = self._getThreadRenderingOptions(); - self._threadWidget.removeMessageAndRender(message.getID(), self._thread, options) - .then(function () { - self._updateButtonStatus(!self._thread.hasMessages(), type); - }); - }); - } else if (_.contains(message.getThreadIDs(), currentThreadID)) { - this._fetchAndRenderThread(); - } - }, - /** - * @private - */ - _onMessagingReady: function () { - if (this._isMessagingReady) { - return; - } - this._isMessagingReady = true; - if (!this._isStarted) { - return; - } - this._initRender(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onModerateAllClicked: function (ev) { - var decision = $(ev.target).data('decision'); - var messageIDs = this._threadWidget.$('.moderation_checkbox:checked') - .map(function () { - return $(this).data('message-id'); - }) - .get(); - this._handleModerationDecision(messageIDs, decision); - }, - /** - * @private - * @param {mail.model.Channel} channel - */ - _onNewChannel: function (channel) { - this._updateThreads(); - if (channel.isAutoswitch()) { - this._setThread(channel.getID()); - } - }, - /** - * @private - * @param {mail.model.Message} message - */ - _onNewMessage: function (message) { - var self = this; - if (_.contains(message.getThreadIDs(), this._thread.getID())) { - if (this._thread.getType() !== 'mailbox' && this._threadWidget.isAtBottom()) { - this._thread.markAsRead(); - } - var shouldScroll = this._threadWidget.isAtBottom(); - this._fetchAndRenderThread().then(function () { - if (shouldScroll) { - self._threadWidget.scrollToMessage({ msgID: message.getID() }); - } - }); - } - // Re-render sidebar to indicate that there is a new message in the corresponding threads - this._updateThreads(); - // Dump scroll position of threads in which the new message arrived - this._threadsScrolltop = _.omit(this._threadsScrolltop, message.getThreadIDs()); - }, - /** - * Called when opening a thread in discuss, due to discuss being open. - * All threads except document threads are valid for discuss app. - * - * @private - * @param {mail.model.Thread} - */ - _onOpenThreadInDiscuss: function (thread) { - if (thread.getType() !== 'document_thread') { - this._setThread(thread.getID()); - } - }, - /** - * @private - * @param {Object} messageData - */ - _onPostMessage: function (messageData) { - var self = this; - var options = {}; - if (this._selectedMessage) { - messageData.subtype_xmlid = this._selectedMessage.isNote() ? 'mail.mt_note': 'mail.mt_comment'; - messageData.subtype_id = false; - messageData.message_type = 'comment'; - - options.documentID = this._selectedMessage.getDocumentID(); - options.documentModel = this._selectedMessage.getDocumentModel(); - } - this._thread.postMessage(messageData, options) - .then(function () { - if (self._selectedMessage) { - self._renderSnackbar('mail.discuss.MessageSentSnackbar', { - documentName: self._selectedMessage.getDocumentName() - }, 5000); - self._unselectMessage(); - } else { - self._threadWidget.scrollToBottom(); - } - }); - }, - /** - * @private - */ - _onPublicChannelsClick: function () { - this.do_action({ - name: _t("Public Channels"), - type: 'ir.actions.act_window', - res_model: 'mail.channel', - views: [[false, 'kanban'], [false, 'form']], - domain: [['public', '!=', 'private']], - }, { - on_reverse_breadcrumb: this.on_reverse_breadcrumb, - }); - }, - /** - * @private - * @param {Object} searchQuery - */ - _onSearch: function (searchQuery) { - this.domain = searchQuery.domain; - if (this._thread) { - // initially (when _onSearch is called manually), there is no - // thread set yet, so don't try to fetch and render the thread as - // this will be done as soon as the default thread is set - this._fetchAndRenderThread(); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onSelectAllClicked: function (ev) { - var $button = $(ev.target); - if (!$button.hasClass('disabled')) { - this._threadWidget.toggleModerationCheckboxes(true); - this._updateModerationButtons(); - } - }, - /** - * @private - */ - _onTypingPartnersUpdated: function () { - this._updateThreads(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onUnpinChannel: function (ev) { - ev.stopPropagation(); - var channelID = $(ev.target).data('thread-id'); - var channel = this.call('mail_service', 'getChannel', channelID); - if (channel.isMyselfAdministrator()) { - this._askConfirmationAdminUnsubscribe(channel); - } else { - channel.unsubscribe(); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onUnselectAllClicked: function (ev) { - var $button = $(ev.target); - if (!$button.hasClass('disabled')) { - this._threadWidget.toggleModerationCheckboxes(false); - this._updateModerationButtons(); - } - }, - /** - * @private - */ - _onUnstarAllClicked: function () { - this.call('mail_service', 'unstarAll'); - }, - /** - * @private - * @param {integer} channelID - */ - _onUpdateChannel: function (channelID) { - if (this._thread.getID() !== channelID) { - return; - } - this._fetchAndRenderThread(); - }, - /** - * @private - */ - _onUpdatedImStatus: function () { - this._throttledUpdateThreads(); - }, - /** - * Update the moderation buttons. - * This is triggered when a moderation checkbox - * has its checked property changed. - * - * @private - */ - _onUpdateModerationButtons: function () { - this._updateModerationButtons(); - }, - /** - * @private - */ - _onUpdateModerationCounter: function () { - this._throttledUpdateThreads(); - }, - /** - * @private - */ - _onUpdateNeedaction: function () { - this._throttledUpdateThreads(); - }, - /** - * @private - */ - _onUpdateStarred: function () { - this._throttledUpdateThreads(); - }, - /** - * @private - */ - _onUpdateThreadUnreadCounter: function () { - this._throttledUpdateThreads(); - }, -}); - -core.action_registry.add('mail.discuss', Discuss); - -return Discuss; - -}); diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js index be7dfeb9115..b46aea302c5 100644 --- a/addons/mail/static/src/js/document_viewer.js +++ b/addons/mail/static/src/js/document_viewer.js @@ -9,6 +9,13 @@ var QWeb = core.qweb; var SCROLL_ZOOM_STEP = 0.1; var ZOOM_STEP = 0.5; +/** + * This widget is deprecated, and should instead use AttachmentViewer component. + * @see `mail/static/src/components/attachment_viewer/attachment_viewer.js` + * TODO: remove this widget when it's not longer used + * + * @deprecated + */ var DocumentViewer = Widget.extend({ template: "DocumentViewer", events: { @@ -86,7 +93,6 @@ var DocumentViewer = Widget.extend({ if (this.isDestroyed()) { return; } - this.trigger_up('document_viewer_closed'); this.$el.modal('hide'); this.$el.remove(); this._super.apply(this, arguments); diff --git a/addons/mail/static/src/js/followers.js b/addons/mail/static/src/js/followers.js deleted file mode 100644 index bed5d27f176..00000000000 --- a/addons/mail/static/src/js/followers.js +++ /dev/null @@ -1,461 +0,0 @@ -odoo.define('mail.Followers', function (require) { -"use strict"; - -var AbstractField = require('web.AbstractField'); -var concurrency = require('web.concurrency'); -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var field_registry = require('web.field_registry'); -var session = require('web.session'); - -var _t = core._t; -var QWeb = core.qweb; - -// ----------------------------------------------------------------------------- -// Followers ('mail_followers') widget -// ----------------------------------------------------------------------------- -var Followers = AbstractField.extend({ - template: 'mail.Followers', - events: { - // click on '(Un)Follow' button, that toggles the follow for uid - 'click .o_followers_follow_button': '_onFollowButtonClicked', - // click on a subtype, that (un)subscribes for this subtype - 'click .o_subtypes_list .custom-checkbox': '_onSubtypeClicked', - // click on 'invite' button, that opens the invite wizard - 'click .o_add_follower': '_onAddFollower', - 'click .o_add_follower_channel': '_onAddChannel', - // click on 'edit_subtype' (pencil) button to edit subscription - 'click .o_edit_subtype': '_onEditSubtype', - 'click .o_remove_follower': '_onRemoveFollower', - 'click .o_mail_redirect': '_onRedirect', - }, - // this widget only supports one2many but is not generic enough to claim - // that it supports all one2many fields - // supportedFieldTypes: ['one2many'], - - // inherited - init: function (parent, name, record, options) { - this._super.apply(this, arguments); - - this.image = this.attrs.image || 'image_128'; - this.comment = this.attrs.help || false; - - this.followers = []; - this.subtypes = []; - this.data_subtype = {}; - this._isFollower = undefined; - this.partnerID = session.partner_id; - - this.dp = new concurrency.DropPrevious(); - - options = options || {}; - this.isEditable = options.isEditable; - }, - _render: function () { - // note: the rendering of this widget is asynchronous as it needs to - // fetch the details of the followers, but it performs a first rendering - // synchronously (_displayGeneric), and updates its rendering once it - // has fetched the required data, so this function doesn't return a promise - // as we don't want to wait to the data to be loaded to display the widget - var self = this; - var fetch_def = this.dp.add(this._readFollowers()); - - concurrency.rejectAfter(concurrency.delay(0), fetch_def).then(this._displayGeneric.bind(this)); - - return fetch_def.then(function () { - self._displayButtons(); - self._displayFollowers(self.followers); - if (self.subtypes) { // current user is follower - self._displaySubtypes(self.subtypes); - } - }); - }, - isSet: function () { - return true; - }, - _reset: function (record) { - this._super.apply(this, arguments); - // the mail widgets being persistent, one need to update the res_id on reset - this.res_id = record.res_id; - }, - - // public - getFollowers: function () { - return this.followers; - }, - - // private - _displayButtons: function () { - if (this._isFollower) { - this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following'); - this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false); - this.$('.o_followers_actions .dropdown-toggle').addClass('o_followers_following'); - } else { - this.$('button.o_followers_follow_button').removeClass('o_followers_following').addClass('o_followers_notfollow'); - this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', true); - this.$('.o_followers_actions .dropdown-toggle').removeClass('o_followers_following'); - } - this.$('button.o_followers_follow_button').attr("aria-pressed", this.is_follower); - }, - _displayGeneric: function () { - // only display the number of followers (e.g. if read failed) - this.$('.o_followers_actions').hide(); - this.$('.o_followers_title_box > button').prop('disabled', true); - this.$('.o_followers_count') - .html(this.value.res_ids.length) - .parent().attr('title', this._formatFollowers(this.value.res_ids.length)); - }, - _displayFollowers: function () { - var self = this; - - // render the dropdown content - var $followers_list = this.$('.o_followers_list').empty(); - $(QWeb.render('mail.Followers.add_more', {widget: this})).appendTo($followers_list); - var $follower_li; - _.each(this.followers, function (record) { - if (!record.is_active) { - record.title = _.str.sprintf(_t('%s \n(inactive)'), record.name); - } else { - record.title = record.name; - } - - $follower_li = $(QWeb.render('mail.Followers.partner', { - 'record': record, - 'widget': self}) - ); - $follower_li.appendTo($followers_list); - - // On mouse-enter it will show the edit_subtype pencil. - if (record.is_editable) { - $follower_li.on('mouseenter mouseleave', function (e) { - $(e.currentTarget).find('.o_edit_subtype').toggleClass('d-none', e.type === 'mouseleave'); - }); - } - }); - - // clean and display title - this.$('.o_followers_actions').show(); - this.$('.o_followers_title_box > button').prop('disabled', !$followers_list.children().length); - this.$('.o_followers_count') - .html(this.value.res_ids.length) - .parent().attr('title', this._formatFollowers(this.value.res_ids.length)); - }, - _displaySubtypes:function (data, dialog, display_warning) { - var old_parent_model; - var $list; - if (dialog) { - $list = $('<div>').appendTo(this.dialog.$el); - } else { - $list = this.$('.o_subtypes_list .dropdown-menu'); - } - $list.empty(); - - this.data_subtype = data; - - _.each(data, function (record) { - if (old_parent_model !== record.parent_model && old_parent_model !== undefined) { - $list.append($('<div>', {class: 'dropdown-divider'})); - } - old_parent_model = record.parent_model; - record.followed = record.followed || undefined; - $list.append(QWeb.render('mail.Followers.subtype', { - 'record': record, - 'dialog': dialog, - 'display_warning': display_warning && record.internal, - })); - }); - - if (display_warning) { - $(QWeb.render('mail.Followers.subtypes.warning')).appendTo(this.dialog.$el); - } - }, - _inviteFollower: function (channel_only) { - var action = { - type: 'ir.actions.act_window', - res_model: 'mail.wizard.invite', - view_mode: 'form', - views: [[false, 'form']], - name: _t("Invite Follower"), - target: 'new', - context: { - 'default_res_model': this.model, - 'default_res_id': this.res_id, - 'mail_invite_follower_channel_only': channel_only, - }, - }; - this.do_action(action, { - on_close: this._reload.bind(this), - }); - }, - _formatFollowers: function (count){ - var str = ''; - if (count <= 0) { - str = _t("No follower"); - } else if (count === 1){ - str = _t("One follower"); - } else { - str = ''+count+' '+_t("followers"); - } - return str; - }, - _readFollowers: function () { - var missing_ids = _.difference(this.value.res_ids, _.pluck(this.followers, 'id')); - var def; - if (missing_ids.length) { - def = this._rpc({ - route: '/mail/read_followers', - params: { follower_ids: missing_ids, context: {} } // empty context to be overridden in session.js with 'allowed_company_ids' - }); - } - return Promise.resolve(def).then((results) => { - if (results) { - // Preprocess records - _.each(results.followers, (record) => { - var resModel = record.partner_id ? 'res.partner' : 'mail.channel'; - var resId = record.partner_id ? record.partner_id : record.channel_id; - record.res_id = resId; - record.res_model = resModel; - record.avatar_url = '/web/image/' + resModel + '/' + resId + '/image_128'; - }); - this.followers = _.uniq(results.followers.concat(this.followers), 'id'); - if (results.subtypes) { //read_followers will return False if current user is not in the list - this.subtypes = results.subtypes; - } - } - // filter out previously fetched followers that are no longer following - this.followers = _.filter(this.followers, (follower) => { - return _.contains(this.value.res_ids, follower.id); - }); - var userFollower = _.filter(this.followers, (rec) => { - return this.partnerID === rec.partner_id; - }); - this._isFollower = userFollower.length >= 1; - }); - }, - _reload: function () { - this.trigger_up('reload', {fieldNames: [this.name]}); - }, - _follow: function () { - var kwargs = { - partner_ids: [this.partnerID], - context: {}, // FIXME - }; - this._rpc({ - model: this.model, - method: 'message_subscribe', - args: [[this.res_id]], - kwargs: kwargs, - }) - .then(this._reload.bind(this)); - }, - /** - * Remove partners or channels from the followers - * - * @private - * @param {Object} ids - * @param {Array} [ids.partner_ids] the partner ids - * @param {Array} [ids.channel_ids] the channel ids - */ - _messageUnsubscribe: function (ids) { - return this._rpc({ - model: this.model, - method: 'message_unsubscribe', - args: [[this.res_id], ids.partner_ids, ids.channel_ids], - }).then(this._reload.bind(this)); - }, - /** - * Remove partners or channels from the followers - * @param {Array} [ids.partner_ids] the partner ids - * @param {Array} [ids.channel_ids] the channel ids - */ - _unfollow: function (ids) { - var self = this; - // do not prompt confirmation dialog on unsubscribe of current user. - if (_.isEqual(ids.partner_ids, [this.partnerID]) && _.isEmpty(ids.channel_ids)) { - return this._messageUnsubscribe(ids); - } - return new Promise(function (resolve, reject) { - var follower = _.find(self.followers, { res_id: ids.partner_ids ? ids.partner_ids[0] : ids.channel_ids[0] }); - var text = _.str.sprintf(_t("If you remove a follower, he won't be notified of any email or discussion on this document. Do you really want to remove %s?"), follower.name); - Dialog.confirm(this, text, { - title: _t("Warning"), - confirm_callback: function () { - self._messageUnsubscribe(ids); - resolve(); - }, - cancel_callback: reject, - }); - }); - }, - _updateSubscription: function (event, followerID, isChannel) { - var ids = {}; - var subtypes; - - if (followerID !== undefined) { - // Subtypes edited from the modal - subtypes = this.dialog.$('input[type="checkbox"]'); - if (isChannel) { - ids.channel_ids = [followerID]; - } else { - ids.partner_ids = [followerID]; - } - } else { - subtypes = this.$('.o_followers_actions input[type="checkbox"]'); - ids.partner_ids = [this.partnerID]; - } - - // Get the subtype ids - var checklist = []; - _.each(subtypes, function (record) { - if ($(record).is(':checked')) { - checklist.push(parseInt($(record).data('id'))); - } - }); - - // If no more subtype followed, unsubscribe the follower - if (!checklist.length) { - this._unfollow(ids).guardedCatch(function () { - $(event.currentTarget).find('input').addBack('input').prop('checked', true); - }); - } else { - var kwargs = _.extend({}, ids); - if (followerID === undefined || followerID === this.partnerID) { - //this.subtypes will only be updated if the current user - //just added himself to the followers. We need to update - //the subtypes manually when editing subtypes - //for current user - _.each(this.subtypes, function (subtype) { - subtype.followed = checklist.indexOf(subtype.id) > -1; - }); - } - kwargs.subtype_ids = checklist; - kwargs.context = {}; // FIXME - this._rpc({ - model: this.model, - method: 'message_subscribe', - args: [[this.res_id]], - kwargs: kwargs, - }) - .then(this._reload.bind(this)); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} ev - */ - _onAddFollower: function (ev) { - ev.preventDefault(); - this._inviteFollower(false); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onAddChannel: function (ev) { - ev.preventDefault(); - this._inviteFollower(true); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onEditSubtype: function (ev) { - var self = this; - var $currentTarget = $(ev.currentTarget); - var follower_id = $currentTarget.data('follower-id'); // id of model mail_follower - this._rpc({ - route: '/mail/read_subscription_data', - params: {follower_id: follower_id}, - }) - .then(function (data) { - var res_id = $currentTarget.data('oe-id'); // id of model res_partner or mail_channel - var is_channel = $currentTarget.data('oe-model') === 'mail.channel'; - self.dialog = new Dialog(this, { - size: 'medium', - title: _t("Edit Subscription of ") + $currentTarget.siblings('a').text(), - buttons: [ - { - text: _t("Apply"), - classes: 'btn-primary', - click: function () { - self._updateSubscription(ev, res_id, is_channel); - }, - close: true - }, - { - text: _t("Cancel"), - close: true, - }, - ], - }); - self.dialog.opened().then(function () { - self._displaySubtypes(data, true, is_channel); - }); - self.dialog.open(); - }); - }, - /** - * @private - */ - _onFollowButtonClicked: function () { - if (!this._isFollower) { - this._follow(); - } else { - this._unfollow({partner_ids: [this.partnerID]}); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onRedirect: function (ev) { - ev.preventDefault(); - var $target = $(ev.target).closest('.o_mail_redirect'); - this.do_action({ - type: 'ir.actions.act_window', - view_mode: 'form', - res_model: $target.data('oe-model'), - views: [[false, 'form']], - res_id: $target.data('oe-id'), - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onRemoveFollower: function (ev) { - var resModel = $(ev.target).parent().find('a').data('oe-model'); - var resID = $(ev.target).parent().find('a').data('oe-id'); - if (resModel === 'res.partner') { - return this._unfollow({partner_ids: [resID]}); - } else { - return this._unfollow({channel_ids: [resID]}); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onSubtypeClicked: function (ev) { - ev.stopPropagation(); - this._updateSubscription(ev); - var $list = this.$('.o_subtypes_list'); - if (!$list.hasClass('show')) { - $list.addClass('show'); - } - if (this.$('.o_subtypes_list .dropdown-menu')[0].children.length < 1) { - $list.removeClass('show'); - } - }, -}); - -field_registry.add('mail_followers', Followers); - -return Followers; - -}); diff --git a/addons/mail/static/src/js/form_controller.js b/addons/mail/static/src/js/form_controller.js deleted file mode 100644 index 0d4c03d7a64..00000000000 --- a/addons/mail/static/src/js/form_controller.js +++ /dev/null @@ -1,47 +0,0 @@ - -odoo.define('mail.form_controller', function (require) { -"use strict"; - -var FormController = require('web.FormController'); - -/** - * Include the FormController to update the datapoint on the model when a - * message is posted. - */ -FormController.include({ - custom_events: _.extend({}, FormController.prototype.custom_events, { - new_message: '_onNewMessage', - }), - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - async saveRecord() { - const changedFields = await this._super(...arguments); - const chatter = this.renderer.chatter; - if (chatter) { - await chatter.updateSuggestedPartners(); - } - return changedFields; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {OdooEvent} ev - * @param {string} event.data.id datapointID - * @param {integer[]} event.data.messageIDs list of message IDs - */ - _onNewMessage: function (ev) { - this.model.updateMessageIDs(ev.data.id, ev.data.messageIDs); - }, -}); - -}); diff --git a/addons/mail/static/src/js/form_renderer.js b/addons/mail/static/src/js/form_renderer.js deleted file mode 100644 index e7b767c0b2f..00000000000 --- a/addons/mail/static/src/js/form_renderer.js +++ /dev/null @@ -1,77 +0,0 @@ -odoo.define('mail.form_renderer', function (require) { -"use strict"; - -var Chatter = require('mail.Chatter'); -var FormRenderer = require('web.FormRenderer'); - -/** - * Include the FormRenderer to instanciate the chatter area containing (a - * subset of) the mail widgets (mail_thread, mail_followers and mail_activity). - */ -FormRenderer.include({ - /** - * @override - */ - init: function (parent, state, params) { - this._super.apply(this, arguments); - this.mailFields = params.mailFields; - this.chatter = undefined; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Updates the chatter area with the new state if its fields has changed - * - * @override - */ - confirmChange: function (state, id, fields) { - if (this.chatter) { - var chatterFields = ['message_attachment_count'].concat(_.values(this.mailFields)); - var updatedMailFields = _.intersection(fields, chatterFields); - if (updatedMailFields.length) { - this.chatter.update(state, updatedMailFields); - } - } - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Overrides the function that renders the nodes to return the chatter's $el - * for the 'oe_chatter' div node. - * - * @override - * @private - */ - _renderNode: function (node) { - var self = this; - if (node.tag === 'div' && node.attrs.class === 'oe_chatter') { - if (!this.chatter) { - this.chatter = new Chatter(this, this.state, this.mailFields, { - isEditable: this.activeActions.edit, - viewType: 'form', - }); - - var $temporaryParentDiv = $('<div>'); - this.defs.push(this.chatter.appendTo($temporaryParentDiv).then(function () { - self.chatter.$el.unwrap(); - self._handleAttributes(self.chatter.$el, node); - })); - return $temporaryParentDiv; - } else { - this.chatter.update(this.state); - return this.chatter.$el; - } - } else { - return this._super.apply(this, arguments); - } - }, -}); - -}); diff --git a/addons/mail/static/src/js/form_view.js b/addons/mail/static/src/js/form_view.js deleted file mode 100644 index 18a8f77da16..00000000000 --- a/addons/mail/static/src/js/form_view.js +++ /dev/null @@ -1,26 +0,0 @@ -odoo.define('mail.FormView', function (require) { -"use strict"; - -var FormView = require('web.FormView'); - -/** - * This file is used to add "message_attachment_count" to fieldsInfo so we can fetch its value for the - * chatter's attachment button without having it explicitly declared in the form view template. - * - */ - -FormView.include({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - - var fieldsInfo = this.fieldsInfo[this.viewType]; - if (!_.isEmpty(this.mailFields) && !('message_attachment_count' in fieldsInfo)) { - fieldsInfo.message_attachment_count = {}; - } - }, -}); - -}); diff --git a/addons/mail/static/src/js/many2one_avatar_user.js b/addons/mail/static/src/js/many2one_avatar_user.js index ba92fdc3d5c..3a4e04089f5 100644 --- a/addons/mail/static/src/js/many2one_avatar_user.js +++ b/addons/mail/static/src/js/many2one_avatar_user.js @@ -26,6 +26,7 @@ odoo.define('mail.Many2OneAvatarUser', function (require) { const { Many2OneAvatar } = require('web.relational_fields'); const session = require('web.session'); + const { Component } = owl; const Many2OneAvatarUser = Many2OneAvatar.extend({ events: Object.assign({}, Many2OneAvatar.prototype.events, { @@ -117,7 +118,11 @@ odoo.define('mail.Many2OneAvatarUser', function (require) { partnerId = await this._resIdToPartnerId(this.value.res_id); } if (partnerId && partnerId !== session.partner_id) { - this.call('mail_service', 'openDMChatWindow', partnerId); + const env = Component.env; + const partner = env.models['mail.partner'].insert({ + id: partnerId, + }); + partner.openChat(); } else { this._displayWarning(partnerId); } diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js deleted file mode 100644 index 815c95a034f..00000000000 --- a/addons/mail/static/src/js/models/mail_failure.js +++ /dev/null @@ -1,127 +0,0 @@ -odoo.define('mail.model.MailFailure', function (require) { -"use strict"; - -var Class = require('web.Class'); -var core = require('web.core'); -var Mixins = require('web.mixins'); -var ServicesMixin = require('web.ServicesMixin'); -var time = require('web.time'); - -var _t = core._t; - -var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { - /** - * @param {mail.Manager} parent - * @param {Object} data - * @param {string} data.failure_type - * @param {string} data.last_message_date - * @param {integer} data.message_id - * @param {string} data.model - * @param {string} data.model_name - * @param {string} data.module_icon - * @param {Array} data.notifications - * @param {string} data.record_name - * @param {integer} data.res_id - * @param {string} data.uuid - */ - init: function (parent, data) { - Mixins.EventDispatcherMixin.init.call(this, arguments); - this.setParent(parent); - - this._documentID = data.res_id; - this._documentModel = data.model; - this._lastMessageDate = moment(); // by default: current datetime - this._messageID = data.message_id; - this._messageType = data.message_type; - this._modelName = data.model_name; - this._moduleIcon = data.module_icon; - this._notifications = data.notifications; - - if (data.last_message_date) { - this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date)); - } - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Get the document model that this mail failure is linked to. - * - * @returns {string} - */ - getDocumentModel: function () { - return this._documentModel; - }, - /** - * Get the document ID that this mail failure is linked to. - * - * @returns {integer} - */ - getDocumentID: function () { - return this._documentID; - }, - /** - * Get the ID of the message that this mail failure is related to. - * - * @returns {integer} - */ - getMessageID: function () { - return this._messageID; - }, - /** - * Get the type of the message that this mail failure is related to. - * - * @returns {string} - */ - getMessageType() { - return this._messageType; - }, - /** - * Get a valid object for the 'mail.preview' template - * - * @returns {Object} - */ - getPreview: function () { - var preview = { - body: this._getPreviewBody(), - date: this._lastMessageDate, - documentID: this._documentID, - documentModel: this._documentModel, - id: 'mail_failure', - imageSRC: this._getPreviewImage(), - title: this._modelName, - }; - return preview; - }, - /** - * Tell whether the mail failure comes from a message in a document. - * - * @returns {boolean} - */ - isLinkedToDocument: function () { - return !!(this._documentModel && this._documentID); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @returns {string} - */ - _getPreviewBody() { - return _t("An error occurred when sending an email."); - }, - /** - * @returns {string} - */ - _getPreviewImage() { - return '/mail/static/src/img/smiley/mailfailure.jpg'; - }, -}); - -return MailFailure; - -}); diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js deleted file mode 100644 index b293f6f1db0..00000000000 --- a/addons/mail/static/src/js/models/messages/abstract_message.js +++ /dev/null @@ -1,462 +0,0 @@ -odoo.define('mail.model.AbstractMessage', function (require) { -"use strict"; - -var mailUtils = require('mail.utils'); - -var Class = require('web.Class'); -var core = require('web.core'); -var session = require('web.session'); -var time = require('web.time'); - -var _t = core._t; - -/** - * This is an abstract class for modeling messages in JS. - * The purpose of this interface is to make im_livechat compatible with - * mail.widget.Thread, as this widget was designed to work with messages that - * are instances of mail.model.Messages. - * - * Ideally, im_livechat should also handle mail.model.Message, but this is not - * feasible for the moment, as mail.model.Message requires mail.Manager to work, - * and this module should not leak outside of the backend, hence the use of - * mail.model.AbstractMessage as a work-around. - */ -var AbstractMessage = Class.extend({ - - /** - * @param {Widget} parent - * @param {Object} data - * @param {Array} [data.attachment_ids=[]] - * @param {Array} [data.author_id] - * @param {string} [data.body = ""] - * @param {string} [data.date] the server-format date time of the message. - * If not provided, use current date time for this message. - * @param {integer} data.id - * @param {boolean} [data.is_discussion = false] - * @param {boolean} [data.is_notification = false] - * @param {string} [data.message_type = undefined] - */ - init: function (parent, data) { - this._attachmentIDs = data.attachment_ids || []; - this._body = data.body || ""; - // by default: current datetime - this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment(); - this._id = data.id; - this._isDiscussion = data.is_discussion; - this._isNotification = data.is_notification; - this._serverAuthorID = data.author_id; - this._type = data.message_type || undefined; - - this._processAttachmentURL(); - this._attachmentIDs.forEach(function (attachment) { - attachment.filename = attachment.filename || attachment.name || _t("unnamed"); - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Get the list of files attached to this message. - * Note that attachments are stored with server-format - * - * @return {Object[]} - */ - getAttachments: function () { - return this._attachmentIDs; - }, - /** - * Get the server ID (number) of the author of this message - * If there are no author, return -1; - * - * @return {integer} - */ - getAuthorID: function () { - if (!this.hasAuthor()) { - return -1; - } - return this._serverAuthorID[0]; - }, - /** - * Threads do not have an im status by default - * - * @return {undefined} - */ - getAuthorImStatus: function () { - return undefined; - }, - /** - * Get the relative url of the avatar to display next to the message - * - * @abstract - * @return {string} - */ - getAvatarSource: function () { - if (this.hasAuthor()) { - return '/web/image/res.partner/' + this.getAuthorID() + '/image_128'; - } - }, - /** - * Get the body content of this message - * - * @return {string} - */ - getBody: function () { - return this._body; - }, - /** - * @return {moment} - */ - getDate: function () { - return this._date; - }, - /** - * Get the date day of this message - * - * @return {string} - */ - getDateDay: function () { - var date = this.getDate().format('YYYY-MM-DD'); - if (date === moment().format('YYYY-MM-DD')) { - return _t("Today"); - } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) { - return _t("Yesterday"); - } - return this.getDate().format('LL'); - }, - /** - * Get the name of the author, if there is an author of this message - * If there are no author of this message, returns 'null' - * - * @return {string} - */ - getDisplayedAuthor: function () { - return this.hasAuthor() ? this._getAuthorName() : null; - }, - /** - * Get the server ID (number) of this message - * - * @override - * @return {integer} - */ - getID: function () { - return this._id; - }, - /** - * Get the list of images attached to this message. - * Note that attachments are stored with server-format - * - * @return {Object[]} - */ - getImageAttachments: function () { - return _.filter(this.getAttachments(), function (attachment) { - return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'; - }); - }, - /** - * Get the list of non-images attached to this message. - * Note that attachments are stored with server-format - * - * @return {Object[]} - */ - getNonImageAttachments: function () { - return _.difference(this.getAttachments(), this.getImageAttachments()); - }, - /** - * Gets the class to use as the notification icon. - * - * @returns {string} - */ - getNotificationIcon() { - if (!this.hasNotificationsError()) { - return 'fa fa-envelope-o'; - } - return 'fa fa-envelope'; - }, - /** - * Gets the list of notifications of this message, in no specific order. - * By default messages do not have notifications. - * - * @returns {Object[]} - */ - getNotifications() { - return []; - }, - /** - * Gets the text to display next to the notification icon. - * - * @returns {string} - */ - getNotificationText() { - return ''; - }, - /** - * Get the time elapsed between sent message and now - * - * @return {string} - */ - getTimeElapsed: function () { - return mailUtils.timeFromNow(this.getDate()); - }, - /** - * Get the type of message (e.g. 'comment', 'email', 'notification', ...) - * By default, messages are of type 'undefined' - * - * @override - * @return {string|undefined} - */ - getType: function () { - return this._type; - }, - /** - * State whether this message contains some attachments. - * - * @override - * @return {boolean} - */ - hasAttachments: function () { - return this.getAttachments().length > 0; - }, - /** - * State whether this message has an author - * - * @return {boolean} - */ - hasAuthor: function () { - return !!(this._serverAuthorID && this._serverAuthorID[0]); - }, - /** - * State whether this message has an email of its sender. - * By default, messages do not have any email of its sender. - * - * @return {string} - */ - hasEmailFrom: function () { - return false; - }, - /** - * State whether this image contains images attachments - * - * @return {boolean} - */ - hasImageAttachments: function () { - return _.some(this.getAttachments(), function (attachment) { - return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'; - }); - }, - /** - * State whether this image contains non-images attachments - * - * @return {boolean} - */ - hasNonImageAttachments: function () { - return _.some(this.getAttachments(), function (attachment) { - return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image'); - }); - }, - /** - * States whether this message has some notifications. - * - * @returns {boolean} - */ - hasNotifications() { - return this.getNotifications().length > 0; - }, - /** - * States whether this message has notifications that are in error. - * - * @returns {boolean} - */ - hasNotificationsError() { - return this.getNotifications().some(notif => - notif.notification_status === 'exception' || - notif.notification_status === 'bounce' - ); - }, - /** - * State whether this message originates from a channel. - * By default, messages do not originate from a channel. - * - * @override - * @return {boolean} - */ - originatesFromChannel: function () { - return false; - }, - /** - * State whether this message has a subject - * By default, messages do not have any subject. - * - * @return {boolean} - */ - hasSubject: function () { - return false; - }, - /** - * State whether this message is empty - * - * @return {boolean} - */ - isEmpty: function () { - return !this.hasTrackingValues() && - !this.hasAttachments() && - !this.getBody(); - }, - /** - * By default, messages do not have any subtype description - * - * @return {boolean} - */ - hasSubtypeDescription: function () { - return false; - }, - /** - * State whether this message contains some tracking values - * By default, messages do not have any tracking values. - * - * @return {boolean} - */ - hasTrackingValues: function () { - return false; - }, - /** - * State whether this message is a discussion - * - * @return {boolean} - */ - isDiscussion: function () { - return this._isDiscussion; - }, - /** - * State whether this message is linked to a document thread - * By default, messages are not linked to a document thread. - * - * @return {boolean} - */ - isLinkedToDocumentThread: function () { - return false; - }, - /** - * State whether this message is needaction - * By default, messages are not needaction. - * - * @return {boolean} - */ - isNeedaction: function () { - return false; - }, - /** - * State whether this message is a note (i.e. a message from "Log note") - * - * @return {boolean} - */ - isNote: function () { - return this._isNote; - }, - /** - * State whether this message is a notification - * - * User notifications are defined as either - * - notes - * - pushed to user Inbox or email through classic notification process - * - not linked to any document, meaning model and res_id are void - * - * This is useful in order to display white background for user - * notifications in chatter - * - * @returns {boolean} - */ - isNotification: function () { - return this._isNotification; - }, - /** - * State whether this message is starred - * By default, messages are not starred. - * - * @return {boolean} - */ - isStarred: function () { - return false; - }, - /** - * State whether this message is a system notification - * By default, messages are not system notifications - * - * @override - * @return {boolean} - */ - isSystemNotification: function () { - return false; - }, - /** - * States whether the current message needs moderation in general. - * By default, messages do not require any moderation. - * - * @returns {boolean} - */ - needsModeration: function () { - return false; - }, - /** - * @params {integer[]} attachmentIDs - */ - removeAttachments: function (attachmentIDs) { - this._attachmentIDs = _.reject(this._attachmentIDs, function (attachment) { - return _.contains(attachmentIDs, attachment.id); - }); - }, - /** - * State whether this message should redirect to the author - * when clicking on the author of this message. - * - * Do not redirect on author clicked of self-posted messages. - * - * @return {boolean} - */ - shouldRedirectToAuthor: function () { - return !this._isMyselfAuthor(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Get the name of the author of this message. - * If there are no author of this messages, returns '' (empty string). - * - * @private - * @returns {string} - */ - _getAuthorName: function () { - if (!this.hasAuthor()) { - return ""; - } - return this._serverAuthorID[1]; - }, - /** - * State whether the current user is the author of this message - * - * @private - * @return {boolean} - */ - _isMyselfAuthor: function () { - return this.hasAuthor() && (this.getAuthorID() === session.partner_id); - }, - /** - * Compute url of attachments of this message - * - * @private - */ - _processAttachmentURL: function () { - _.each(this.getAttachments(), function (attachment) { - attachment.url = '/web/content/' + attachment.id + '?download=true'; - }); - }, - -}); - -return AbstractMessage; - -}); diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js deleted file mode 100644 index 1e3ae8c5289..00000000000 --- a/addons/mail/static/src/js/models/messages/message.js +++ /dev/null @@ -1,806 +0,0 @@ -odoo.define('mail.model.Message', function (require) { -"use strict"; - -var emojis = require('mail.emojis'); -var AbstractMessage = require('mail.model.AbstractMessage'); -var mailUtils = require('mail.utils'); - -var core = require('web.core'); -var Mixins = require('web.mixins'); -var ServicesMixin = require('web.ServicesMixin'); -var session = require('web.session'); - -var _t = core._t; - -/** - * This is the main class for the modeling messages in JS. - * Such messages are stored in the mail manager, and any piece of JS code whose - * logic relies on threads must ideally interact with such objects. - */ -var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, { - /** - * @override - * @param {web.Manager} parent - * @param {Object} data - * @param {string} [data.body = ""] - * @param {(string|integer)[]} [data.channel_ids] - * @param {string} [data.email_from] - * @param {Array} [data.history_partner_ids = []] - * @param {string} [data.info] - * @param {string} [data.model] - * @param {string} [data.moderation_status='accepted'] - * @param {string} [data.module_icon] - * @param {Array} [data.needaction_partner_ids = []] - * @param {Object[]} [data.notifications] - * @param {string} [data.record_name] - * @param {integer} [data.res_id] - * @param {Array} [data.starred_partner_ids = []] - * @param {string} [data.subject] - * @param {string} [data.subtype_description] - * @param {Object[]} [data.tracking_value_ids] - * @param {Object[]} emojis - */ - init: function (parent, data, emojis) { - this._super.apply(this, arguments); - - Mixins.EventDispatcherMixin.init.call(this, arguments); - this.setParent(parent); - - this._setInitialData(data); - - this._processBody(emojis); - this._processMailboxes(); - this._processModeration(); - this._processDocumentThread(); - this._processTrackingValues(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - * @return {string|undefined} - */ - getAuthorImStatus: function () { - if (!this.hasAuthor()) { - return undefined; - } - return this.call('mail_service', 'getImStatus', { partnerID: this.getAuthorID() }); - }, - /** - * Get the name of the author of this message - * If there are no author, return "". - * - * @return {string} - */ - getAuthorName: function () { - return this._getAuthorName(); - }, - /** - * Get the relative url of the avatar to display next to the message - * - * @override - * @return {string} - */ - getAvatarSource: function () { - if (this._isOdoobotAuthor()) { - return '/mail/static/src/img/odoobot.png'; - } else if (this.hasAuthor()) { - return '/web/image/res.partner/' + this.getAuthorID() + '/image_128'; - } else if (this.getType() === 'email') { - return '/mail/static/src/img/email_icon.png'; - } - return '/mail/static/src/img/smiley/avatar.jpg'; - }, - /** - * Get the text to display for the author of the message - * - * Rule of precedence for the displayed author:: - * - * author name > sender email > "anonymous" - * - * @override - * @return {string} - */ - getDisplayedAuthor: function () { - var res = this._super.apply(this, arguments); - if (res) { - return res; - } else { - return this.hasEmailFrom() ? this.getEmailFrom() : - _t("Anonymous"); - } - }, - /** - * Get the ID of the document that this message is linked. - * - * @override - * @return {integer} - */ - getDocumentID: function () { - return this._documentID; - }, - /** - * Get the model of the document that this message is linked. - * If this message is not linked to a document, returns "". - * - * @override - * @return {string} - */ - getDocumentModel: function () { - return this._documentModel; - }, - /** - * Get the name of the document that this message is linked. - * If this message is not linked to a document, returns "". - * - * It uses the name of an existing document thread, in order to have the - * updated document name. If there is no document thread yet, use the - * currently aware document name. - * - * As a result, this method should ideally be called internally, in order - * to avoid having different names for messages linked to a same modified - * document. - * - * @override - * @return {string} - */ - getDocumentName: function () { - if (!this.isLinkedToDocumentThread()) { - return ""; - } - var model = this.getDocumentModel(); - var id = this.getDocumentID(); - var documentThread = this.call('mail_service', 'getDocumentThread', model, id); - if (documentThread) { - this._documentName = documentThread.getName(); - } - return this._documentName; - }, - /** - * Get the email of the sender of this message. - * If this email has no sender email, returns "". - * - * @return {string} - */ - getEmailFrom: function () { - if (!this.hasEmailFrom()) { - return ""; - } - return this._emailFrom; - }, - /** - * @override - */ - getNotifications() { - return Object.values(this._notifications); - }, - /** - * Get the ID of the channel that this message originates from. - * If this message does not originate from a channel, returns `-1`. - * - * @override - * @return {integer} - */ - getOriginChannelID: function () { - if (!this.originatesFromChannel()) { - return -1; - } - return this._documentID; - }, - /** - * Get the name of the channel that this message originates from. - * If this message does not originate from a channel, returns "". - * - * @override - * @returns {string} - */ - getOriginChannelName: function () { - if (!this.originatesFromChannel()) { - return ""; - } - var originChannelID = this.getOriginChannelID(); - var channel = originChannelID && this.call( - 'mail_service', - 'getChannel', - originChannelID); - if (!channel) { - return ""; - } - return channel.getName(); - }, - /** - * Returns the information required to render the preview of this channel. - * - * @return {Object} - */ - getPreview: function () { - var id, title; - if (this.isLinkedToDocumentThread()) { - id = this.getDocumentModel() + '_' + this.getDocumentID(); - title = this.getDocumentName(); - } else { - id = 'mailbox_inbox'; - title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor(); - } - return { - author: this.getDisplayedAuthor(), - body: mailUtils.htmlToTextContentInline(this.getBody()), - date: this.getDate(), - documentModel: this.getDocumentModel(), - documentID: this.getDocumentID(), - id: id, - imageSRC: this._getModuleIcon() || this.getAvatarSource(), - messageID: this.getID(), - status: this.status, - title: title, - isLinkedToDocumentThread: this.isLinkedToDocumentThread(), - }; - }, - /** - * Get the subject of this message - * If this message has no subject, returns "". - * - * @override - * @return {string} - */ - getSubject: function () { - if (!this.hasSubject()) { - return ""; - } - return this._subject; - }, - /** - * @override - * @return {string} - */ - getSubtypeDescription: function () { - return this._subtypeDescription; - }, - /** - * Get the list of thread IDs that this message is linked to - * If this message is not linked to a thread, returns 'undefined' - * - * @return {string[]|undefined} list of thread IDs, if any - */ - getThreadIDs: function () { - return this._threadIDs; - }, - /** - * Get the tracking values of this message - * If this message has no tracking values, returns 'undefined' - * - * @override - * @return {Object[]|undefined} - */ - getTrackingValues: function () { - if (!this.hasTrackingValues()) { - return undefined; - } - return this._trackingValueIDs; - }, - /** - * @override - * @return {string} - */ - getURL: function () { - return session.url('/mail/view?message_id=' + this._id); - }, - /** - * State whether this message has an email of its sender. - * - * @override - * @return {string} - */ - hasEmailFrom: function () { - return !!(this._emailFrom); - }, - /** - * State whether this message has a subject - * - * @return {boolean} - */ - hasSubject: function () { - return !!(this._subject); - }, - /** - * @override - * @return {boolean} - */ - hasSubtypeDescription: function () { - return !!(this._subtypeDescription); - }, - /** - * State whether this message contains some tracking values - * - * @override - * @return {boolean} - */ - hasTrackingValues: function () { - return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0)); - }, - /** - * State whether this message is linked to a document thread (not channel) - * - * Usually, if this is true, then this message comes from a document thread, - * but the document model could be a channel. In that case, the document - * resID tells the channel that this message originally comes from. - * - * To detect whether the message comes from a channel, see the method - * 'originatesFromChannel'. - * - * @override - * @return {boolean} - */ - isLinkedToDocumentThread: function () { - return !!(this._documentModel !== 'mail.channel' && this._documentID && this._type !== 'user_notification'); - }, - /** - * State whether the current user is the author of this message - * - * @return {boolean} - */ - isMyselfAuthor: function () { - return this._isMyselfAuthor(); - }, - /** - * States whether the current message needs moderation in general. - * - * @override - * @returns {boolean} - */ - needsModeration: function () { - return this._moderationStatus === 'pending_moderation'; - }, - /** - * States whether the current message needs moderation by the current user. - * Such a message should be in the moderation mailbox. - * - * @returns {boolean} - */ - needsModerationByUser: function () { - return _.contains(this._threadIDs, 'mailbox_moderation'); - }, - /** - * State whether this message is needaction - * - * @override - * @returns {boolean} - */ - isNeedaction: function () { - return _.contains(this._threadIDs, 'mailbox_inbox'); - }, - /** - * State whether this message is starred - * - * @override - * @returns {boolean} - */ - isStarred: function () { - return _.contains(this._threadIDs, 'mailbox_starred'); - }, - /** - * State whether this message is a system notification - * - * @override - * @returns {boolean} - */ - isSystemNotification: function () { - return ( - this.getType() === 'notification' && - this._documentModel === 'mail.channel' - ) || this._isTransient(); - }, - /** - * States whether the message originates from a channel or not - * - * @returns {boolean} - */ - originatesFromChannel: function () { - return this._documentModel === 'mail.channel'; - }, - /** - * Unregister thread with ID `threadID` from this message - * - * @param {string|integer} threadID ID of thread - */ - removeThread: function (threadID) { - this._threadIDs = _.without(this._threadIDs, threadID); - }, - /** - * Update the moderation status of the message, so that it is now accepted - * or rejected. When the message is accepted, it may be linked to more - * threads, which is the case for relay channels on moderated channels. - * - * @param {string} newModerationStatus ['accepted', 'rejected'] - * @param {Object} [options] - * @param {Object} [options.additionalThreadIDs] contains additional thread - * IDs to be registered on the message. - */ - setModerationStatus: function (newModerationStatus, options) { - var self = this; - if (newModerationStatus === this._moderationStatus) { - return; - } - this._moderationStatus = newModerationStatus; - if (newModerationStatus === 'accepted' && options) { - _.each(options.additionalThreadIDs, function (threadID) { - self._addThread(threadID); - }); - } - this._warnMessageModerated(); - }, - /** - * Sets notifications based on data from the server. - * - * @param {Object} notifications - */ - setNotifications(notifications) { - this._notifications = notifications; - }, - /** - * Set whether the message is starred or not. - * If it is starred, the message is moved to the "Starred" mailbox. - * Note that this function only applies it locally, the server is not aware - * - * @param {boolean} starred if set, the message is starred - */ - setStarred: function (starred) { - if (starred) { - this._addThread('mailbox_starred'); - } else { - this.removeThread('mailbox_starred'); - } - }, - /** - * State whether this message should display the subject - * - * @return {boolean} - */ - shouldDisplaySubject: function () { - return this.hasSubject() && - this.getType() !== 'notification' && - !this.originatesFromChannel(); - }, - /** - * State whether this message should redirect to the author - * when clicking on the author of this message. - * - * Do not redirect on author clicked of self-posted or Odoobot messages - * (note: Odoobot is the default author of transient messages) - * - * @override - * @return {boolean} - */ - shouldRedirectToAuthor: function () { - return this._super.apply(this, arguments) && !this._isOdoobotAuthor(); - }, - /** - * Toggle the star status of the message - * - * It relies on the star status of the message from the date of the server. - * The star status is updated from a 'toggle_star' notification on the - * longpoll bus - * - * @see {mail.Manager.Notification} for the receipt of 'toggle_star' - * notification after this rpc. - * - * @return {Promise} - */ - toggleStarStatus: function () { - return this._rpc({ - model: 'mail.message', - method: 'toggle_message_starred', - args: [[this._id]], - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Register a thread to the message - * Useful when you mark a message as 'to do'. - * This message will be available in 'Starred' mailbox. - * - * @private - * @param {string|integer} threadID - */ - _addThread: function (threadID) { - if (!this._threadIDs) { - this._threadIDs = []; - } - this._threadIDs.push(threadID); - this._threadIDs = _.uniq(this._threadIDs); - }, - /** - * Get the name of the author of this message. - * If Odoobot is the author (default author for transient messages), - * returns 'Odoobot'. - * - * @override - * @private - * @returns {string} - */ - _getAuthorName: function () { - if (this._isOdoobotAuthor()) { - return "OdooBot"; - } - return this._super.apply(this, arguments); - }, - /** - * @private - * @return {string} - */ - _getModuleIcon: function () { - return this._moduleIcon; - }, - /** - * State if the author of this message is OdooBot - * This is the default author for transient messages. - * - * @private - * @return {boolean} - */ - _isOdoobotAuthor: function () { - return this._serverAuthorID && - this._serverAuthorID[0] === this.call('mail_service', 'getOdoobotID')[0]; - }, - /** - * State whether the message is transient or not - * - * @private - * @return {boolean} - */ - _isTransient: function () { - return this._info === 'transient_message'; - }, - /** - * Convert the server-format body of the message to the client-format. - * Basically, it processes emojis and url. - * - * @private - */ - _processBody: function () { - var self = this; - - _.each(emojis, function (emoji) { - var unicode = emoji.unicode; - var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", 'g'); - var originalBody = self._body; - self._body = self._body.replace(regexp, - ' <span class="o_mail_emoji">' + unicode + '</span> '); - // Idiot-proof limit. If the user had the amazing idea of - // copy-pasting thousands of emojis, the image rendering can lead - // to memory overflow errors on some browsers (e.g. Chrome). Set an - // arbitrary limit to 200 from which we simply don't replace them - // (anyway, they are already replaced by the unicode counterpart). - if (_.str.count(self._body, 'o_mail_emoji') > 200) { - self._body = originalBody; - } - }); - - // add anchor tags to urls - self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink); - }, - /** - * If the message is linked to a document, link this message to the related - * document thread. If there is no document thread yet, create it - */ - _processDocumentThread: function () { - if (!this.isLinkedToDocumentThread()) { - return; - } - var resModel = this.getDocumentModel(); - var resID = this.getDocumentID(); - var documentThread = this.call('mail_service', 'getOrAddDocumentThread', { - resModel: resModel, - resID: resID, - name: this.getDocumentName(), - }); - this._threadIDs.push(documentThread.getID()); - }, - /** - * Set the appropriate mailboxes to this message based on server data - * - * @private - */ - _processMailboxes: function () { - if (_.contains(this._needactionPartnerIDs, session.partner_id)) { - this._setNeedaction(true); - } - if (_.contains(this._starredPartnerIDs, session.partner_id)) { - this.setStarred(true); - } - if (_.contains(this._historyPartnerIDs, session.partner_id)) { - this._setHistory(true); - } - if ( - this.originatesFromChannel() && - _.contains( - this.call('mail_service', 'getModeratedChannelIDs'), - this.getOriginChannelID() - ) && - this.needsModeration() - ) { - this._setModeratedByUser(true); - } - }, - /** - * Do some extra processing at message init, related to the - * moderated status of the message. - * - * If the message needs moderation, it is not yet linked to the - * moderated channel server-side. Therefore, it is not registered - * in the list of threadIDs, as it is built on server-side information - * at message initialisation (using data.channel_ids). - * - * Since the web client uses the list of thread IDs to show visually the - * message in a thread, we should hack the response of the server so that - * it assumes the message really belongs to this thread. - * - * @private - */ - _processModeration: function () { - if (this.needsModeration()) { - // the message is not linked to the moderated channel on the - // server, therefore this message has not this channel in - // channel_ids. Here, just to show this message in the channel - // visually, it links this message to the channel - this._threadIDs.push(this.getOriginChannelID()); - } - }, - /** - * Process the tracking values on message creation, which - * basically format date to the local only once by message - * - * Cannot be done in preprocess, since it alter the original value - * - * @private - */ - _processTrackingValues: function () { - if (this.hasTrackingValues()) { - _.each(this.getTrackingValues(), function (trackingValue) { - if (trackingValue.field_type === 'datetime') { - if (trackingValue.old_value) { - trackingValue.old_value = - moment - .utc(trackingValue.old_value) - .local() - .format('LLL'); - } - if (trackingValue.new_value) { - trackingValue.new_value = - moment - .utc(trackingValue.new_value) - .local() - .format('LLL'); - } - } else if (trackingValue.field_type === 'date') { - if (trackingValue.old_value) { - trackingValue.old_value = - moment(trackingValue.old_value) - .local() - .format('LL'); - } - if (trackingValue.new_value) { - trackingValue.new_value = - moment(trackingValue.new_value) - .local() - .format('LL'); - } - } - }); - } - }, - /** - * @private - * @param {Object} data - * @param {string} [data.body = ""] - * @param {(string|integer)[]} [data.channel_ids] - * @param {Object[]} [data.notifications] - * @param {string} [data.email_from] - * @param {string} [data.info] - * @param {string} [data.model] - * @param {string} [data.moderation_status='accepted'] - * @param {string} [data.module_icon] - * @param {Array} [data.needaction_partner_ids = []] - * @param {Array} [data.history_partner_ids = []] - * @param {string} [data.record_name] - * @param {integer} [data.res_id] - * @param {Array} [data.starred_partner_ids = []] - * @param {string} [data.subject] - * @param {string} [data.subtype_description] - * @param {Object[]} [data.tracking_value_ids] - */ - _setInitialData(data) { - this._notifications = data.notifications || {}; - this._documentModel = data.model; - this._documentName = data.record_name; - this._documentID = data.res_id; - this._emailFrom = data.email_from; - this._info = data.info; - this._isNote = data.is_note; - this._moduleIcon = data.module_icon; - this._needactionPartnerIDs = data.needaction_partner_ids || []; - this._starredPartnerIDs = data.starred_partner_ids || []; - this._historyPartnerIDs = data.history_partner_ids || []; - this._subject = data.subject; - this._subtypeDescription = data.subtype_description; - this._threadIDs = data.channel_ids || []; - this._trackingValueIDs = data.tracking_value_ids; - - this._moderationStatus = data.moderation_status || 'accepted'; - }, - /* - * Set whether the message is history or not. - * If it is history, the message is moved to the "History" mailbox. - * Note that this function only applies it locally, the server is not aware - * - * @private - * @param {boolean} history if set, the message is history - */ - _setHistory: function (history) { - if (history) { - this._addThread('mailbox_history'); - } else { - this.removeThread('mailbox_history'); - } - }, - /** - * Set whether the message is moderated by current user or not. - * If it is moderated by the current user, the message is moved to the - * "Moderation" mailbox. Note that this function only applies it locally, - * the server is not aware - * - * @private - * @param {boolean} moderated if set, the message is moderated by user - */ - _setModeratedByUser: function (moderated) { - if (moderated) { - this._addThread('mailbox_moderation'); - } else { - this.removeThread('mailbox_moderation'); - } - }, - /** - * Set whether the message is needaction or not. - * If it is needaction, the message is moved to the "Inbox" mailbox. - * Note that this function only applies it locally, the server is not aware - * - * @private - * @param {boolean} needaction if set, the message is needaction - */ - _setNeedaction: function (needaction) { - if (needaction) { - this._addThread('mailbox_inbox'); - } else { - this.removeThread('mailbox_inbox'); - } - }, - /** - * @private - */ - _warnMessageModerated: function () { - var mailBus = this.call('mail_service', 'getMailBus'); - if (this.needsModerationByUser()) { - this._setModeratedByUser(false); - var moderationBox = this.call('mail_service', 'getMailbox', 'moderation'); - moderationBox.decrementMailboxCounter(); - moderationBox.removeMessage(this.getID()); - mailBus.trigger('update_moderation_counter'); - } - if (this._moderationStatus !== 'accepted') { - this.call('mail_service', 'removeMessageFromThreads', this); - } - mailBus.trigger('update_message', this); - }, - -}); - -return Message; - -}); diff --git a/addons/mail/static/src/js/models/threads/abstract_thread.js b/addons/mail/static/src/js/models/threads/abstract_thread.js deleted file mode 100644 index 619bf95b8e3..00000000000 --- a/addons/mail/static/src/js/models/threads/abstract_thread.js +++ /dev/null @@ -1,231 +0,0 @@ -odoo.define('mail.model.AbstractThread', function (require) { -"use strict"; - -var Class = require('web.Class'); -var Mixins = require('web.mixins'); - -/** - * Abstract thread is the super class of all threads, either backend threads - * (which are compatible with mail service) or website livechats. - * - * Abstract threads contain abstract messages - */ -var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, { - /** - * @param {Object} params - * @param {Object} params.data - * @param {integer|string} params.data.id the ID of this thread - * @param {string} params.data.name the name of this thread - * @param {string} [params.data.status=''] the status of this thread - * @param {Object} params.parent Object with the event-dispatcher mixin - * (@see {web.mixins.EventDispatcherMixin}) - */ - init: function (params) { - Mixins.EventDispatcherMixin.init.call(this, arguments); - this.setParent(params.parent); - - this._folded = false; // threads are unfolded by default - this._id = params.data.id; - this._name = params.data.name; - this._status = params.data.status || ''; - this._unreadCounter = 0; // amount of messages not yet been read - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Add a message to this thread. - * - * @param {mail.model.AbstractMessage} message - */ - addMessage: function (message) { - this._addMessage.apply(this, arguments); - this.trigger('message_added', message); - }, - /** - * Updates the folded state of the thread - * - * @param {boolean} folded - */ - fold: function (folded) { - this._folded = folded; - }, - /** - * Get the ID of this thread - * - * @returns {integer|string} - */ - getID: function () { - return this._id; - }, - /** - * @abstract - * @returns {mail.model.AbstractMessage[]} - */ - getMessages: function () {}, - /** - * Get the name of this thread. If the name of the thread has been created - * by the user from an input, it may be escaped. - * - * @returns {string} - */ - getName: function () { - return this._name; - }, - /** - * By default, threads do not have out-of-office feature - * - * @return {string|undefined} - */ - getOutOfOfficeInfo: function () { - return undefined; - }, - /** - * Get the status of the thread (e.g. 'online', 'offline', etc.) - * - * @returns {string} - */ - getStatus: function () { - return this._status; - }, - /** - * Returns the title to display in thread window's headers. - * - * @returns {string} the name of the thread by default (see @getName) - */ - getTitle: function () { - return this.getName(); - }, - getType: function () {}, - /** - * @returns {integer} - */ - getUnreadCounter: function () { - return this._unreadCounter; - }, - /** - * @returns {boolean} - */ - hasMessages: function () { - return !_.isEmpty(this.getMessages()); - }, - /** - * @return {boolean} - */ - hasOutOfOffice() { - return !!this.getOutOfOfficeInfo(); - }, - /** - * States whether this thread is compatible with the 'seen' feature. - * By default, threads do not have thsi feature active. - * @see {mail.model.ThreadSeenMixin} to enable this feature on a thread. - * - * @returns {boolean} - */ - hasSeenFeature: function () { - return false; - }, - /** - * States whether this thread is compatible with the 'is typing...' feature. - * By default, threads do not have this feature active. - * @see {mail.model.ThreadTypingMixin} to enable this feature on a thread. - * - * @returns {boolean} - */ - hasTypingNotification: function () { - return false; - }, - /** - * States whether this thread is folded or not. - * - * @return {boolean} - */ - isFolded: function () { - return this._folded; - }, - /** - * Mark the thread as read, which resets the unread counter to 0. This is - * only performed if the unread counter is not 0. - * - * @returns {Promise} - */ - markAsRead: function () { - if (this._unreadCounter > 0) { - return this._markAsRead(); - } - return Promise.resolve(); - }, - /** - * Post a message on this thread - * - * @returns {Promise} resolved with the message object to be sent to the - * server - */ - postMessage: function () { - return this._postMessage.apply(this, arguments) - .then(this.trigger.bind(this, 'message_posted')); - }, - /** - * Resets the unread counter of this thread to 0. - */ - resetUnreadCounter: function () { - this._unreadCounter = 0; - this._warnUpdatedUnreadCounter(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add a message to this thread. - * - * @abstract - * @private - * @param {mail.model.AbstractMessage} message - */ - _addMessage: function (message) {}, - /** - * Increments the unread counter of this thread by 1 unit. - * - * @private - */ - _incrementUnreadCounter: function () { - this._unreadCounter++; - }, - /** - * Mark the thread as read - * - * @private - * @returns {Promise} - */ - _markAsRead: function () { - this.resetUnreadCounter(); - return Promise.resolve(); - }, - /** - * Post a message on this thread - * - * @abstract - * @private - * @returns {Promise} resolved with the message object to be sent to the - * server - */ - _postMessage: function () { - return Promise.resolve(); - }, - /** - * Warn views (e.g. discuss app, thread window, etc.) to update visually - * the unread counter of this thread. - * - * @abstract - * @private - */ - _warnUpdatedUnreadCounter: function () {}, -}); - -return AbstractThread; - -}); diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js deleted file mode 100644 index dced861c00a..00000000000 --- a/addons/mail/static/src/js/models/threads/channel.js +++ /dev/null @@ -1,474 +0,0 @@ -odoo.define('mail.model.Channel', function (require) { -"use strict"; - -var SearchableThread = require('mail.model.SearchableThread'); -var ThreadTypingMixin = require('mail.model.ThreadTypingMixin'); -var mailUtils = require('mail.utils'); - -const config = require('web.config'); -var session = require('web.session'); -var time = require('web.time'); - -/** - * This class represent channels in JS. In this context, the word channel - * has the same meaning of channel on the server, meaning that direct messages - * (DM) and livechats are also channels. - * - * Any piece of code in JS that make use of channels must ideally interact with - * such objects, instead of direct data from the server. - */ -var Channel = SearchableThread.extend(ThreadTypingMixin, { - /** - * @override - * @param {Object} params - * @param {Object} params.data - * @param {string} params.data.channel_type - * @param {integer} [params.data.create_uid] the ID of the user that has - * created the channel. - * @param {boolean} params.data.group_based_subscription - * @param {boolean} [params.data.is_minimized=false] - * @param {boolean} params.data.is_moderator whether the current user is - * moderator of this channel. - * @param {string} [params.data.last_message_date] date in server-format - * @param {Object[]} [params.data.members=[]] - * @param {integer} [params.data.members[i].id] - * @param {string} [params.data.members[i].name] - * @param {string} [params.data.members[i].email] - * @param {integer} [params.data.message_unread_counter] - * @param {boolean} [params.data.moderation=false] whether the channel is - * moderated or not - * @param {Object[]} [params.data.partners_info=[]] - * @param {integer} [params.data.partners_info[i].partner_id] - * @param {integer} [params.data.partners_info[i].fetched_message_id] - * @param {integer} [params.data.partners_info[i].seen_message_id] - * @param {string} params.data.state - * @param {string} [params.data.uuid] - * @param {Object} params.options - * @param {boolean} [params.options.autoswitch=true] - * @param {Object[]} params.commands - */ - init: function (params) { - var self = this; - this._super.apply(this, arguments); - ThreadTypingMixin.init.apply(this, arguments); - - var data = params.data; - var options = params.options; - var commands = params.commands; - - // If set, autoswitch channel on joining this channel in discuss - // the default behaviour is to autoswitch on join. - // exception: receiving channel session notifications - this._autoswitch = 'autoswitch' in options ? options.autoswitch : true; - this._commands = undefined; - this._creatorUID = data.create_uid; - this._detached = data.is_minimized || false; - this._directPartnerID = undefined; - this._folded = data.state === 'folded'; - // if set: hide 'Leave channel' button - this._groupBasedSubscription = data.group_based_subscription; - this._isModerated = data.moderation; - this._isMyselfModerator = data.is_moderator; - this._lastMessageDate = undefined; - this._members = data.members || []; - // Promise that is resolved on fetched members of this channel. - this._membersDef = undefined; - // number of messages that are 'needaction', which is equivalent to the - // number of messages in this channel that are in inbox. - this._needactionCounter = data.message_needaction_counter || 0; - this._serverType = data.channel_type; - // unique identifier for this channel, which is required for some rpc - this._uuid = data.uuid; - - // * list of commands available for this channel - this._commands = _.filter(commands, function (command) { - return !command.channel_types || - _.contains(command.channel_types, self._serverType); - }); - if (data.last_message_date) { - this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date)); - } - if (data.message_unread_counter) { - this._unreadCounter = data.message_unread_counter; - } - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Close the channel - * - * This operation is executed server-side and the chat window will be - * folded in all potential tabs from all browsers. - * - * @override - */ - close: function () { - this._super.apply(this, arguments); - // Do not notify the server to avoid desktop chat window from closing - // when a chat window is closed on mobile. - if (!config.device.isMobile) { - this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: { uuid: this.getUUID(), state: 'closed' }, - }, { shadow: true }); - } - }, - /** - * Decrement the needaction counter of the channel - * Floor value at 0. - * - * @param {integer} [num=1] the amount to decrement at most - */ - decrementNeedactionCounter: function (num) { - num = _.isNumber(num) ? num : 1; - this._needactionCounter = Math.max(this._needactionCounter - 1, 0); - }, - /** - * Open the chat window for this channel - * (in all potential tabs from all browsers) - * - * @pverride - */ - detach: function () { - var self = this; - return this._super.apply(this, arguments).then(function () { - // Do not notify the server to avoid desktop chat window from opening - // when a chat window is opened on mobile. - if (!config.device.isMobile) { - self._rpc({ - model: 'mail.channel', - method: 'channel_minimize', - args: [self.getUUID(), true], - }, { - shadow: true, - }); - } - }); - }, - /** - * Force fetch members of the channel - * - * @returns {Promise<Object[]>} resolved with list of channel listeners - */ - forceFetchMembers: function () { - this._membersDef = undefined; - return this.getMentionPartnerSuggestions(); - }, - /** - * Folds/Minimize the channel - * (in all potential tabs from all browsers) - * - * @override - * @param {boolean} folded - */ - fold: function (folded) { - this._super.apply(this, arguments); - var args = { - uuid: this.getUUID(), - }; - if (_.isBoolean(folded)) { - args.state = folded ? 'folded' : 'open'; - } - this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: args, - }, {shadow: true}); - }, - /** - * Show the list of available commands on this channel (e.g. '/help') - * - * @override - * @returns {Array} list of commands - */ - getCommands: function () { - return this._commands; - }, - /** - * @returns {mail.model.Message|undefined} last message of channel, if any - */ - getLastMessage: function () { - return _.last(this._cache['[]'].messages); - }, - /** - * Get listeners of a channel - * - * @returns {Promise<Object[]>} resolved with list of list of - * channel listeners. - */ - getMentionPartnerSuggestions: function () { - var self = this; - if (!this._membersDef) { - this._membersDef = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_listeners', - args: [this.getUUID()], - }, { - shadow: true - }) - .then(function (members) { - self._members = members; - return [members]; - }); - } - return this._membersDef; - }, - /** - * @returns {integer} - */ - getNeedactionCounter: function () { - return this._needactionCounter; - }, - /** - * @override - */ - getPreview: function () { - var result = this._super.apply(this, arguments); - if (!this.isTwoUserThread()) { - result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_128'; - } - var lastMessage = this.getLastMessage(); - return _.extend(result, { - author: lastMessage ? lastMessage.getDisplayedAuthor() : '', - body: lastMessage ? mailUtils.htmlToTextContentInline(lastMessage.getBody()) : '', - date: lastMessage ? lastMessage.getDate() : undefined, - isMyselfAuthor: this.hasMessages() && this.getLastMessage().isMyselfAuthor(), - }); - }, - /** - * Get the UUID of the channel. - * This is a string that uniquely links this channel to a server channel. - * - * @returns {string} uuid of this channel - */ - getUUID: function () { - return this._uuid; - }, - /** - * Increment the needaction counter of this channel by 1 unit - */ - incrementNeedactionCounter: function () { - this._needactionCounter++; - }, - /** - * States whether the channel should be auto-selected on creation - * - * Note that this is not of the responsibility of the channel - * (see mail.model.Thread for more information) - * - * @override - * @returns {boolean} - */ - isAutoswitch: function () { - return this._autoswitch; - }, - /** - * All instances of this class are chanels - * - * @override - * @returns {boolean} - */ - isChannel: function () { - return true; - }, - /** - * States whether the channel auto-subscribes some users in a group - * - * @returns {boolean} - */ - isGroupBasedSubscription: function () { - return this._groupBasedSubscription; - }, - /** - * States whether the channel is moderated or not. - * - * @override - * @returns {boolean} - */ - isModerated: function () { - return this._isModerated; - }, - /** - * Tells whether the current user is administrator of the channel. - * Note that there is no administrator for two-user channels - * - * @returns {boolean} - */ - isMyselfAdministrator: function () { - return session.uid === this._creatorUID && !this.isTwoUserThread(); - }, - /** - * States whether the current user is moderator of this channel. - * - * @returns {boolean} - */ - isMyselfModerator: function () { - return this._isMyselfModerator; - }, - /** - * Unsubscribes from channel - * - * @abstract - * @returns {Promise} resolve when unsubscribed - */ - unsubscribe: function () {}, - /** - * Updates the internal state of the channel, and reflects the changes in - * the UI. - * - * Called by {mail.Manager.Notification} on receiving a channel window - * notification. A notification is received after a client-side action - * that changes the state of the channel window visually, so that cross tab - * channels are updated similarly. - * - * TODO: This method should be dropped if the state of windows are - * completely handled client-side, which should be the case in the near - * future. - * - * @param {Object} params - * @param {boolean} params.detached - * @param {boolean} params.folded - */ - updateWindowState: function (params) { - this._folded = 'folded' in params ? params.folded : this._folded; - this._detached = 'detached' in params ? params.detached : this._detached; - this._warnUpdatedWindowState(); - }, - /** - * Reset the needaction counter to 0. - */ - resetNeedactionCounter: function () { - this._needactionCounter = 0; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Override so that it tells whether the channel is moderated or not. This - * is useful in order to display pending moderation messages when the - * current user is either moderator of the channel or has posted some - * messages that are pending moderation. - * - * @override - * @private - * @returns {Object} - */ - _getFetchMessagesKwargs: function () { - var kwargs = this._super.apply(this, arguments); - if (this.isModerated()) { - kwargs.moderated_channel_ids = [this.getID()]; - } - return kwargs; - }, - /** - * Get the domain to fetch all the messages in the current channel - * - * @override - * @private - * @returns {Array} - */ - _getThreadDomain: function () { - return [['channel_ids', 'in', [this._id]]]; - }, - /** - * @override {mail.model.ThreadTypingMixin} - * @private - * @param {Object} params - * @param {integer} params.partnerID - */ - _isTypingMyselfInfo: function (params) { - return session.partner_id === params.partnerID; - }, - /** - * Marks this channel as read. - * The last seen message will be the last message. - * Resolved with the last seen message, only for non-mailbox channels - * - * @override - * @private - * @returns {Promise} resolved when message has been marked as read - */ - _markAsRead: function () { - var superDef = this._super.apply(this, arguments); - var seenDef = this._notifySeen(); - return Promise.all([superDef, seenDef]); - }, - /** - * @override {mail.model.ThreadTypingMixin} - * @private - * @param {Object} params - * @param {boolean} params.typing - * @returns {Promise} - */ - _notifyMyselfTyping: function (params) { - return this._rpc({ - model: 'mail.channel', - method: 'notify_typing', - args: [this.getID()], - kwargs: { is_typing: params.typing }, - }, { shadow: true }); - }, - /** - * @private - * @returns {$.Promise<integer>} resolved with ID of last seen message - */ - _notifySeen: function () { - var self = this; - return this._rpc({ - model: 'mail.channel', - method: 'channel_seen', - args: [[this._id]], - }, { shadow: true }).then(function (lastSeenMessageID) { - self._lastSeenMessageID = lastSeenMessageID; - return lastSeenMessageID; - }); - }, - /** - * Prepare and send a message to the server on this channel. - * - * @override - * @private - * @param {Object} data data related to the new message - * @returns {Promise<Object>} resolved when the message has been sent to - * the server, with the object message that has been sent to the server. - */ - _postMessage: function (data) { - var self = this; - return this._super.apply(this, arguments).then(function (messageData) { - _.extend(messageData, { - message_type: 'comment', - subtype_xmlid: 'mail.mt_comment', - command: data.command, - }); - return self._rpc({ - model: 'mail.channel', - method: data.command ? 'execute_command' : 'message_post', - args: [self._id], - kwargs: messageData, - }).then(function () { - return messageData; - }); - }); - }, - /** - * Warn views that the list of users that are currently typing on this - * thread has been updated. - * - * @override {mail.model.ThreadTypingMixin} - * @private - */ - _warnUpdatedTypingPartners: function () { - this.call('mail_service', 'getMailBus') - .trigger('update_typing_partners', this.getID()); - }, -}); - -return Channel; - -}); diff --git a/addons/mail/static/src/js/models/threads/create_mode_document_thread.js b/addons/mail/static/src/js/models/threads/create_mode_document_thread.js deleted file mode 100644 index da632de0470..00000000000 --- a/addons/mail/static/src/js/models/threads/create_mode_document_thread.js +++ /dev/null @@ -1,75 +0,0 @@ -odoo.define('mail.model.CreateModeDocumentThread', function (require) { -"use strict"; - -var AbstractMessage = require('mail.model.AbstractMessage'); -var AbstractThread = require('mail.model.AbstractThread'); - -var core = require('web.core'); -var session = require('web.session'); - -var _t = core._t; - -/** - * Special document thread that is linked to a new document that is in create - * mode. This thread only display a single (fake) message which displays the - * user avatar and the message 'Creating a new record...'. - * - * Note: it uses AbstractThread and AbstractMessage, because this is a fake - * thread which contains a fake message. We do not want to register both of them - * in the mail service. - */ -var CreateModeDocumentThread = AbstractThread.extend({ - - /** - * @override - */ - init: function () { - var params = { - data: { - id: '_createModeDocumentThread' - } - }; - - this._super(params); - - this._messages = this._forgeCreateMessages(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @returns {mail.model.AbstractMessage[]} - */ - getMessages: function () { - return this._messages; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Make a fake list of messages to render in create mode. - * Instead of rendering no messages at all, it displays a single message - * with body content "Creating a new record". - * - * @private - * @returns {mail.model.AbstractMessage[]} an array containing a single - * message 'Creating a new record...' - */ - _forgeCreateMessages: function () { - var createMessage = new AbstractMessage(this, { - id: 0, - body: _t("<p>Creating a new record...</p>"), - author_id: [session.partner_id, session.partner_display_name], - }); - return [createMessage]; - }, - -}); - -return CreateModeDocumentThread; - -}); diff --git a/addons/mail/static/src/js/models/threads/dm_chat.js b/addons/mail/static/src/js/models/threads/dm_chat.js deleted file mode 100644 index 177c5224ea4..00000000000 --- a/addons/mail/static/src/js/models/threads/dm_chat.js +++ /dev/null @@ -1,109 +0,0 @@ -odoo.define('mail.model.DMChat', function (require) { -"use strict"; - -var ChannelSeenMixin = require('mail.model.ChannelSeenMixin'); -var TwoUserChannel = require('mail.model.TwoUserChannel'); - -var core = require('web.core'); -const { str_to_datetime } = require('web.time'); - -var _t = core._t; - -/** - * Any piece of code in JS that make use of DMs must ideally interact with - * such objects, instead of direct data from the server. - */ -var DMChat = TwoUserChannel.extend(ChannelSeenMixin, { - /** - * @override - * @param {Object} params - * @param {Object} params.data - * @param {string|undefined} [params.custom_channel_name] if set, use this - * custom name for this DM - * @param {Object[]} params.data.direct_partner - * @param {integer} params.data.direct_partner[0].id - * @param {string} params.data.direct_partner[0].im_status - * @param {string} params.data.direct_partner[0].name - * @param {string} [params.data.direct_partner[0].out_of_office_date_end] - */ - init: function (params) { - this._super.apply(this, arguments); - ChannelSeenMixin.init.apply(this, arguments); - - var data = params.data; - - this._directPartnerID = data.direct_partner[0].id; - this._name = data.custom_channel_name || data.direct_partner[0].name; - this._outOfOfficeDateEnd = data.direct_partner[0].out_of_office_date_end; - this._type = 'dm_chat'; - - this.call('mail_service', 'updateImStatus', [{ - id: this._directPartnerID, - im_status: data.direct_partner[0].im_status - }]); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Get the direct partner ID linked to the DM, i.e. the partner ID of the - * user at the other end of the DM conversation. All DM chats do have a - * direct partner iD. - * - * @returns {integer} - */ - getDirectPartnerID: function () { - return this._directPartnerID; - }, - /** - * Get the out of office info - * - * @override {mail.model.AbstractThread} - * @returns {string|undefined} - */ - getOutOfOfficeInfo: function () { - if (!this._outOfOfficeDateEnd) { - return undefined; - } - var currentDate = new Date(); - var date = str_to_datetime(this._outOfOfficeDateEnd); - var options = { day: 'numeric', month: 'short' }; - if (currentDate.getFullYear() !== date.getFullYear()) { - options.year = 'numeric'; - } - var formattedDate = date.toLocaleDateString(window.navigator.language, options); - return _.str.sprintf(_t("Out of office until %s"), formattedDate); - }, - /** - * @override - */ - getPreview: function () { - var result = this._super.apply(this, arguments); - result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_128'; - return result; - }, - /** - * @override - * @return {string} - */ - getStatus: function () { - return this.call('mail_service', 'getImStatus', { partnerID: this._directPartnerID }); - }, - - /** - * @param {Object} data - * @param {string} data.outOfOfficeDateEnd - */ - updateOutOfOfficeInfo: function (data) { - this._outOfOfficeDateEnd = data.outOfOfficeDateEnd; - this.call('mail_service', 'getMailBus').trigger('updated_out_of_office', { - threadID: this.getID(), - }); - } -}); - -return DMChat; - -}); diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js deleted file mode 100644 index eefc8f9bd79..00000000000 --- a/addons/mail/static/src/js/models/threads/document_thread.js +++ /dev/null @@ -1,364 +0,0 @@ -odoo.define('mail.model.DocumentThread', function (require) { -"use strict"; - -var Thread = require('mail.model.Thread'); - -var session = require('web.session'); - -/** - * These threads represent threads that are directly attached to documents. - * It is sometimes called the "chatter", but technically this is just the - * part of the chatter containing the thread, as the chatter also contains - * the activity and listener fields. - * - * Note that there are still some hacks in the handling of document threads, - * due to document threads are instantiated from fetched messages, either from - * mail.ThreadField or by fetching messages in Inbox. - * - * Also, because messages are not sent on the longpoll bus for this kind of - * threads, it stores the list of message IDs that it contains, which should - * be up-to-date whenever someone wants to get the messages in the document - * thread. - */ -var DocumentThread = Thread.extend({ - - /** - * @override - * @param {Object} params - * @param {Object} params.data - * @param {string} [params.data.messagesIDs] the list of message ids linked - * to the document (if not given, they will be fetched before fetching the - * messages) - * @param {string} params.data.model - * @param {integer} params.data.resID - */ - init: function (params) { - var data = params.data; - data.id = data.resModel + '_' + data.resID; - data.type = 'document_thread'; - - this._super.apply(this, arguments); - - // ID of the related document - this._documentID = data.resID; - // model of the related document - this._documentModel = data.resModel; - // used to handle history of messages - this._messageIDs = data.messageIDs || []; - // list of loaded messages - this._messages = []; - - // if the messageIDs haven't been given, fetch them before fetching the - // messages - this._mustFetchMessageIDs = !_.isArray(data.messageIDs); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Overrides to store the thread's state in the LocalStorage, so that it is - * shared between tabs, and restored on F5. - * - * @override - * @param {Object} [options] - * @param {boolean} [options.skipCrossTabSync=false] if set, it should - * not notify other tabs from updating document thread state. This happens - * in case the `close` operation comes from local storage event. - */ - close: function (options) { - this._super.apply(this, arguments); - if (options && options.skipCrossTabSync) { - return; - } - this.call('mail_service', 'updateDocumentThreadState', this._id, { - name: this.getName(), - windowState: 'closed', - }); - }, - /** - * Overrides to store the thread's state in the LocalStorage, so that it is - * shared between tabs, and restored on F5. - * - * @override - * @param {Object} [options] - * @param {boolean} [options.skipCrossTabSync=false] if set, it should - * not notify other tabs from updating document thread state. This happens - * in case the `detach` operation comes from local storage event. - */ - detach: function (options) { - this._super.apply(this, arguments); - if (options && options.skipCrossTabSync) { - return; - } - var windowState = this._folded ? 'folded' : 'open'; - this.call('mail_service', 'updateDocumentThreadState', this._id, { - name: this.getName(), - windowState: windowState, - }); - }, - /** - * - * Fetch messages of the document thread - * - * @override - * @param {Object} options - * @param {boolean} [options.forceFetch] if true, fetch anyway, as user - * clicked on 'load more'. - * @returns {Promise<mail.model.Message[]>} - */ - fetchMessages: function (options) { - var self = this; - return this._fetchMessages(options).then(function () { - return self._messages; - }); - }, - /** - * Overrides to store the thread's state in the LocalStorage, so that it is - * shared between tabs, and restored on F5. - * - * @override - * @param {Object} [options] - * @param {boolean} [options.skipCrossTabSync=false] if set, it should - * not notify other tabs from updating document thread state. This happens - * in case the `detach` operation comes from local storage event. - */ - fold: function (folded, options) { - this._super.apply(this, arguments); - if (options && options.skipCrossTabSync) { - return; - } - var windowState = this._folded ? 'folded' : 'open'; - this.call('mail_service', 'updateDocumentThreadState', this._id, { - name: this.getName(), - windowState: windowState, - }); - }, - /** - * Get the model name of the document that is linked to this document thread - * - * @returns {string} - */ - getDocumentModel: function () { - return this._documentModel; - }, - /** - * Get the ID of the document that is linked to this document thread - * - * @returns {integer} - */ - getDocumentID: function () { - return this._documentID; - }, - /** - * Get he list of message IDs that this document threads contain. - * - * @returns {integer[]} - */ - getMessageIDs: function () { - return this._messageIDs; - }, - /** - * @override - * @returns {mail.model.Message[]} - */ - getMessages: function () { - return this._messages; - }, - /** - * States whether the thread is linked to a document - * Document thread's are always linked to a document. - * - * @override - * @returns {boolean} - */ - isLinkedToDocument: function () { - return true; - }, - /** - * @param {integer[]} attachmentIDs - */ - removeAttachmentsFromMessages: function (attachmentIDs) { - _.each(this.getMessages(), function (message) { - message.removeAttachments(attachmentIDs); - }); - }, - /** - * Set list of message IDs of this document thread - * - * Useful in order to handle message history of the document thread, - * in particular to fetch messages when necessary and/or display 'load more'. - * - * @param {integer[]} messageIDs - */ - setMessageIDs: function (messageIDs) { - this._mustFetchMessageIDs = false; - this._messageIDs = messageIDs; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add this message to this document thread. - * - * This is ignored if the message is already linked to this document thread. - * Also, there is no fetch of unread message counter in a document thread, - * as this object is built from messages and/or thread field widget. The - * unread counter of a document thread is the sum of needaction messages - * linked to this document thread. - * - * @override - * @private - * @param {mail.model.Message} message - */ - _addMessage: function (message) { - this._super.apply(this, arguments); - if (_.contains(this._messages, message)) { - return; - } - // update internal list of messages - this._messages.push(message); - this._messages = _.sortBy(this._messages, function (msg) { - return msg.getID(); - }); - // update message ids associated to this document thread - if (!_.contains(this._messageIDs, message.getID())) { - this._messageIDs.push(message.getID()); - } - // update unread counter - if (message.isNeedaction()) { - this._unreadCounter++; - } - }, - /** - * Get most up to date messageIDs - * - * @private - * @returns {Promise} resolved when message IDs have been fetched and set - * in the model - */ - _fetchMessageIDs: function () { - var self = this; - var resID = this.getDocumentID(); - if (!resID || !this._mustFetchMessageIDs) { - return Promise.resolve(); - } - return this._rpc({ - model: this.getDocumentModel(), - method: 'read', - args: [[resID], ['message_ids']], - }).then(function (result) { - self.setMessageIDs(result[0].message_ids); - }); - }, - /** - * @override - * @private - * @param {Object} options - * @param {boolean} [options.forceFetch] - * @returns {Promise} resolved when messages have been fetched + document - * thread has updated messages - */ - _fetchMessages: function (options) { - var self = this; - return this._fetchMessageIDs().then(function () { - var messageIDs = self._messageIDs; - var loadedMessages = _.filter(self._messages, function (message) { - return _.contains(messageIDs, message.getID()); - }); - var loadedMessageIDs = _.map(loadedMessages, function (message) { - return message.getID(); - }); - - options = options || {}; - var shouldFetch = _.difference( - messageIDs.slice(0, self._FETCH_LIMIT), - loadedMessageIDs - ).length > 0; - if (options.forceFetch || shouldFetch) { - var idsToLoad = _.difference(messageIDs, loadedMessageIDs) - .slice(0, self._FETCH_LIMIT); - return self._rpc({ - model: 'mail.message', - method: 'message_format', - args: [idsToLoad], - context: session.user_context, - }) - .then(function (messagesData) { - _.each(messagesData, function (messageData) { - self.call('mail_service', 'addMessage', messageData, { silent: true }); - }); - }); - } else { - return Promise.resolve(); - } - - }); - }, - /** - * Overrides this method so that all the messages of this document thread - * are marked as read on the server. - * - * @override - * @private - * @returns {Promise} resolved when messages have been marked as read on - * the server. - */ - _markAsRead: function () { - var self = this; - return this._super.apply(this, arguments).then(function () { - self.call('mail_service', 'markMessagesAsRead', self._messageIDs); - }); - }, - /** - * Post message for document thread - * - * @override - * @private - * @param {Object} data data related to the new message - * @return {Promise<Object>} resolved when the message has been sent to - * the server, with the object message that has been sent to the server. - */ - _postMessage: function (data) { - var self = this; - var resModel = this.getDocumentModel(); - var resID = this.getDocumentID(); - return this._super.apply(this, arguments) - .then(function (messageData) { - _.extend(messageData, { - context: data.context, - message_type: data.message_type, - subtype_xmlid: data.subtype_xmlid || "mail.mt_comment", - subtype_id: data.subtype_id, - }); - return self._rpc({ - model: resModel, - method: 'message_post', - args: [resID], - kwargs: messageData, - }) - .then(function (messageID) { - return self._rpc({ - model: 'mail.message', - method: 'message_format', - args: [[messageID]], - }) - .then(function (messages) { - messages[0].model = resModel; - messages[0].res_id = resID; - self.call('mail_service', 'addMessage', messages[0], { - postedFromDocumentThread: true, - }); - return messages[0]; - }); - }); - }); - }, -}); - -return DocumentThread; - -}); diff --git a/addons/mail/static/src/js/models/threads/livechat.js b/addons/mail/static/src/js/models/threads/livechat.js deleted file mode 100644 index c3b4a5e2b4d..00000000000 --- a/addons/mail/static/src/js/models/threads/livechat.js +++ /dev/null @@ -1,95 +0,0 @@ -odoo.define('mail.model.Livechat', function (require) { -"use strict"; - -var TwoUserChannel = require('mail.model.TwoUserChannel'); - -/** - * backend-side of the livechat. - * - * Any piece of code in JS that make use of Livechats must ideally interact with - * such objects, instead of direct data from the server. - */ -var Livechat = TwoUserChannel.extend({ - _WEBSITE_USER_ID: '_websiteUser', - _WEBSITE_USER_NAME: 'Website user', - - /** - * @override - * @param {Object} params - * @param {Object} params.data - */ - init: function (params) { - this._super.apply(this, arguments); - this._name = params.data.correspondent_name; - this._WEBSITE_USER_NAME = this._name; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Override so that the list of members has the website use. This is - * necessary in order for the 'is_typing' feature to compute the name to - * display of a user that is typing. - * - * @override - * @returns {Promise<Object[]>} resolved with list of list of - * livechat members. - */ - getMentionPartnerSuggestions: function () { - var self = this; - return this._super.apply(this, arguments).then(function () { - var websiteUser = _.findWhere(self._members, { id: self._WEBSITE_USER_ID }); - if (!websiteUser) { - self._members.push({ - id: self._WEBSITE_USER_ID, - name: self._WEBSITE_USER_NAME, - }); - } - return [self._members]; - }); - }, - /** - * Called when someone starts typing something on the livechat. - * - * Overrides it so that it determine the partner based on the received - * userID. The reason is that anonymous users have the partner ID of the - * admin, which is likely also an operator, so userID must be used in order - * to distinct them. - * - * @override {mail.model.ThreadTypingMixin} - * @param {Object} params - * @param {integer} params.partnerID ID of the partner that is currently - * typing something on the thread. - * @param {boolean} [params.isWebsiteUser=false] whether the typing partner - * is an anonymous user (reminder: they share partnerID with admin). - */ - registerTyping: function (params) { - params.partnerID = this._WEBSITE_USER_ID; - this._super.call(this, params); - }, - /** - * Called when someone stops typing something on the livechat. - * - * Overrides it so that it determine the partner based on the received - * userID. The reason is that anonymous users have the partner ID of the - * admin, which is likely also an operator, so userID must be used in order - * to distinct them. - * - * @override {mail.model.ThreadTypingMixin} - * @param {Object} params - * @param {integer} params.partnerID ID of the partner that stops typing - * something on the thread. - * @param {boolean} [params.isWebsiteUser=false] whether the typing partner - * is an anonymous user (reminder: they share partnerID with admin). - */ - unregisterTyping: function (params) { - params.partnerID = this._WEBSITE_USER_ID; - this._super.call(this, params); - }, -}); - -return Livechat; - -}); diff --git a/addons/mail/static/src/js/models/threads/mailbox.js b/addons/mail/static/src/js/models/threads/mailbox.js deleted file mode 100644 index a08f92f0ab8..00000000000 --- a/addons/mail/static/src/js/models/threads/mailbox.js +++ /dev/null @@ -1,224 +0,0 @@ -odoo.define('mail.model.Mailbox', function (require) { -"use strict"; - -var SearchableThread = require('mail.model.SearchableThread'); - -var core = require('web.core'); - -var _t = core._t; - -/** - * Mailboxes, called "static channels" in earlier version of Odoo, are threads - * that are not represented on the server. We can see them as "threads that are - * not conversations". For instance, the well-known "Inbox" folder contains a - * list of messages, but the inbox does not represent a conversation: Inbox is - * modeled as a mailbox. - */ -var Mailbox = SearchableThread.extend({ - - /** - * @override - * @param {Object} params - * @param {Object} params.data - * @param {string} params.data.id the ID of the mailbox, without the ``mailbox_`` prefix. - * @param {integer} [params.data.mailboxCounter=0] the initial mailbox counter of this mailbox. - */ - init: function (params) { - var data = params.data; - // ID or mailboxes are always prefixed with 'mailbox_' - data.id = 'mailbox_' + data.id; - data.type = 'mailbox'; - this._super.apply(this, arguments); - this._mailboxCounter = data.mailboxCounter || 0; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Decrement the counter of this mailbox. It floors to 0. - * - * @param {integer} [num=1] how many, at most, the counter is decremented - */ - decrementMailboxCounter: function (num) { - num = _.isNumber(num) ? num : 1; - this._mailboxCounter = Math.max(this._mailboxCounter - num, 0); - }, - /** - * Get the mailbox counter of this mailbox. - * - * @returns {integer} - */ - getMailboxCounter: function () { - return this._mailboxCounter; - }, - /** - * Get the preview of the messages from the inbox mailbox. - * If there are multiple messages associated to a similar document or - * channel, only keep the lastest message of this document/channel in the - * previews. - * - * @returns {Promise<Object[]>} - */ - getMessagePreviews: function () { - var self = this; - - return this.fetchMessages().then(function (messages) { - // pick only last message of chatter - // items = list of objects - // { - // unreadCounter: {integer}, - // message: {mail.model.Message}, - // messageIDs: {integer[]}, - // } - var items = []; - _.each(messages, function (message) { - var unreadCounter = 1; - var messageIDs = [message.getID()]; - var similarItem = _.find(items, function (item) { - return self._areMessagesFromSameDocumentThread(item.message, message) || - self._areMessagesFromSameChannel(item.message, message); - }); - if (similarItem) { - unreadCounter = similarItem.unreadCounter + 1; - messageIDs = similarItem.messageIDs.concat(messageIDs); - var index = _.findIndex(items, similarItem); - items[index] = { - unreadCounter: unreadCounter, - message: message, - messageIDs: messageIDs - }; - } else { - items.push({ - unreadCounter: unreadCounter, - message: message, - messageIDs: messageIDs, - }); - } - }); - return _.map(items, function (item) { - return _.extend(item.message.getPreview(), { - unreadCounter: item.unreadCounter, - messageIDs: item.messageIDs, - }); - }); - }); - }, - /** - * Increment the counter of this mailbox - * - * @param {integer} [num=1] how many, the counter is incremented - */ - incrementMailboxCounter: function (num) { - num = _.isNumber(num) ? num : 1; - this._mailboxCounter = Math.max(this._mailboxCounter + num, 0); - }, - /** - * Marks all messages from the mailbox as read. At the moment, this method - * makes only sense for 'Inbox'. - * - * @param {Array} domain - * @return {Promise} resolved when all messages have been marked as read - * on the server - */ - markAllMessagesAsRead: function (domain) { - if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) { - return this._rpc({ - model: 'mail.message', - method: 'mark_all_as_read', - kwargs: { - domain: domain, - }, - }); - } - return Promise.resolve(); - }, - /** - * @param {integer} newCounter - */ - setMailboxCounter(newCounter) { - this._mailboxCounter = newCounter; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Tell whether the two messages originate from the same channel. - * If any one of them does not originate from a channel, returns false. - * - * @private - * @param {mail.model.Message} message1 - * @param {mail.model.Message} message2 - * @returns {boolean} - */ - _areMessagesFromSameChannel: function (message1, message2) { - return message1.originatesFromChannel() && - message2.originatesFromChannel() && - message1.getOriginChannelID() === message2.getOriginChannelID(); - }, - /** - * Tell whether the two messages are linked to the same document thread. - * If any one of them is not linked to a document, returns false. - * - * @private - * @param {mail.model.Message} message1 - * @param {mail.model.Message} message2 - * @returns {boolean} - */ - _areMessagesFromSameDocumentThread: function (message1, message2) { - return message1.isLinkedToDocumentThread() && - message2.isLinkedToDocumentThread() && - message1.getDocumentModel() === message2.getDocumentModel() && - message1.getDocumentID() === message2.getDocumentID(); - }, - /** - * Get the domain to fetch all the messages in the current mailbox - * - * @override - * @private - * @returns {Array} - * @throws on missing domain for the provided mailbox (mailboxes should - * always have a domain on messages to fetch) - */ - _getThreadDomain: function () { - if (this._id === 'mailbox_inbox') { - return [['needaction', '=', true]]; - } else if (this._id === 'mailbox_starred') { - return [['starred', '=', true]]; - } else if (this._id === 'mailbox_history') { - return [['needaction', '=', false]]; - } else if (this._id === 'mailbox_moderation') { - return [['need_moderation', '=', true]]; - } else { - throw (_.str(_t("Missing domain for mailbox with ID '%s'"), this._id)); - } - }, - /** - * Post a message from inbox. This is used when using the 'reply' feature - * on a message that is linked to a document thread. - * - * @override - * @private - * @param {Object} messageData - * @param {Object} options - * @param {integer} options.documentID - * @param {string} options.documentModel - * @returns {$.Promise} - */ - _postMessage: function (messageData, options) { - var documentThread = this.call( - 'mail_service', - 'getDocumentThread', - options.documentModel, - options.documentID - ); - return documentThread.postMessage(messageData); - }, -}); - -return Mailbox; - -}); diff --git a/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js b/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js deleted file mode 100644 index 20a5bba5407..00000000000 --- a/addons/mail/static/src/js/models/threads/mixins/channel_seen_mixin.js +++ /dev/null @@ -1,229 +0,0 @@ -odoo.define('mail.model.ChannelSeenMixin', function (require) { -"use strict"; - -var session = require('web.session'); - -/** - * Mixin for enabling the "seen" feature on channels. - */ -var ChannelSeenMixin = { - /** - * Initialize the internal data for seen feature on channels. - * - * Also listens on some internal events of the channel: - * - * - 'message_added': used to notify channel has been fetched - * - * @param {Object} params - * @param {Object[]} [params.data.partners_info=[]] - * @param {integer} [params.data.seen_partners_info[i].partner_id] - * @param {integer|boolean} [params.data.seen_partners_info[i].fetched_message_id=false] - * @param {integer|boolean} [params.data.seen_partners_info[i].seen_message_id=false] - */ - init: function (params) { - this._seenPartnersInfo = params.data.seen_partners_info || []; - - this.on('message_added', this, this._onSeenMessageAdded); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Get list of members that have fetched the provided message on this - * channel, but not yet seen it. Ignore current user in this list. - * - * @param {mail.model.Message} message - * @returns {Object[]} list of members - */ - getFetchedNotSeenMembers: function (message) { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - - // seen partner IDs - var seenPartnersInfo = _.filter(otherPartnersInfo, function (info) { - return info.seen_message_id && info.seen_message_id >= message.getID(); - }); - var seenPartnerIDs = _.map(seenPartnersInfo, function (info) { - return info.partner_id; - }); - - // fetched partner IDs (ignore seen) - var fetchedPartnersInfo = _.filter(otherPartnersInfo, function (info) { - var lastFetchedID = info.fetched_message_id || info.seen_message_id || 0; - return lastFetchedID >= message.getID(); - }); - var fetchedPartnerIDs = _.map(fetchedPartnersInfo, function (info) { - return info.partner_id; - }); - fetchedPartnerIDs = _.reject(fetchedPartnerIDs, function (fetchedPartnerID) { - return _.contains(seenPartnerIDs, fetchedPartnerID); - }); - - // fetched members - var fetchedMembers = _.filter(this._members, function (member) { - return _.contains(fetchedPartnerIDs, member.id); - }); - - return fetchedMembers; - }, - /** - * @returns {integer} - */ - getLastMessageIDSeenByEveryone: function () { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - var seenMessageIDs = _.map(otherPartnersInfo, function (info) { - return info.seen_message_id || 0; - }); - if (_.isEmpty(seenMessageIDs)) { - return 0; - } - return _.min(seenMessageIDs); - }, - /** - * Get list of members that have seen the provided message on this channel. - * Ignore current user in this list. - * - * @param {mail.model.Message} message - * @returns {Object[]} list of members - */ - getSeenMembers: function (message) { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - - // seen partner IDs - var seenPartnersInfo = _.filter(otherPartnersInfo, function (info) { - return info.seen_message_id >= message.getID(); - }); - var seenPartnerIDs = _.map(seenPartnersInfo, function (info) { - return info.partner_id; - }); - - // seen members - var seenMembers = _.filter(this._members, function (member) { - return _.contains(seenPartnerIDs, member.id); - }); - - return seenMembers; - }, - /** - * @param {mail.model.Message} message - * @returns {boolean} - */ - hasEveryoneFetched: function (message) { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - var lastFetchedMessageIDs = _.map(otherPartnersInfo, function (info) { - return info.fetched_message_id || info.seen_message_id || 0; - }); - if (_.isEmpty(lastFetchedMessageIDs)) { - return 0; - } - return message.getID() <= _.min(lastFetchedMessageIDs); - }, - /** - * @param {mail.model.Message} message - * @returns {boolean} - */ - hasEveryoneSeen: function (message) { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - var lastSeenMessageIDs = _.map(otherPartnersInfo, function (info) { - return info.seen_message_id || 0; - }); - if (_.isEmpty(lastSeenMessageIDs)) { - return 0; - } - return message.getID() <= _.min(lastSeenMessageIDs); - }, - /** - * @param {mail.model.Message} message - * @returns {boolean} - */ - hasSomeoneFetched: function (message) { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - var lastFetchedMessageIDs = _.map(otherPartnersInfo, function (info) { - return info.fetched_message_id || info.seen_message_id || 0; - }); - if (_.isEmpty(lastFetchedMessageIDs)) { - return false; - } - var fetched = _.find(lastFetchedMessageIDs, function (msgID) { - return msgID >= message.getID(); - }); - return fetched; - }, - /** - * @param {mail.model.Message} message - * @returns {boolean} - */ - hasSomeoneSeen: function (message) { - var otherPartnersInfo = _.filter(this._seenPartnersInfo, function (info) { - return info.partner_id !== session.partner_id; - }); - var lastSeenMessageIDs = _.map(otherPartnersInfo, function (info) { - return info.seen_message_id || 0; - }); - if (_.isEmpty(lastSeenMessageIDs)) { - return false; - } - var seen = _.find(lastSeenMessageIDs, function (msgID) { - return msgID >= message.getID(); - }); - return seen; - }, - /** - * @returns {boolean} - */ - hasSeenFeature: function () { - return true; - }, - /** - * @param {Object} data - * @param {string} data.info either 'channel_seen' or 'channel_fetched' - * @param {integer} data.partner_id - * @param {integer} data.last_message_id - */ - updateSeenPartnersInfo: function (data) { - var seenInfo = _.findWhere(this._seenPartnersInfo, { partner_id: data.partner_id }); - if (!seenInfo) { - seenInfo = { - partner_id: data.partner_id, - seen_message_id: 0, - fetched_message_id: 0, - }; - this._seenPartnersInfo.push(seenInfo); - } - if (data.info === 'channel_fetched') { - seenInfo.fetched_message_id = data.last_message_id; - } else if (data.info === 'channel_seen') { - seenInfo.fetched_message_id = data.last_message_id; - seenInfo.seen_message_id = data.last_message_id; - } - this.call('mail_service', 'getMailBus').trigger('update_channel', this.getID()); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onSeenMessageAdded: function () { - this.call('mail_service', 'notifyChannelFetched', { channelID: this._id }); - }, -}; - -return ChannelSeenMixin; - -}); diff --git a/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js b/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js deleted file mode 100644 index 3c98836dc70..00000000000 --- a/addons/mail/static/src/js/models/threads/mixins/thread_typing_mixin.js +++ /dev/null @@ -1,360 +0,0 @@ -odoo.define('mail.model.ThreadTypingMixin', function (require) { -"use strict"; - -var CCThrottleFunction = require('mail.model.CCThrottleFunction'); -var Timer = require('mail.model.Timer'); -var Timers = require('mail.model.Timers'); - -var core = require('web.core'); - -var _t = core._t; - -/** - * Mixin for enabling the "is typing..." notification on a type of thread. - */ -var ThreadTypingMixin = { - // Default partner infos - _DEFAULT_TYPING_PARTNER_ID: '_default', - _DEFAULT_TYPING_PARTNER_NAME: 'Someone', - - /** - * Initialize the internal data for typing feature on threads. - * - * Also listens on some internal events of the thread: - * - * - 'message_added': when a message is added, remove the author in the - * typing partners. - * - 'message_posted': when a message is posted, let the user have the - * possibility to immediately notify if he types something right away, - * instead of waiting for a throttle behaviour. - */ - init: function () { - // Store the last "myself typing" status that has been sent to the - // server. This is useful in order to not notify the same typing - // status multiple times. - this._lastNotifiedMyselfTyping = false; - - // Timer of current user that is typing a very long text. When the - // receivers do not receive any typing notification for a long time, - // they assume that the related partner is no longer typing - // something (e.g. they have closed the browser tab). - // This is a timer to let others know that we are still typing - // something, so that they do not assume we stopped typing - // something. - this._myselfLongTypingTimer = new Timer({ - duration: 50*1000, - onTimeout: this._onMyselfLongTypingTimeout.bind(this), - }); - - // Timer of current user that was currently typing something, but - // there is no change on the input for several time. This is used - // in order to automatically notify other users that we have stopped - // typing something, due to making no changes on the composer for - // some time. - this._myselfTypingInactivityTimer = new Timer({ - duration: 5*1000, - onTimeout: this._onMyselfTypingInactivityTimeout.bind(this), - }); - - // Timers of users currently typing in the thread. This is useful - // in order to automatically unregister typing users when we do not - // receive any typing notification after a long time. Timers are - // internally indexed by partnerID. The current user is ignored in - // this list of timers. - this._othersTypingTimers = new Timers({ - duration: 60*1000, - onTimeout: this._onOthersTypingTimeout.bind(this), - }); - - // Clearable and cancellable throttled version of the - // `doNotifyMyselfTyping` method. (basically `notifyMyselfTyping` - // with slight pre- and post-processing) - // @see {mail.model.ResetableThrottleFunction} - // This is useful when the user posts a message and types something - // else: he must notify immediately that he is typing something, - // instead of waiting for the throttle internal timer. - this._throttleNotifyMyselfTyping = CCThrottleFunction({ - duration: 2.5*1000, - func: this._onNotifyMyselfTyping.bind(this), - }); - - // This is used to track the order of registered partners typing - // something, in order to display the oldest typing partners. - this._typingPartnerIDs = []; - - this.on('message_added', this, this._onTypingMessageAdded); - this.on('message_posted', this, this._onTypingMessagePosted); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Get the text to display when some partners are typing something on the - * thread: - * - * - single typing partner: - * - * A is typing... - * - * - two typing partners: - * - * A and B are typing... - * - * - three or more typing partners: - * - * A, B and more are typing... - * - * The choice of the members name for display is not random: it displays - * the user that have been typing for the longest time. Also, this function - * is hard-coded to display at most 2 partners. This limitation comes from - * how translation works in Odoo, for which unevaluated string cannot be - * translated. - * - * @returns {string} list of members that are typing something on the thread - * (excluding the current user). - */ - getTypingMembersToText: function () { - var typingPartnerIDs = this._typingPartnerIDs; - var typingMembers = _.filter(this._members, function (member) { - return _.contains(typingPartnerIDs, member.id); - }); - var sortedTypingMembers = _.sortBy(typingMembers, function (member) { - return _.indexOf(typingPartnerIDs, member.id); - }); - var displayableTypingMembers = sortedTypingMembers.slice(0, 3); - - if (displayableTypingMembers.length === 0) { - return ''; - } else if (displayableTypingMembers.length === 1) { - return _.str.sprintf(_t("%s is typing..."), displayableTypingMembers[0].name); - } else if (displayableTypingMembers.length === 2) { - return _.str.sprintf(_t("%s and %s are typing..."), - displayableTypingMembers[0].name, - displayableTypingMembers[1].name); - } else { - return _.str.sprintf(_t("%s, %s and more are typing..."), - displayableTypingMembers[0].name, - displayableTypingMembers[1].name); - } - }, - /** - * Threads with this mixin have the typing notification feature - * - * @returns {boolean} - */ - hasTypingNotification: function () { - return true; - }, - /** - * Tells if someone other than current user is typing something on this - * thread. - * - * @returns {boolean} - */ - isSomeoneTyping: function () { - return !(_.isEmpty(this._typingPartnerIDs)); - }, - /** - * Register someone that is currently typing something in this thread. - * If this is the current user that is typing something, don't do anything - * (we do not have to display anything) - * - * This method is ignored if we try to register the current user. - * - * @param {Object} params - * @param {integer} params.partnerID ID of the partner linked to the user - * currently typing something on the thread. - */ - registerTyping: function (params) { - if (this._isTypingMyselfInfo(params)) { - return; - } - var partnerID = params.partnerID; - this._othersTypingTimers.registerTimer({ - timeoutCallbackArguments: [partnerID], - timerID: partnerID, - }); - if (_.contains(this._typingPartnerIDs, partnerID)) { - return; - } - this._typingPartnerIDs.push(partnerID); - this._warnUpdatedTypingPartners(); - }, - /** - * This method must be called when the user starts or stops typing something - * in the composer of the thread. - * - * @param {Object} params - * @param {boolean} params.typing tell whether the current is typing or not. - */ - setMyselfTyping: function (params) { - var typing = params.typing; - if (this._lastNotifiedMyselfTyping === typing) { - this._throttleNotifyMyselfTyping.cancel(); - } else { - this._throttleNotifyMyselfTyping(params); - } - - if (typing) { - this._myselfTypingInactivityTimer.reset(); - } else { - this._myselfTypingInactivityTimer.clear(); - } - }, - /** - * Unregister someone from currently typing something in this thread. - * - * @param {Object} params - * @param {integer} params.partnerID ID of the partner related to the user - * that is currently typing something - */ - unregisterTyping: function (params) { - var partnerID = params.partnerID; - this._othersTypingTimers.unregisterTimer({ timerID: partnerID }); - if (!_.contains(this._typingPartnerIDs, partnerID)) { - return; - } - this._typingPartnerIDs = _.reject(this._typingPartnerIDs, function (id) { - return id === partnerID; - }); - this._warnUpdatedTypingPartners(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Tells whether the provided information on a partner is related to the - * current user or not. - * - * @abstract - * @private - * @param {Object} params - * @param {integer} params.partner ID of partner to check - */ - _isTypingMyselfInfo: function (params) { - return false; - }, - /** - * Notify to the server that the current user either starts or stops typing - * something. - * - * @abstract - * @private - * @param {Object} params - * @param {boolean} params.typing whether we are typing something or not - * @returns {Promise} resolved if the server is notified, rejected - * otherwise - */ - _notifyMyselfTyping: function (params) { - return Promise.resolve(); - }, - /** - * Warn views that the list of users that are currently typing on this - * thread has been updated. - * - * @abstract - * @private - */ - _warnUpdatedTypingPartners: function () {}, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called when current user is typing something for a long time. In order - * to not let other users assume that we are no longer typing something, we - * must notify again that we are typing something. - * - * @private - */ - _onMyselfLongTypingTimeout: function () { - this._throttleNotifyMyselfTyping.clear(); - this._throttleNotifyMyselfTyping({ typing: true }); - }, - /** - * Called when current user has something typed in the composer, but is - * inactive for some time. In this case, he automatically notifies that he - * is no longer typing something - * - * @private - */ - _onMyselfTypingInactivityTimeout: function () { - this._throttleNotifyMyselfTyping.clear(); - this._throttleNotifyMyselfTyping({ typing: false }); - }, - /** - * Called by throttled version of notify myself typing - * - * Notify to the server that the current user either starts or stops typing - * something. Remember last notified stuff from the server, and update - * related typing timers. - * - * @private - * @param {Object} params - * @param {boolean} params.typing whether we are typing something or not. - */ - _onNotifyMyselfTyping: function (params) { - var typing = params.typing; - this._lastNotifiedMyselfTyping = typing; - this._notifyMyselfTyping(params); - if (typing) { - this._myselfLongTypingTimer.reset(); - } else { - this._myselfLongTypingTimer.clear(); - } - }, - /** - * Called when current user do not receive a typing notification of someone - * else typing for a long time. In this case, we assume that this person is - * no longer typing something. - * - * @private - * @param {integer} partnerID partnerID of the person we assume he is no - * longer typing something. - */ - _onOthersTypingTimeout: function (partnerID) { - this.unregisterTyping({ partnerID: partnerID }); - }, - /** - * Called when a new message is added to the thread - * On receiving a message from a typing partner, unregister this partner - * from typing partners (otherwise, it will still display it until timeout). - * - * @private - * @param {mail.model.AbstractMessage} message - */ - _onTypingMessageAdded: function (message) { - var partnerID = message.hasAuthor() ? - message.getAuthorID() : - this._DEFAULT_TYPING_PARTNER_ID; - this.unregisterTyping({ partnerID: partnerID }); - }, - /** - * Called when current user has posted a message on this thread. - * - * The current user receives the possibility to immediately notify the - * other users if he is typing something else. - * - * Refresh the context for the current user to notify that he starts or - * stops typing something. In other words, when this function is called and - * then the current user types something, it immediately notifies the - * server as if it is the first time he is typing something. - * - * @private - */ - _onTypingMessagePosted: function () { - this._lastNotifiedMyselfTyping = false; - this._throttleNotifyMyselfTyping.clear(); - this._myselfLongTypingTimer.clear(); - this._myselfTypingInactivityTimer.clear(); - }, -}; - -return ThreadTypingMixin; - -}); diff --git a/addons/mail/static/src/js/models/threads/multi_user_channel.js b/addons/mail/static/src/js/models/threads/multi_user_channel.js deleted file mode 100644 index 3dab9337502..00000000000 --- a/addons/mail/static/src/js/models/threads/multi_user_channel.js +++ /dev/null @@ -1,73 +0,0 @@ -odoo.define('mail.model.MultiUserChannel', function (require) { -"use strict"; - -var Channel = require('mail.model.Channel'); - -var MultiUserChannel = Channel.extend({ - /** - * @override - * @param {Object} params - * @param {Object} params.data - * @param {boolean} [params.data.mass_mailing=false] - * @param {string} [params.data.public='public'] either 'public' or - * 'private' - */ - init: function (params) { - this._super.apply(this, arguments); - - var data = params.data; - - this._isMassMailing = data.mass_mailing || false; - this._type = 'multi_user_channel'; - this._public = data.public !== 'private'; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Returns the title to display in thread window's headers. - * For channels, the title is prefixed with "#". - * - * @override - * @returns {string|Object} the name of the thread by default (see getName) - */ - getTitle: function () { - return "#" + this._super.apply(this, arguments); - }, - /** - * States whether this thread has the mass mailing setting active or not. - * This is a server-side setting, that determine the type of composer that - * is used (basic or extended composer). - * - * @return {boolean} - */ - isMassMailing: function () { - return this._isMassMailing; - }, - /** - * @returns {boolean} - */ - isPublic: function () { - return this._public; - }, - /** - * Unsubscribes from channel - * - * @override - * @returns {Promise} resolve when unsubscribed - */ - unsubscribe: function () { - return this._rpc({ - model: 'mail.channel', - method: 'action_unfollow', - args: [[this.getID()]], - }); - }, - -}); - -return MultiUserChannel; - -}); diff --git a/addons/mail/static/src/js/models/threads/searchable_thread.js b/addons/mail/static/src/js/models/threads/searchable_thread.js deleted file mode 100644 index c2a48a2d410..00000000000 --- a/addons/mail/static/src/js/models/threads/searchable_thread.js +++ /dev/null @@ -1,244 +0,0 @@ -odoo.define('mail.model.SearchableThread', function (require) { -"use strict"; - -var Thread = require('mail.model.Thread'); - -var session = require('web.session'); - -/** - * This is a version of a thread that is handling a cache. - * Basically, this cache is an object indexed by stringified indexes. - * These indexes represent the domain of messages that match this domain. - * - * Any threads that are instances of this Class can be used with a search view, - * in order to make searches on messages in a thread. - */ -var SearchableThread = Thread.extend({ - - /** - * @override - * @param {Object} params - * @param {Object} params.data - * @param {integer} [params.data.seen_message_id=undefined] - */ - init: function (params) { - this._super.apply(this, arguments); - - this._cache = { - '[]': { - allHistoryLoaded: false, - loaded: false, - messages: [], - } - }; - this._lastSeenMessageID = params.data.seen_message_id; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - * @param {Array} [domain] - * @param {boolean} [loadMore=false] - */ - fetchMessages: function (domain, loadMore) { - if (loadMore) { - return this._fetchMessages(domain, true); - } - var cache = this._getCache(domain); - if (cache.loaded) { - return Promise.all(cache.messages); - } else { - return this._fetchMessages(domain); - } - }, - /** - * Get the last seen message for a given thread - * - * @return {integer|null} last seen message ID (if any) - */ - getLastSeenMessageID: function () { - return this._lastSeenMessageID || null; - }, - /** - * @override - * @param {Object} [options={}] - * @param {Array} [options.domain=[]] - * @return {mail.model.Message[]} - */ - getMessages: function (options) { - options = options || {}; - var domain = options.domain || []; - return this._getCache(domain).messages; - }, - /** - * Override so that a thread can tell whether there are message based on - * the domain. - * - * @override - * @param {Object} [options={}] - * @param {Array} [options.domain=[]] - * @returns {boolean} - */ - hasMessages: function (options) { - options = options || {}; - var domain = options.domain || []; - return !_.isEmpty(this.getMessages({ domain: domain })); - }, - /** - * Invalidate the caches of the thread - */ - invalidateCaches: function () { - this._cache = _.pick(this._cache, '[]'); - }, - /** - * State whether all messages have been loaded or not - * - * @param {Array} domain - * @return {boolean} - */ - isAllHistoryLoaded: function (domain) { - return this._getCache(domain).allHistoryLoaded; - }, - /** - * Remove message with ID `messageID` from this thread - * - * This is equivalent to removing this message from all the caches - * - * @param {integer} messageID - */ - removeMessage: function (messageID) { - _.each(this._cache, function (cache) { - cache.messages = _.reject(cache.messages, function (message) { - return message.getID() === messageID; - }); - }); - }, - /** - * Set 'messageID' as the ID of the last message seen in this thread - * - * @param {integer} messageID - */ - setLastSeenMessageID: function (messageID) { - this._lastSeenMessageID = messageID; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add the message to this thread - * - * @override - * @private - * @param {mail.model.Message} message - * @param {Object} [options={}] - * @param {Array} [options.domain=[]] - * @param {boolean} [options.incrementUnread=false] - */ - _addMessage: function (message, options) { - options = options || {}; - this._super.apply(this, arguments); - var cache = this._getCache(options.domain || []); - var index = _.sortedIndex(cache.messages, message, function (msg) { - return msg.getID(); - }); - if (cache.messages[index] !== message) { - cache.messages.splice(index, 0, message); - } - if ( - !message.isMyselfAuthor() && - options.incrementUnread && - message.getType() !== 'notification' - ) { - this._incrementUnreadCounter(); - } - }, - /** - * Gets messages from thread - * - * @override - * @private - * @param {Array} [pDomain] filter on the messages of the channel - * @param {boolean} [loadMore] Whether it should load more message - * @return {Promise<mail.model.Message[]>} resolved with list of messages - */ - _fetchMessages: function (pDomain, loadMore) { - var self = this; - var domain = this._getThreadDomain(); - var cache = this._getCache(pDomain); - if (pDomain) { - domain = domain.concat(pDomain || []); - } - if (loadMore) { - var minMessageID = cache.messages[0].getID(); - domain = [['id', '<', minMessageID]].concat(domain); - } - return this._rpc({ - model: 'mail.message', - method: 'message_fetch', - args: [domain], - kwargs: this._getFetchMessagesKwargs(), - }).then(function (messages) { - if (!cache.allHistoryLoaded) { - cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT; - } - cache.loaded = true; - _.each(messages, function (message) { - self.call('mail_service', 'addMessage', message, { - silent: true, - domain: pDomain, - }); - }); - cache = self._getCache(pDomain || []); - return cache.messages; - }); - }, - /** - * Get thread content from the cache - * Useful to get cached messages. - * - * @private - * @param {Array} domain - * @return {Object|undefined} - */ - _getCache: function (domain) { - var stringifiedDomain = JSON.stringify(domain || []); - if (!this._cache[stringifiedDomain]) { - this._cache[stringifiedDomain] = { - allHistoryLoaded: false, - loaded: false, - messages: [], - }; - } - return this._cache[stringifiedDomain]; - }, - /** - * Get the kwargs that are passed to the server with the 'message_fetch' - * RPC. - * - * @private - * @returns {Object} - */ - _getFetchMessagesKwargs: function () { - return { - limit: this._FETCH_LIMIT, - context: session.user_context - }; - }, - /** - * Get the domain to fetch all the messages in the current thread - * - * @abstract - * @private - * @returns {Array} - */ - _getThreadDomain: function () {}, -}); - -return SearchableThread; - -}); diff --git a/addons/mail/static/src/js/models/threads/thread.js b/addons/mail/static/src/js/models/threads/thread.js deleted file mode 100644 index 1e99ee84c11..00000000000 --- a/addons/mail/static/src/js/models/threads/thread.js +++ /dev/null @@ -1,383 +0,0 @@ -odoo.define('mail.model.Thread', function (require) { -"use strict"; - -var emojis = require('mail.emojis'); -var AbstractThread = require('mail.model.AbstractThread'); -var mailUtils = require('mail.utils'); - -var ServicesMixin = require('web.ServicesMixin'); - -/** - * This is the super class modeling threads in JS. - * Such threads are stored in the mail manager, and any piece of JS code whose - * logic relies on threads must ideally interact with such objects. - * - * In particular, channels and mailboxes are two different kinds of threads. - */ -var Thread = AbstractThread.extend(ServicesMixin, { - // max number of fetched messages from the server - _FETCH_LIMIT: 30, - /** - * @override - * @param {Object} params - * @param {mail.Manager} params.parent - * @param {Object} params.data - * @param {string} [params.data.channel_type] - * @param {string} params.data.name - * @param {string} [params.data.type] - */ - init: function (params) { - this._super.apply(this, arguments); - // threads are not detached by default - this._detached = false; - // on 1st request to getPreview, fetch data if incomplete. Otherwise it - // means that there is no message in this channel. - this._previewed = false; - this._type = params.data.type || params.data.channel_type; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Updates the _detached state of the thread. Must be overridden to reflect - * the new state in the interface. - */ - close: function () { - this._detached = false; - this._folded = false; - this._warnUpdatedWindowState(); - }, - /** - * Updates the _detached state of the thread. Must be overridden to reflect - * the new state in the interface. - * - * @param {Object} [options={}] - * @param {boolean} [options.keepFoldState=false] if set, keep the fold state - * of this thread. Otherwise unfold it while detaching it. - * @param {boolean} [options.passively=false] if set, if the thread window - * will be created passively. - * @returns {Promise} - */ - detach: function (options) { - options = options || {}; - this._detached = true; - this._folded = options.keepFoldState ? this._folded : false; - return this._warnUpdatedWindowState({ - passively: options.passively, - }); - }, - /** - * Fetch the list of messages in this thread. - * By default, a thread has no messages. - * - * Note that this method only returns some messages, as we do not want to - * fetch all messages of a thread at once, just to read the last message. - * - * As a result, this method fetches only some messages of the thread, - * starting from the last message in the thread. At most, it fetches - * `this.LIMIT` number of messages at a time. - * - * @abstract - * @returns {Promise<mail.model.Message[]>} - */ - fetchMessages: function () { - return Promise.resolve([]); - }, - /** - * Updates the folded state of the thread. Must be overridden to reflect - * the new state in the interface. - * - * @override - * @param {boolean} folded - */ - fold: function (folded) { - this._super.apply(this, arguments); - this._detached = true; // auto-detach the thread - this._warnUpdatedWindowState(); - }, - /** - * Get the list of available commands for the thread. - * By default, threads do not have any available command. - * - * @returns {Array} - */ - getCommands: function () { - return []; - }, - /** - * Get the listeners of the thread. - * By default, a thread has not listener. - * - * @abstract - * @returns {Promise<Array<Object[]>>} - */ - getMentionPartnerSuggestions: function () { - return Promise.resolve([]); - }, - /** - * Returns the information required to render the preview of this channel. - * - * @returns {Object} a valid object for the rendering of previews - * (@see mail.Preview template) - */ - getPreview: function () { - return { - id: this.getID(), - imageSRC: '/mail/static/src/img/smiley/avatar.jpg', - isTwoUserThread: this.isTwoUserThread(), - status: this.getStatus(), - title: this.getName(), - unreadCounter: this.getUnreadCounter(), - }; - }, - /** - * @returns {string} - */ - getType: function () { - return this._type; - }, - /** - * State whether this channel has been previewed - * - * A channel that has been previewed means that it had the necessary data - * to display its preview format. A channel needs its meta data and the - * last message in order to build its preview format. - * - * This is useful in order to not fetch preview info on this channel more - * than once on channels that have no message at all. - * - * Any received message updates the last_message, so a channel should - * always have all the necessary information to display its preview after - * the 1st time. - * - * @returns {boolean} - */ - hasBeenPreviewed: function () { - return this._previewed; - }, - /** - * State whether there are unread messages in this thread - * - * @returns {boolean} - */ - hasUnreadMessages: function () { - return this._unreadCounter !== 0; - }, - /** - * Increments the needaction counter of this thread. - * - * @abstract - */ - incrementNeedactionCounter: function () {}, - /** - * States whether the thread should be auto-selected on creation - * - * Note that this is not of the responsibility of the thread: it only - * provides guidance for the object that uses threads (e.g. mail.Discuss - * must listen on threads and auto-select the thread if autoswitch is set). - * - * By default, threads are not in autoswitch mode. - * - * @returns {boolean} - */ - isAutoswitch: function () { - return false; - }, - /** - * States whether this thread is a channel or not. A thread is a channel if - * it is an instance of mail.model.Channel (direct or indirect). - * By default, any thread is not a channel - * - * @returns {boolean} - */ - isChannel: function () { - return false; - }, - /** - * States whether this thread is detached or not. - * A thread that is detached must have a thread window linked to itself. - * - * @return {boolean} - */ - isDetached: function () { - return this._detached; - }, - /** - * States whether the thread is linked to a document - * By default, threads are not linked to a document. - * - * @returns {boolean} - */ - isLinkedToDocument: function () { - return false; - }, - /** - * States whether this thread has the mass mailing setting active or not. - * This is a server-side setting, that determine the type of composer that - * is used (basic or extended composer). - * - * By default, thread have not mass-mailings feature set. - * - * @return {boolean} - */ - isMassMailing: function () { - return false; - }, - /** - - * States whether the thread is moderated or not. - * By default, threads are not moderated. - * - * @returns {boolean} - */ - isModerated: function () { - return false; - }, - /** - * States whether the current user is moderator of this thread. - * By default, the current user is not moderator of this thread. - * - * @returns {boolean} - */ - isMyselfModerator: function () { - return false; - }, - /** - * States whether this is a two-user thread or not. In particular, public - * and private channels are not two-user threads, but DMs and Livechats are - * two-user threads. Two-user threads are used for communication between - * two users. By default, any thread is not two-user thread. - * - * @returns {boolean} - */ - isTwoUserThread: function () { - return false; - }, - /** - * Mark this channel as previewed - * - * This is useful in order to not fetch preview info on this channel - * is the server has no preview in the first place. - * - * Note: preview fetch is useful only when the channel contains messages - * that have not been fetched at all. After that, this channel instance - * is updated regularly so that the most up-to-date info are available - * to make the preview of this channel. - */ - markAsPreviewed: function () { - this._previewed = true; - }, - /** - * Set the name of this thread - * - * @param {string} newName - */ - setName: function (newName) { - this._name = newName; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @abstract - * @private - * @return {Promise} - */ - _fetchMessages: function () { - return Promise.resolve(); - }, - /** - * Replace character representations of emojis by their unicode - * representation in the provided HTML string. Note that the provided html - * string is altered by this function. - * - * @param {string} htmlString - * @returns {string} - */ - _generateEmojis: function (htmlString) { - _.each(emojis, function (emoji) { - _.each(emoji.sources, function (source) { - var escapedSource = String(source).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); - var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g'); - htmlString = htmlString.replace(regexp, '$1' + emoji.unicode); - }); - }); - return htmlString; - }, - /** - * Increment the unread counter of this thread by 1 unit, and warn that the - * counter has been changed. - * - * @override - * @private - */ - _incrementUnreadCounter: function () { - this._super.apply(this, arguments); - this._warnUpdatedUnreadCounter(); - }, - /** - * Post a message on the thread. - * - * This method must be completed by concrete threads, - * As it currently only pre-process the messages at the moment. - * - * @abstract - * @private - * @param {Object} data - * @returns {Promise<Object>} resolved with the message object to be sent - * to the server - */ - _postMessage: function (data) { - // This message will be received from the mail composer as html content - // subtype but the urls will not be linkified. If the mail composer - // takes the responsibility to linkify the urls we end up with double - // linkification a bit everywhere. Ideally we want to keep the content - // as text internally and only make html enrichment at display time but - // the current design makes this quite hard to do. - var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink); - body = this._generateEmojis(body); - var messageData = { - partner_ids: data.partner_ids, - channel_ids: data.channel_ids, - body: body, - attachment_ids: data.attachment_ids, - canned_response_ids: data.canned_response_ids, - }; - if ('subject' in data) { - messageData.subject = data.subject; - } - return this._super.apply(this, arguments).then(function () { - return messageData; - }); - }, - /** - * Warn on the mail bus that the unread counter has been updated - * - * @override - * @private - */ - _warnUpdatedUnreadCounter: function () { - this.call('mail_service', 'getMailBus').trigger('update_thread_unread_counter', this); - }, - /** - * Warn other mail components that the window state of this thread has - * changed (it has become closed, folded, unfolded, detached, etc.) - * - * @private - * @param {Object} [options={}] - * @param {boolean} [options.passively=false] - * @returns {Promise} - */ - _warnUpdatedWindowState: function (options) { - options = options || {}; - return this.call('mail_service', 'updateThreadWindow', this.getID(), options); - }, -}); - -return Thread; - -}); diff --git a/addons/mail/static/src/js/models/threads/two_user_channel.js b/addons/mail/static/src/js/models/threads/two_user_channel.js deleted file mode 100644 index 54a4145bd13..00000000000 --- a/addons/mail/static/src/js/models/threads/two_user_channel.js +++ /dev/null @@ -1,32 +0,0 @@ -odoo.define('mail.model.TwoUserChannel', function (require) { -"use strict"; - -var Channel = require('mail.model.Channel'); - -var TwoUserChannel = Channel.extend({ - /** - * @override - * @returns {boolean} - */ - isTwoUserThread: function () { - return true; - }, - /** - * Unpin from two-user thread - * - * @override - * @returns {Promise} resolve when unsubscribed - */ - unsubscribe: function () { - return this._rpc({ - model: 'mail.channel', - method: 'channel_pin', - args: [this.getUUID(), false], - }); - }, - -}); - -return TwoUserChannel; - -}); diff --git a/addons/mail/static/src/js/models/utils/cc_throttle_function.js b/addons/mail/static/src/js/models/utils/cc_throttle_function.js deleted file mode 100644 index b4b87087e6d..00000000000 --- a/addons/mail/static/src/js/models/utils/cc_throttle_function.js +++ /dev/null @@ -1,181 +0,0 @@ -odoo.define('mail.model.CCThrottleFunctionObject', function (require) { -"use strict"; - -var Class = require('web.Class'); - -/** - * This object models the behaviour of the clearable and cancellable (CC) - * throttle version of a provided function. - */ -var CCThrottleFunctionObject = Class.extend({ - - /** - * @param {Object} params - * @param {integer} params.duration duration of the 'cooldown' phase, i.e. - * the minimum duration between the most recent function call that has - * been made and the following function call. - * @param {function} params.func provided function for making the CC - * throttled version. - */ - init: function (params) { - this._arguments = undefined; - this._cooldownTimeout = undefined; - this._duration = params.duration; - this._func = params.func; - this._shouldCallFunctionAfterCD = false; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Cancel any buffered function call, but keep the cooldown phase running. - */ - cancel: function () { - this._arguments = undefined; - this._shouldCallFunctionAfterCD = false; - }, - /** - * Clear the internal throttle timer, so that the following function call - * is immediate. For instance, if there is a cooldown stage, it is aborted. - */ - clear: function () { - if (this._cooldownTimeout) { - clearTimeout(this._cooldownTimeout); - this._onCooldownTimeout(); - } - }, - /** - * Called when there is a call to the function. This function is throttled, - * so the time it is called depends on whether the "cooldown stage" occurs - * or not: - * - * - no cooldown stage: function is called immediately, and it starts - * the cooldown stage when successful. - * - in cooldown stage: function is called when the cooldown stage has - * ended from timeout. - * - * Note that after the cooldown stage, only the last attempted function - * call will be considered. - */ - do: function () { - this._arguments = Array.prototype.slice.call(arguments); - if (this._cooldownTimeout === undefined) { - this._callFunction(); - } else { - this._shouldCallFunctionAfterCD = true; - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Immediately calls the function with arguments of last buffered function - * call. It initiates the cooldown stage after this function call. - * - * @private - */ - _callFunction: function () { - this._func.apply(null, this._arguments); - this._cooldown(); - }, - /** - * Called when the function has been successfully called. The following - * calls to the function with this object should suffer a "cooldown stage", - * which prevents the function from being called until this stage has ended. - * - * @private - */ - _cooldown: function () { - this.cancel(); - this._cooldownTimeout = setTimeout( - this._onCooldownTimeout.bind(this), - this._duration - ); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called when the cooldown stage ended from timeout. Calls the function if - * a function call was buffered. - * - * @private - */ - _onCooldownTimeout: function () { - if (this._shouldCallFunctionAfterCD) { - this._callFunction(); - } else { - this._cooldownTimeout = undefined; - } - }, -}); - -return CCThrottleFunctionObject; - -}); - - -odoo.define('mail.model.CCThrottleFunction', function (require) { -"use strict"; - -var CCThrottleFunctionObject = require('mail.model.CCThrottleFunctionObject'); - -/** - * A function that creates a cancellable and clearable (CC) throttle version - * of a provided function. - * - * This throttle mechanism allows calling a function at most once during a - * certain period: - * - * - When a function call is made, it enters a 'cooldown' phase, in which any - * attempt to call the function is buffered until the cooldown phase ends. - * - At most 1 function call can be buffered during the cooldown phase, and the - * latest one in this phase will be considered at its end. - * - When a cooldown phase ends, any buffered function call will be performed - * and another cooldown phase will follow up. - * - * This throttle version has the following interesting properties: - * - * - cancellable: it allows removing a buffered function call during the - * cooldown phase, but it keeps the cooldown phase running. - * - clearable: it allows to clear the internal clock of the throttled function, - * so that any cooldown phase is immediately ending. - * - * @param {Object} params - * @param {integer} params.duration a duration for the throttled behaviour, - * in milli-seconds. - * @param {function} params.func the function to throttle - * @returns {function} the cancellable and clearable throttle version of the - * provided function in argument. - */ -var CCThrottleFunction = function (params) { - var duration = params.duration; - var func = params.func; - - var throttleFunctionObject = new CCThrottleFunctionObject({ - duration: duration, - func: func, - }); - - var callable = function () { - return throttleFunctionObject.do.apply(throttleFunctionObject, arguments); - }; - callable.cancel = function () { - throttleFunctionObject.cancel(); - }; - callable.clear = function () { - throttleFunctionObject.clear(); - }; - - return callable; -}; - -return CCThrottleFunction; - -}); diff --git a/addons/mail/static/src/js/models/utils/timer.js b/addons/mail/static/src/js/models/utils/timer.js deleted file mode 100644 index 9aaae471d2d..00000000000 --- a/addons/mail/static/src/js/models/utils/timer.js +++ /dev/null @@ -1,69 +0,0 @@ -odoo.define('mail.model.Timer', function (require) { -"use strict"; - -var Class = require('web.Class'); - -/** - * This class creates a timer which, when times out, calls a function. - */ -var Timer = Class.extend({ - - /** - * Instantiate a new timer. Note that the timer is not started on - * initialization (@see start method). - * - * @param {Object} params - * @param {number} params.duration duration of timer before timeout in - * milli-seconds. - * @param {function} params.onTimeout function that is called when the - * timer times out. - */ - init: function (params) { - this._duration = params.duration; - this._timeout = undefined; - this._timeoutCallback = params.onTimeout; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Clears the countdown of the timer. - */ - clear: function () { - clearTimeout(this._timeout); - }, - /** - * Resets the timer, i.e. resets its duration. - */ - reset: function () { - this.clear(); - this.start(); - }, - /** - * Starts the timer, i.e. after a certain duration, it times out and calls - * a function back. - */ - start: function () { - this._timeout = setTimeout(this._onTimeout.bind(this), this._duration); - }, - - //-------------------------------------------------------------------------- - // Handler - //-------------------------------------------------------------------------- - - /** - * Called when the timer times out, calls back a function on timeout. - * - * @private - */ - _onTimeout: function () { - this._timeoutCallback(); - }, - -}); - -return Timer; - -}); diff --git a/addons/mail/static/src/js/models/utils/timers.js b/addons/mail/static/src/js/models/utils/timers.js deleted file mode 100644 index 757945223f7..00000000000 --- a/addons/mail/static/src/js/models/utils/timers.js +++ /dev/null @@ -1,82 +0,0 @@ -odoo.define('mail.model.Timers', function (require) { -"use strict"; - -var Timer = require('mail.model.Timer'); - -var Class = require('web.Class'); - -/** - * This class lists several timers that use a same callback and duration. - */ -var Timers = Class.extend({ - - /** - * Instantiate a new list of timers - * - * @param {Object} params - * @param {integer} params.duration duration of the underlying timers from - * start to timeout, in milli-seconds. - * @param {function} params.onTimeout a function to call back for underlying - * timers on timeout. - */ - init: function (params) { - this._duration = params.duration; - this._timeoutCallback = params.onTimeout; - this._timers = {}; - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Register a timer with ID `timerID` to start. - * - * - an already registered timer with this ID is reset. - * - (optional) can provide a list of arguments that is passed to the - * function callback when timer times out. - * - * @param {Object} params - * @param {Array} [params.timeoutCallbackArguments] - * @param {integer} params.timerID - */ - registerTimer: function (params) { - var timerID = params.timerID; - if (this._timers[timerID]) { - this._timers[timerID].clear(); - } - var timerParams = { - duration: this._duration, - onTimeout: this._timeoutCallback, - }; - if ('timeoutCallbackArguments' in params) { - timerParams.onTimeout = this._timeoutCallback.bind.apply( - this._timeoutCallback, - [null].concat(params.timeoutCallbackArguments) - ); - } else { - timerParams.onTimeout = this._timeoutCallback; - } - this._timers[timerID] = new Timer(timerParams); - this._timers[timerID].start(); - }, - /** - * Unregister a timer with ID `timerID`. The unregistered timer is aborted - * and will not time out. - * - * @param {Object} params - * @param {integer} params.timerID - */ - unregisterTimer: function (params) { - var timerID = params.timerID; - if (this._timers[timerID]) { - this._timers[timerID].clear(); - delete this._timers[timerID]; - } - }, - -}); - -return Timers; - -}); diff --git a/addons/mail/static/src/js/services/mail_document_thread_manager.js b/addons/mail/static/src/js/services/mail_document_thread_manager.js deleted file mode 100644 index 652567baf14..00000000000 --- a/addons/mail/static/src/js/services/mail_document_thread_manager.js +++ /dev/null @@ -1,260 +0,0 @@ -odoo.define('mail.Manager.DocumentThread', function (require) { -"use strict"; - -var MailManager = require('mail.Manager'); -var DocumentThread = require('mail.model.DocumentThread'); - -var session = require('web.session'); - -/** - * Document Thread Manager - * - * This part of the mail manager extends the core functionnalities of mail - * manager with document threads, in addition to enable cross tab - * synchronization of document thread windows, through the local storage. - * - * Note that it assumes that the Mail Window Manager is loaded - */ -MailManager.include({ - DOCUMENT_THREAD_MESSAGE_KEY: 'mail.document_threads_last_message', - DOCUMENT_THREAD_STATE_KEY: 'mail.document_threads_state', - - start: function () { - this._super.apply(this, arguments); - this._documentThreadWindowStates = {}; - this._tabId = this.call('bus_service', 'getTabId'); - // retrieve the open DocumentThreads from the localStorage - var localStorageLength = this.call('local_storage', 'length'); - for (var i = 0; i < localStorageLength; i++) { - var key = this.call('local_storage', 'key', i); - if (key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) { - // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId - var documentThreadId = key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1); - this._documentThreadWindowStates[documentThreadId] = - this.call('local_storage', 'getItem', key); - } - } - this._mailBus.on('messaging_ready', this, this._updateDocumentThreadWindows.bind(this, this._documentThreadWindowStates)); - // listen to localStorage changes to synchronize DocumentThread's - // windows between tabs - this.call('local_storage', 'onStorage', this, this._onStorage); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * For new posted message of current user in a document thread: store it in - * the localStorage to make it appear on the other tabs. - * - * Note: 'addMessage' modifies the message in place (by setting the channel_id, - * so we must store the message in the localStorage *before* calling - * 'addMessage') - * - * @override - * @param {Object} data - * @param {integer} data.id server ID of the message - * @param {Object} [options] - * @param {boolean} [options.postedFromDocumentThread=false] - */ - addMessage: function (data, options) { - var message = this.getMessage(data.id); - if ( - !message && - options && - options.postedFromDocumentThread - ) { - var key = this.DOCUMENT_THREAD_MESSAGE_KEY; - this.call('local_storage', 'setItem', key, { - messageData: data, - tabId: this._tabId, - }); - } - return this._super.apply(this, arguments); - }, - /** - * Returns a document thread corresponding to the given model and resID. - * - * @param {string} model of the document thread, if it exists - * @return {integer} resID of the document thread, if it exists - */ - getDocumentThread: function (model, resID) { - return this.getThread(model + '_' + resID); - }, - /** - * Add a new document thread, or get if it exists already. - * - * Also, if a name is provided for the document thread, it overwrites the - * previous one. - * - * @param {Object} params - * @param {interger[]} [params.message_ids] the list of message ids linked - * to the document (if not given, they will be fetched before fetching the - * messages) - * @param {string} [params.name] if provided, overwrites the name of the - * existing document thread - * @param {integer} params.resID - * @param {string} params.resModel - * @return {mail.model.DocumentThread} - */ - getOrAddDocumentThread: function (params) { - var thread = this.getDocumentThread(params.resModel, params.resID); - if (!thread) { - thread = new DocumentThread({ - parent: this, - data: { - messageIDs: params.messageIDs, - name: params.name, - resID: params.resID, - resModel: params.resModel, - }, - }); - this._threads.push(thread); - } else { - if ('messageIDs' in params) { - thread.setMessageIDs(params.messageIDs); - } - if ('name' in params && params.name) { - // document thread may have a change of name - thread.setName(params.name); - } - } - return thread; - }, - /** - * Open the document thread form if discuss is open - * - * @override - * @param {integer|string} threadID - */ - openThread: function (threadID) { - var thread = this.getThread(threadID); - if ( - thread && - thread.getType() === 'document_thread' && - this._isDiscussOpen() - ) { - var resModel = thread.getDocumentModel(); - var resID = thread.getDocumentID(); - this._redirectToDocument(resModel, resID); - } else { - this._super.apply(this, arguments); - } - }, - /** - * Updates the state of a given document thread (stored in localStorage). - * Garbage collects windows previously marked as 'closed' (assuming that the - * info has already been processed by the other tabs). - * - * @param {string} threadID ID of a document thread - * @param {Object} state - * @param {string} state.name name of the document thread - * @param {string} state.windowState ('closed', 'folded' or 'open') - */ - updateDocumentThreadState: function (threadID, state) { - this._documentThreadWindowStates[threadID] = state; - this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY + '/' + threadID, { - state: state, - tabId: this._tabId, - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * For new messages, postprocess document thread linked to this message. - * If there is no such document thread, create it and register its ID to - * the message's thread IDs. - * - * @override - * @private - * @param {mail.model.Message} message - */ - _addNewMessagePostprocessThread: function (message) { - var resModel = message.getDocumentModel(); - var resID = message.getDocumentID(); - if (resModel && resModel !== 'mail.channel' && resID) { - this.getOrAddDocumentThread({ - name: message.getDocumentName(), - resID: resID, - resModel: resModel, - }); - } - return this._super.apply(this, arguments); - }, - /** - * Updates the thread windows related to document threads. - * - * @param {Object} documentThreadStates - keys are strings 'resModel_resID', - * and values are the state of the corresponding document thread ('open' - * or 'folded') - */ - _updateDocumentThreadWindows: function (documentThreadStates) { - var self = this; - _.each(documentThreadStates, function (state, key) { - var info = key.split('_'); - var documentThread = self.getOrAddDocumentThread({ - name: state.name, - resID: parseInt(info[1]), - resModel: info[0], - }); - if (state.windowState === 'closed') { - documentThread.close({ - skipCrossTabSync: true, - }); - } else { - documentThread.fold(state.windowState === 'folded', { - skipCrossTabSync: true, - }); - self.openThreadWindow(documentThread.getID(), { - keepFoldState: true, - skipCrossTabSync: true, - }); - } - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called each time a localStorage key is updated. - * - * @private - * @param {StorageEvent} ev - */ - _onStorage: function (ev) { - var value; - try { - value = JSON.parse(ev.newValue); - } catch (err) { - return; - } - if (ev.key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) { - if (value.tabId === this._tabId) { - return; - } - // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId - var documentThreadId = ev.key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1); - var param = {}; - param[documentThreadId] = value.state; - this._updateDocumentThreadWindows(param); - } else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) { - if (value.tabId === this._tabId) { - return; - } - if (value.messageData) { - this.addMessage(value.messageData); - } - } - }, - -}); - -return MailManager; - -}); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js deleted file mode 100644 index f52af585f94..00000000000 --- a/addons/mail/static/src/js/services/mail_manager.js +++ /dev/null @@ -1,1356 +0,0 @@ -odoo.define('mail.Manager', function (require) { -"use strict"; - -/** - * This service is responsible for anything related to mail in JS. - * In particular, it stores all threads and messages that have been fetched - * from the server. - * - * As this service does quite a lot of stuffs, it is split into many different - * files. For instance, the handling of notification in the longpoll is in - * mail_notification_manager.js. This file contains the core functionnality - * of the mail service. - * - * The main point of entry of this service is to call public methods by mean - * of a service call. - * - * @example - * - * To get all threads that have been fetched from the server: - * - * this.call('mail_service', 'getThreads'); - * - * To get a particular thread with server ID 1: - * - * this.call('mail_service', 'getThread', 1); - */ -var AbstractService = require('web.AbstractService'); - -var DMChat = require('mail.model.DMChat'); -var Livechat = require('mail.model.Livechat'); -var Mailbox = require('mail.model.Mailbox'); -var MailFailure = require('mail.model.MailFailure'); -var Message = require('mail.model.Message'); -var MultiUserChannel = require('mail.model.MultiUserChannel'); -var mailUtils = require('mail.utils'); -var utils = require('web.utils'); - -var Bus = require('web.Bus'); -var config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); - -var _t = core._t; - -var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers - -var MailManager = AbstractService.extend({ - dependencies: ['ajax', 'bus_service', 'local_storage'], - _isReady: false, - _ODOOBOT_ID: ["ODOOBOT", "ODOOBOT"], // authorID for transient messages - - /** - * @override - */ - start: function () { - this._super.apply(this, arguments); - - this._throttleNotifyChannelFetched = _.throttle(this._notifyChannelFetched.bind(this), 3000); - // List of channel that have requested the service to notify when - // it has been fetched, so that throttled RPCs batch the ids. - this._toNotifyChannelFetchedIDs = []; - - this._initializeInternalState(); - this._listenOnBuses(); - this._fetchMailStateFromServer(); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Add a message - * - * @param {Object} data message data - * @param {integer} data.id - * @param {string} [data.moderation_status] - * @param {Object} [options] - * @param {Array} [options.domain] - * @param {boolean} [options.incrementUnread] whether we should increment - * the unread_counter of channel. - * @param {boolean} [options.silent] whether it should inform in the mailBus - * of the newly created message. - * @returns {Promise<mail.model.Message>} - */ - addMessage: function (data, options) { - options = options || {}; - var message = this.getMessage(data.id); - var prom; - if (!message) { - prom = this._addNewMessage(data, options); - } else { - if (data.moderation_status === 'accepted') { - message.setModerationStatus('accepted', { - additionalThreadIDs: data.channel_ids - }); - } - this._addMessageToThreads(message, options); - prom = Promise.resolve(message); - } - return prom; - }, - /** - * Creates a channel, can be either a true channel or a DM chat based on - * `type` - * - * @param {integer|string} name id of partner (in case of dm) or name - * @param {string} type ['dm_chat', 'public', 'private'] - * @returns {Promise<integer>} resolved with ID of the newly created - * channel - */ - createChannel: function (name, type) { - if (type === 'dm_chat') { - return this._createDMChat(name); - } else { - return this._createChannel(name, type); - } - }, - /** - * Returns the list of canned responses - * A canned response is a pre-formatted text that is triggered with - * some keystrokes, such as with ':'. - * - * @returns {Array} array of Objects (mail.shortcode) - */ - getCannedResponses: function () { - return this._cannedResponses; - }, - /** - * Returns a channel corresponding to the given id. - * - * @param {integer} threadID - * @returns {mail.model.Channel|undefined} the channel, if it exists - */ - getChannel: function (threadID) { - return _.find(this._threads, function (thread) { - return thread.isChannel() && (thread.getID() === threadID); - }); - }, - /** - * Returns a list of channels - * - * @returns {mail.model.Channel[]} list of channels - */ - getChannels: function () { - return _.filter(this._threads, function (thread) { - return thread.isChannel(); - }); - }, - /** - * Returns the content that will be shown in the mail navbar dropdown - * - * @param {mail.model.Channel[]} channels - * @returns {Promise<Object[]>} resolved with list of channel previews - */ - getChannelPreviews: function (channels) { - var self = this; - return this._getChannelPreviews(channels).then(function (previews) { - var sortedPreviews = self._sortPreviews(previews); - return sortedPreviews; - }); - }, - /** - * @returns {web.Bus} the mail bus - */ - getMailBus: function () { - return this._mailBus; - }, - /** - * Returns the record id of ir.ui.menu for Discuss - * - * @returns {integer} record id - */ - getDiscussMenuID: function () { - return this._discussMenuID; - }, - /** - * Gets direct message channel - * - * @param {integer} partnerID - * @returns {Object|undefined} channel - */ - getDMChatFromPartnerID: function (partnerID) { - return _.find(this._threads, function (thread) { - return thread.getType() === 'dm_chat' && - thread.getDirectPartnerID() === partnerID; - }); - }, - /** - * @param {string} mailboxID - * @returns {mail.model.Mailbox} the mailbox, if any - */ - getMailbox: function (mailboxID) { - return _.find(this._threads, function (thread) { - return thread.getID() === 'mailbox_' + mailboxID; - }); - }, - /** - * Returns a list of mail failures - * - * @returns {mail.model.MailFailure[]} list of mail failures - */ - getMailFailures: function () { - return this._mailFailures; - }, - /** - * Get partners as mentions from a chatter - * Typically all employees as partner suggestions. - * - * @returns {Array<Array<Object[]>>} - */ - getMentionPartnerSuggestions: function () { - return this._mentionPartnerSuggestions; - }, - /** - * Gets message from its ID - * - * @param {integer} messageID - * @returns {mail.model.Message|undefined} the matched message (if any) - */ - getMessage: function (messageID) { - return _.find(this._messages, function (message) { - return message.getID() === messageID; - }); - }, - /** - * Returns the previews to display in the systray's messaging menu. - * - * @param {string} [filter=undefined] specify 'chat' or 'channels' to only - * get previews for that type of threads - * @returns {Promise<Object[]>} resolved with list of objects that have a - * valid format for rendering a messaging menu preview items. - */ - getSystrayPreviews: function (filter) { - var self = this; - - var channelDef = this._getSystrayChannelPreviews(filter); - var inboxDef = this._getSystrayInboxPreviews(filter); - var failureDef = this._getSystrayMailFailurePreviews(filter); - - return Promise.all([channelDef, inboxDef, failureDef]) - .then(function (result) { //previewsChannel, previewsInbox, previewsFailure - // order: failures > inbox > channel, each group must be sorted - var previewsChannel = self._sortPreviews(result[0]); - var previewsInbox = self._sortPreviews(result[1]); - var previewsFailure = self._sortPreviews(result[2]); - return _.union(previewsFailure, previewsInbox, previewsChannel); - }); - }, - /** - * Get the list of channel IDs where the current user is a moderator - * - * @returns {integer[]} - */ - getModeratedChannelIDs: function () { - return this._moderatedChannelIDs || []; - }, - /** - * Get the OdooBot ID, which is the default authorID for transient messages - * - * @returns {Array<string>} - */ - getOdoobotID: function () { - return this._ODOOBOT_ID; - }, - /** - * Returns thread matching provided ID, if any - * - * @param {string|integer} threadID - * @returns {mail.model.Thread|undefined} the thread, if it exists - */ - getThread: function (threadID) { - return _.find(this._threads, function (thread) { - return thread.getID() === threadID; - }); - }, - /** - * Returns a list of threads - * - * @returns {mail.model.Thread[]} list of threads - */ - getThreads: function () { - return this._threads; - }, - /** - * States whether the current user is a moderator or not - * - * @returns {boolean} - */ - isMyselfModerator: function () { - return this._isMyselfModerator; - }, - /** - * States whether the mail manager is ready or not - * This is the case when it has fetched the initial state from the server, - * by means of the route 'mail/init_messaging' - * - * @returns {boolean} - */ - isReady: function () { - return this._isReady; - }, - /** - * Join an existing channel - * See @createChannel to join a new channel - * - * @param {integer} channelID - * @param {Object} [options] options to be passed on channel add - * @returns {Promise<integer>} resolved with channelID joined - */ - joinChannel: function (channelID, options) { - var prom; - var channel = this.getChannel(channelID); - if (channel) { - // channel already joined - prom = Promise.resolve(channelID); - } else { - prom = this._joinAndAddChannel(channelID, options); - } - return prom; - }, - /** - * Mark messages as read - * - * @param {Array} messageIDs list of messages IDs - * @returns {Promise} resolved when messages have been marked as read on - * the server. - */ - markMessagesAsRead: function (messageIDs) { - var self = this; - var ids = _.filter(messageIDs, function (id) { - var message = self.getMessage(id); - // If too many messages, not all are fetched, - // and some might not be found - return !message || message.isNeedaction(); - }); - if (ids.length) { - return this._rpc({ - model: 'mail.message', - method: 'set_message_done', - args: [ids], - }); - } else { - return Promise.resolve(); - } - }, - /** - * @param {Object} data - * @param {integer} data.channelID - */ - notifyChannelFetched: function (data) { - this._toNotifyChannelFetchedIDs = _.uniq(this._toNotifyChannelFetchedIDs.concat(data.channelID)); - this._throttleNotifyChannelFetched(); - }, - /** - * @param {integer|string} threadID - */ - openThread: function (threadID) { - this._openThreadInDiscuss(threadID); - }, - /** - * Special redirection handling for given model and id - * - * If the model is res.partner, and there is a user associated with this - * partner which isn't the current user, open the DM chat with this user. - * Otherwhise, open the record's form view (if not current user's). - * - * @param {string} resModel model to open - * @param {integer} resID record to open - * @param {function} [dmRedirectionCallback] only used if 'res.partner', - * a function that has a threadID as input - */ - redirect: function (resModel, resID, dmRedirectionCallback) { - if (resModel === 'res.partner') { - this._redirectPartner(resModel, resID, dmRedirectionCallback); - } else { - this._redirectDefault(resModel, resID); - } - }, - /** - * Remove the message from all of its threads - * - * @param {mail.model.Message} message - */ - removeMessageFromThreads: function (message) { - var self = this; - _.each(message.getThreadIDs(), function (threadID) { - var thread = self.getThread(threadID); - if (thread) { - thread.removeMessage(message.getID()); - } - }); - }, - /** - * Search among prefetched partners, using the string 'searchVal' - * - * @param {string} searchVal - * @param {integer} limit max number of found partners in the response - * @returns {Promise<Object[]>} list of found partners (matching - * 'searchVal') - */ - searchPartner: function (searchVal, limit) { - var self = this; - var partners = this._searchPartnerPrefetch(searchVal, limit); - return new Promise(function (resolve, reject) { - if (!partners.length) { - resolve(self._searchPartnerFetch(searchVal, limit)); - } else { - resolve(partners); - } - }).then(function (partners) { - var suggestions = _.map(partners, function (partner) { - return { - id: partner.id, - value: partner.name, - label: partner.name - }; - }); - return _.sortBy(suggestions, 'label'); - }); - }, - /** - * Unstars all messages from all channels - * - * @returns {Promise} - */ - unstarAll: function () { - return this._rpc({ - model: 'mail.message', - method: 'unstar_all', - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Adds a channel and returns it. - * Simply returns the channel if already exists. - * - * @private - * @param {Object} data - * @param {integer} data.id id of channel - * @param {string|Object} data.name name of channel, e.g. 'general' - * @param {string} data.type type of channel, e.g. 'public' - * @param {string} [data.state] e.g. 'open', 'folded' - * @param {Object|integer} [options=undefined] - * @param {boolean} [options.silent=false] - * @returns {Promise<integer>} resolves with the ID of the newly or already - * existing channel - */ - _addChannel: function (data, options) { - options = typeof options === 'object' ? options : {}; - var channel = this.getChannel(data.id); - var proms = []; - if (!channel) { - channel = this._makeChannel(data, options); - if (channel.getType() === 'dm_chat') { - this._pinnedDmPartners.push(channel.getDirectPartnerID()); - } - this._threads.push(channel); - if (data.last_message) { - // channel_info in mobile, necessary for showing channel - // preview in mobile - proms.push(this.addMessage(data.last_message)); - } - this._sortThreads(); - if (!options.silent) { - this._mailBus.trigger('new_channel', channel, proms); - } - } - return Promise.all(proms).then(function () { - return channel.getID(); - }); - }, - /** - * Add a new mailbox - * - * @private - * @param {Object} data - * @param {Object} options - * @returns {mail.model.Mailbox} - */ - _addMailbox: function (data, options) { - options = typeof options === 'object' ? options : {}; - var mailbox = new Mailbox({ - parent: this, - data: data, - options: options, - commands: this._commands - }); - this._threads.push(mailbox); - this._sortThreads(); - return mailbox; - }, - /** - * Stores a message to the cache `domain` of all of its threads - * - * @private - * @param {mail.model.Message} message - * @param {Object} [options={}] - * @param {Array} [options.domain=[]] - * @param {boolean} [options.incrementUnread=false] - */ - _addMessageToThreads: function (message, options) { - var self = this; - options = options || {}; - _.each(message.getThreadIDs(), function (threadID) { - var thread = self.getThread(threadID); - if (thread) { - thread.addMessage(message, options); - } - }); - }, - /** - * Add a new message - * - * @private - * @param {Object} data - * @param {Object} options - * @return {Promise<mail.model.Message>} - */ - _addNewMessage: function (data, options) { - var self = this; - var message = this._makeMessage(data); - // Keep the array ordered by ID when inserting the new message - var index = _.sortedIndex(this._messages, message, function (msg) { - return msg.getID(); - }); - this._messages.splice(index, 0, message); - return this._addNewMessagePostprocessThread(message, options).then(function () { - self._addMessageToThreads(message, options); - if (!options.silent) { - self._mailBus.trigger('new_message', message); - } - return message; - }); - }, - /** - * For newly added message, postprocess threads linked to this message - * - * @private - * @param {mail.model.Message} message - * @param {Object} options - * @param {Array} [options.domain] - * @param {boolean} [options.incrementUnread] - * @param {boolean} [options.showNotification] - * @returns {Promise} - */ - _addNewMessagePostprocessThread: function (message, options) { - var self = this; - var proms = []; - _.each(message.getThreadIDs(), function (threadID) { - var thread = self.getThread(threadID); - if (thread) { - if ( - thread.getType() !== 'mailbox' && - !message.isMyselfAuthor() && - !message.isSystemNotification() - ) { - if (thread.isTwoUserThread() && options.showNotification) { - var prom = Promise.resolve(); - if ( - !self._isDiscussOpen() && - !config.device.isMobile && - !thread.isDetached() - ) { - // automatically open thread window - // while keeping it unread - prom = thread.detach({ passively: true }); - } - proms.push(prom); - prom.then(function () { - var query = { isVisible: false }; - self._mailBus.trigger('is_thread_bottom_visible', thread, query); - if (!self.call('bus_service', 'isOdooFocused') || !query.isVisible) { - self._notifyIncomingMessage(message); - } - }); - } - } - } - }); - return Promise.all(proms); - }, - /** - * Create a Channel (other than a DM) - * - * @private - * @param {string} name - * @param {string} type - * @returns {Promise<integer>} ID of the created channel - */ - _createChannel: function (name, type) { - var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); - return this._rpc({ - model: 'mail.channel', - method: 'channel_create', - args: [name, type], - kwargs: { context: context }, - }) - .then(this._addChannel.bind(this)); - }, - /** - * Create a Direct Messages Chat - * - * @private - * @param {string} name - * @returns {Promise<integer>} ID of the created channel - */ - _createDMChat: function (name) { - var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); - return this._rpc({ - model: 'mail.channel', - method: 'channel_get', - args: [[name]], - kwargs: { context: context }, - }) - .then(this._addChannel.bind(this)); - }, - /** - * @private - * @param {integer[]} channelIDs - * @returns {Promise<Object[]>} resolved with list of channel preview - */ - _fetchChannelPreviews: function (channelIDs) { - return this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_preview', - args: [channelIDs], - }, { shadow: true }); - }, - /** - * @private - */ - _fetchMailStateFromServer: function () { - var self = this; - session.is_bound.then(function () { - var context = _.extend( - { isMobile: config.device.isMobile }, - session.user_context - ); - return self._rpc({ - route: '/mail/init_messaging', - params: { context: context }, - }); - }).then(function (result) { - self._updateInternalStateFromServer(result); - self.call('bus_service', 'startPolling'); - self._isReady = true; - self._mailBus.trigger('messaging_ready'); - }); - }, - /** - * Get previews of the channels - * - * The preview of a channel is built from the channel information and its - * lastest message. A channel without a last message is incomplete, so it - * must fetch this message. - * - * Note that a channel could have no message at all, so it fetches for the - * last message only once per channel. This is correct to not fetch more - * than once in this case, because any later received message updates - * automatically the last message of a channel. - * - * @private - * @param {mail.model.Channel[]} channels - * @returns {Promise<Object[]>} resolved with list of channel previews - */ - _getChannelPreviews: function (channels) { - var self = this; - - var unpreviewedChannels = _.reject(channels, function (channel) { - return channel.hasBeenPreviewed(); - }); - var fetchDef; - if (unpreviewedChannels.length) { - var ids = _.map(unpreviewedChannels, function (channel) { - return channel.getID(); - }); - fetchDef = this._fetchChannelPreviews(ids); - } else { - fetchDef = Promise.resolve(); - } - return fetchDef.then(function (fetchedPreviews) { - // add fetched messages - var proms = []; - _.each(fetchedPreviews, function (fetchedPreview) { - if (fetchedPreview.last_message) { - proms.push(self.addMessage(fetchedPreview.last_message)); - } - // mark the channel as previewed, so that we do not need to - // fetch preview again - var channel = self.getChannel(fetchedPreview.id); - channel.markAsPreviewed(); - }); - return Promise.all(proms); - }).then(function () { - return _.map(channels, function (channel) { - return channel.getPreview(); - }); - }); - }, - /** - * Get the previews of the mail failures - * Mail failures of a same model with the same type are grouped together, so - * that there are few preview items on the messaging menu in the systray. - * - * To determine whether this is a model preview or a document preview review - * the documentID is omitted for a model preview, whereas it is set for a - * preview of a failure related to a document. - * - * @returns {Promise<Object[]>} resolved with previews of mail failures - */ - _getMailFailurePreviews: function () { - // items = list of objects: - // item = { - // unreadCounter: {integer}, - // failure: {mail.model.MailFailure}, - // isSameDocument: {boolean} - // } - var items = []; - _.each(this._mailFailures, function (failure) { - var unreadCounter = 1; - var isSameDocument = true; - var sameModelAndTypeItem = _.find(items, function (item) { - if ( - item.failure.isLinkedToDocument() && - (item.failure.getDocumentModel() === failure.getDocumentModel()) && - (item.failure.getMessageType() === failure.getMessageType()) - ) { - isSameDocument = item.failure.getDocumentID() === failure.getDocumentID(); - return true; - } - return false; - }); - - if (failure.isLinkedToDocument() && sameModelAndTypeItem) { - unreadCounter = sameModelAndTypeItem.unreadCounter + 1; - isSameDocument = sameModelAndTypeItem.isSameDocument && isSameDocument; - var index = _.findIndex(items, sameModelAndTypeItem); - items[index] = { - unreadCounter: unreadCounter, - failure: failure, - isSameDocument: isSameDocument, - }; - } else { - items.push({ - unreadCounter: unreadCounter, - failure: failure, - isSameDocument: true, - }); - } - }); - return _.map(items, function (item) { - // return preview with correct unread counter - // also unset documentID if the grouped mail failures are from - // same model but different document - var preview = {}; - _.extend(preview, item.failure.getPreview(), { - unreadCounter: item.unreadCounter, - }); - if (!item.isSameDocument) { - preview.documentID = undefined; - } - return preview; - }); - }, - /** - * @private - * @param {string|undefined} [filter] the filter to apply on channel - * previews in systray messaging menu - * @returns {Promise<Object[]>} resolved with object valid for template - * mail.Preview on channel previews, based on selected filter - */ - _getSystrayChannelPreviews: function (filter) { - var filteredThreads = _.filter(this._threads, function (thread) { - if (!thread.isChannel()) { - return false; - } - if (filter === 'chat') { - return thread.isTwoUserThread(); - } else if (filter === 'channels') { - return !thread.isTwoUserThread(); - } - return true; - }); - return this.getChannelPreviews(filteredThreads); - }, - /** - * Get the previews of needaction messages, i.e. messages in Inbox mailbox, - * based on selected filter. - * - * FIXME: it will use the current fetched messages in inbox + perform - * another message fetch on inbox. In other words, it means that some thread - * previews may be missing in the systray messaging menu. For example: - * - * - Each message fetch on Inbox fetches at most 30 messages. - * - There are 31 messages on Inbox. - * - No message on Inbox has been fetched at the moment - * - the 1st message on Inbox is linked to a document, and no other - * message in Inbox is linked to this document - * - * When clicking on the messaging menu, it will fetch all messages of Inbox, - * except the 1st message, in order to show the previews. The preview of - * the document thread of the 1st message will not be shown in the message - * menu - * - * This is a limitation, which should be solved by making changes on the - * server logic (e.g. provide list of all document threads in inbox on - * the rpc mail/init_messaging). - * - * @private - * @param {string|undefined} [filter] - * @returns {Promise<Object[]>} resolved with valid objects for template - * mail.Preview on inbox message previews - */ - _getSystrayInboxPreviews: function (filter) { - // 'All' filter, show messages preview from inbox and mail failures - // inbox previews - if (filter === 'mailbox_inbox' || !filter) { - var inbox = this.getMailbox('inbox'); - return inbox.getMessagePreviews(); - } else { - return Promise.resolve([]); - } - }, - /** - * Get the previews of mail failure, based on selected filter. - * - * @private - * @param {string|undefined} [filter] - */ - _getSystrayMailFailurePreviews: function (filter) { - // mail failure previews - if (filter === 'mailbox_inbox' || !filter) { - return this._getMailFailurePreviews(); - } else { - return Promise.resolve([]); - } - }, - /** - * Initialize the internal state of the mail service. Ensure that all - * attributes are set before doing any operation on them. - * - * @private - */ - _initializeInternalState: function () { - this._cannedResponses = []; - this._mailBus = new Bus(this); - this._commands = []; - this._discussMenuID = undefined; - this._discussOpen = false; - this._isMyselfModerator = false; - this._mailFailures = []; - // list of employees for chatter mentions - this._mentionPartnerSuggestions = []; - this._messages = []; - this._moderatedChannelIDs = []; - // # of message received when odoo is out of focus - this._outOfFocusUnreadMessageCounter = 0; - // partner_ids we have a pinned DM chat with - this._pinnedDmPartners = []; - // all threads, including channels, DM, mailboxes, document threads, ... - this._threads = []; - - this._addMailbox({ - id: 'inbox', - name: _t("Inbox"), - }); - this._addMailbox({ - id: 'starred', - name: _t("Starred"), - }); - this._addMailbox({ - id: 'history', - name: _t("History"), - }); - }, - /** - * State whether discuss app is open or not - * - * @private - * @returns {boolean} - */ - _isDiscussOpen: function () { - return this._discussOpen; - }, - /** - * Join the channel, and add it locally afterwards - * - * @private - * @param {integer|string} channelID - * @param {Object} options options passed on channel add - * @returns {Promise<integer>} channelID of joined and added channel - */ - _joinAndAddChannel: function (channelID, options) { - var self = this; - return this._rpc({ - model: 'mail.channel', - method: 'channel_join_and_get_info', - args: [[channelID]], - }) - .then(function (result) { - return self._addChannel(result, options); - }); - }, - /** - * Listen on several buses, before doing any action that trigger something - * on those buses. - * - * @private - */ - _listenOnBuses: function () { - this._mailBus.on('discuss_open', this, this._onDiscussOpen); - this.call('bus_service', 'on', 'window_focus', this, this._onWindowFocus); - }, - /** - * Creates a new instance of Channel with the given data and options. - * - * @private - * @param {Object} data - * @param {Array} [data.channel_type] if set and is 'livechat', the channel - * is a Livechat. - * @param {Array} [data.direct_partner] if set and is an non-empty array, - * the channel is a DM chat - * @param {Object} [options] - * @returns {mail.model.Channel} - */ - _makeChannel: function (data, options) { - if (_.size(data.direct_partner) > 0) { - return new DMChat({ - parent: this, - data: data, - options: options, - commands: this._commands - }); - } - if (data.channel_type === 'livechat') { - return new Livechat({ - parent: this, - data: data, - options: options, - commands: this._commands, - }); - } - return new MultiUserChannel({ - parent: this, - data: data, - options: options, - commands: this._commands, - }); - }, - /** - * Creates a new instance of Message with the given data. - * - * @private - * @param {Object} data - * @returns {mail.model.Message} - */ - _makeMessage: function (data) { - return new Message(this, data); - }, - /** - * @private - * @returns {$.Promise} - */ - _notifyChannelFetched: function () { - var channelIDs = this._toNotifyChannelFetchedIDs; - this._toNotifyChannelFetchedIDs = []; - if (_.isEmpty(channelIDs)) { - // no channel to notify fetched - return Promise.resolve(); - } - return this._rpc({ - model: 'mail.channel', - method: 'channel_fetched', - args: [channelIDs], - }, { shadow: true }); - }, - /** - * shows a popup to notify a new received message. - * This will also rename the browser tab if this is not the active tab. - * - * @private - * @param {mail.model.Message} message message received - */ - _notifyIncomingMessage: function (message) { - if (this.call('bus_service', 'isOdooFocused')) { - // no need to notify - return; - } - var title = _t("New message"); - if (message.hasAuthor()) { - title = _.escape(message.getAuthorName()); - } - var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML) - .substr(0, PREVIEW_MSG_MAX_SIZE); - - if (!this.call('bus_service', 'isOdooFocused')) { - this._outOfFocusUnreadMessageCounter++; - var tabTitle = _.str.sprintf( - _t("%d Messages"), - this._outOfFocusUnreadMessageCounter - ); - this.env.bus.trigger('set_title_part', { - part: '_chat', - title: tabTitle - }); - } - - this.call('bus_service', 'sendNotification', title, content); - }, - /** - * Open the thread in the Discuss app - * - * @private - * @param {integer|string} threadID a valid threadID - */ - _openThreadInDiscuss: function (threadID) { - var thread = this.getThread(threadID); - this._mailBus.trigger('open_thread_in_discuss', thread); - }, - /** - * @private - * @param {string} resModel - * @param {integer} resID - */ - _redirectDefault: function (resModel, resID) { - var self = this; - this._rpc({ - model: resModel, - method: 'get_formview_id', - args: [[resID], session.user_context.uid], - }) - .then(function (viewID) { - self._redirectToDocument(resModel, resID, viewID); - }); - }, - /** - * @private - * @param {Object} message - * @param {Array} message.channel_ids list of integers and strings, - * where strings for static channels, e.g. 'mailbox_inbox'. - * @param {string} resModel - * @param {integer} resID - * @param {string} viewID - */ - _redirectToDocument: function (resModel, resID, viewID) { - this.env.bus.trigger('do-action', { - action: { - type: 'ir.actions.act_window', - view_mode: 'form', - res_model: resModel, - views: [[viewID || false, 'form']], - res_id: resID, - }, - }); - }, - /** - * @private - * @param {string} resModel 'res.partner' - * @param {integer} resID - * @param {function} dmRedirection a function callback that has - * threadID as input - */ - _redirectPartner: function (resModel, resID, dmRedirection) { - var self = this; - var domain = [['partner_id', '=', resID]]; - this._rpc({ - model: 'res.users', - method: 'search', - args: [domain], - }) - .then(function (userIDs) { - if ( - userIDs.length && - userIDs[0] !== session.uid && - dmRedirection - ) { - self.createChannel(resID, 'dm_chat').then(dmRedirection); - } else { - self._redirectToDocument(resModel, resID); - } - }); - }, - /** - * Remove channel - * - * This is only called by the mail_notification_manager - * - * @private - * @param {mail.model.Channel} [channel] - */ - _removeChannel: function (channel) { - if (!channel) { return; } - if (channel.getType() === 'dm_chat') { - var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID()); - if (index > -1) { - this._pinnedDmPartners.splice(index, 1); - } - } - this._threads = _.without(this._threads, channel); - }, - /** - * Removes a message from a thread. - * - * @private - * @param {string} threadID - * @param {mail.model.Message} message - */ - _removeMessageFromThread: function (threadID, message) { - message.removeThread(threadID); - var thread = _.find(this._threads, function (thread) { - return thread.getID() === threadID; - }); - thread.removeMessage(message.getID()); - }, - /** - * @private - */ - _resetOutOfFocusUnreadMessageCounter: function () { - this._outOfFocusUnreadMessageCounter = 0; - }, - /** - * Extend the research to all users - * - * @private - * @param {string} searchVal - * @param {integer} limit - * @returns {Promise<Object[]>} fetched partners matching 'searchVal' - */ - _searchPartnerFetch: function (searchVal, limit) { - return this._rpc({ - model: 'res.partner', - method: 'im_search', - args: [searchVal, limit || 20], - }, { shadow: true }); - }, - /** - * Search among prefetched partners - * - * @private - * @param {string} searchVal - * @param {integer} limit - * @returns {string[]} partner suggestions that match searchVal - * (max limit, exclude session partner) - */ - _searchPartnerPrefetch: function (searchVal, limit) { - var values = []; - var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(searchVal)), 'i'); - _.each(this._mentionPartnerSuggestions, function (partners) { - if (values.length < limit) { - values = values.concat(_.filter(partners, function (partner) { - return (session.partner_id !== partner.id) && - searchRegexp.test(partner.name); - })).splice(0, limit); - } - }); - return values; - }, - /** - * Sort previews - * - * 1. unread, - * 2. dated previews - * 3. two-user thread, - * 4. date, - * - * @private - * @param {Object[]} previews - * @returns {Object[]} sorted list of previews - */ - _sortPreviews: function (previews) { - var res = previews.sort(function (p1, p2) { - var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter); - var datedDiff = !!p2.date - !!p1.date; - var isTwoUserThreadDiff = p2.isTwoUserThread - p1.isTwoUserThread; - var dateDiff = p2.date && p2.date.diff(p1.date); - return unreadDiff || datedDiff || isTwoUserThreadDiff || dateDiff; - }); - return res; - }, - /** - * Sort threads - * - * In case of mailboxes (Inbox, Starred), the name is translated - * thanks to _lt (lazy translate). In this case, channel.getName() is an - * object, not a string. - * - * @private - */ - _sortThreads: function () { - this._threads = _.sortBy(this._threads, function (thread) { - var name = thread.getName(); - return _.isString(name) ? name.toLowerCase() : ''; - }); - }, - /** - * Update the canned responses with mail data fetched from the server - * - * @private - * @param {Object} data - * @param {Object[]} [data.shortcodes] - * @param {integer} data.shortcodes[i].id - * @param {string} data.shortcodes[i].source - * @param {string} data.shortcodes[i].substitution - */ - _updateCannedResponsesFromServer: function (data) { - var self = this; - _.each(data.shortcodes, function (s) { - var cannedResponse = _.pick(s, ['id', 'source', 'substitution']); - self._cannedResponses.push(cannedResponse); - }); - }, - /** - * Update the channels with the mail data fetched from server, including - * public, private, DM, livechat, etc. - * - * @private - * @param {Object} data - * @param {Object} [data.channel_slots] contains the data of channels to - * update, which are grouped by channel type by key of the object - * (e.g. list of public channel data are stored in 'channel_channel') - * @param {Object[]} [data.channel_slots[i] list of data of channel of type - * `i` - * @returns {Promise} - */ - _updateChannelsFromServer: function (data) { - var self = this; - var proms = []; - const options = {}; - - _.each(data.channel_slots, function (channels) { - _.each(channels, function (channel) { - proms.push(self._addChannel(channel, options)); - }); - }); - return Promise.all(proms); - }, - /** - * Update commands from mail data fetched from the server - * - * @private - * @param {Object} data - * @param {Object[]} data.commands list of command data from the server - */ - _updateCommandsFromServer: function (data) { - this._commands = _.map(data.commands, function (command) { - return _.extend({ id: command.name }, command); - }); - }, - /** - * Update internal state from server data (mail/init_messaging rpc result) - * - * @private - * @param {Object} result data from server on mail/init_messaging rpc - * @param {Array<Object[]>} result.mention_partner_suggestions list of - * suggestions. - * @param {integer} result.menu_id the menu ID of discuss app - * @returns {Promise} - */ - _updateInternalStateFromServer: function (result) { - // commands are needed for channel instantiation - this._updateCommandsFromServer(result); - var prom = this._updateChannelsFromServer(result); - this._updateModerationSettingsFromServer(result); - this._updateMailboxesFromServer(result); - this._updateMailFailuresFromServer(result); - this._updateCannedResponsesFromServer(result); - - this._mentionPartnerSuggestions = result.mention_partner_suggestions; - this._discussMenuID = result.menu_id; - - return prom; - }, - /** - * Update the mailboxes with mail data fetched from server, namely 'Inbox', - * 'Starred', 'History', and 'Moderation Queue' if the user is a moderator of a channel - * - * @private - * @param {Object} data - * @param {boolean} [data.is_moderator=false] states whether the user is - * moderator of a channel - * @param {integer} [data.moderation_counter=0] states the mailbox counter - * to set to 'Moderation Queue' - * @param {integer} [data.needaction_inbox_counter=0] states the mailbox - * counter to set to 'Inbox' - * @param {integer} [data.starred_counter=0] states the mailbox counter to - * set to 'Starred' - */ - _updateMailboxesFromServer: function (data) { - this.getMailbox('inbox').setMailboxCounter(data.needaction_inbox_counter || 0); - this.getMailbox('starred').setMailboxCounter(data.starred_counter || 0); - - if (data.is_moderator) { - this._addMailbox({ - id: 'moderation', - name: _t("Moderate Messages"), - mailboxCounter: data.moderation_counter || 0, - }); - } - }, - /** - * Update mail failures with mail data fetched from the server - * - * @private - * @param {Object} data - * @param {Object[]} data.mail_failures data to update mail failures - * locally - */ - _updateMailFailuresFromServer: function (data) { - var self = this; - this._mailFailures = _.map(data.mail_failures, function (mailFailureData) { - return new MailFailure(self, mailFailureData); - }); - }, - /** - * Update moderation settings with mail data fetched from server - * - * @private - * @param {Object} data - * @param {boolean} [data.is_moderator=false] - * @param {integer[]} [data.moderation_channel_ids] - */ - _updateModerationSettingsFromServer: function (data) { - this._isMyselfModerator = data.is_moderator; - this._moderatedChannelIDs = data.moderation_channel_ids; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {boolean} open - */ - _onDiscussOpen: function (open) { - this._discussOpen = open; - }, - /** - * Reset out of focus unread message counter + tab title - * - * @private - */ - _onWindowFocus: function () { - this._resetOutOfFocusUnreadMessageCounter(); - this.env.bus.trigger('set_title_part', { part: '_chat' }); - }, -}); - -return MailManager; - -}); diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js deleted file mode 100644 index c7c5a03e6bf..00000000000 --- a/addons/mail/static/src/js/services/mail_notification_manager.js +++ /dev/null @@ -1,597 +0,0 @@ -odoo.define('mail.Manager.Notification', function (require) { -"use strict"; - -/** - * Mail Notification Manager - * - * This part of the mail manager is responsible for receiving notifications on - * the longpoll bus, which are data received from the server. - */ -var MailManager = require('mail.Manager'); -var MailFailure = require('mail.model.MailFailure'); - -const config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); - -var _t = core._t; - -MailManager.include({ - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Remove channel notifications if there is an unsubscribe notification - * on this channel. - * - * @private - * @param {Object[]} notifications - * @returns {Object[]} notifications filtered of channel notifications - * matching unsubscribe notifs - */ - _filterNotificationsOnUnsubscribe: function (notifications) { - var unsubscribedNotif = _.find(notifications, function (notif) { - return notif[1].info === 'unsubscribe'; - }); - if (unsubscribedNotif) { - notifications = _.reject(notifications, function (notif) { - return notif[0][1] === 'mail.channel' && - notif[0][2] === unsubscribedNotif[1].id; - }); - } - return notifications; - }, - /** - * Called when receiving a notification on a channel (all members of a - * channel receive this notification) - * - * @private - * @param {Object} params - * @param {integer} [params.channelID] - * @param {Object} params.data - * @param {string} [params.data.info] if set, specify the type of data on - * this channel notification - */ - _handleChannelNotification: function (params) { - if (params.data && params.data.info === 'typing_status') { - this._handleChannelTypingNotification(params.channelID, params.data); - } else if (params.data && params.data.info === 'channel_fetched') { - this._handleChannelFetchedNotification(params.channelID, params.data); - } else if (params.data && params.data.info === 'channel_seen') { - this._handleChannelSeenNotification(params.channelID, params.data); - } else { - this._handleChannelMessageNotification(params.data); - } - }, - /** - * Called when a channel has been fetched, and the server responses with the - * last message fetched. Useful in order to track last message fetched. - * - * @private - * @param {integer} channelID - * @param {Object} data - * @param {string} data.info 'channel_fetched' - * @param {integer} data.last_message_id - * @param {integer} data.partner_id - */ - _handleChannelFetchedNotification: function (channelID, data) { - var channel = this.getChannel(channelID); - if (!channel) { - return; - } - if (channel.hasSeenFeature()) { - channel.updateSeenPartnersInfo(data); - } - }, - /** - * Called when a new or updated message is received on a channel - * - * @private - * @param {Object} messageData - * @param {integer[]} messageData.channel_ids channel IDs of this message - * (note that 'pending moderation' messages in moderated channels do not - * have the moderated channels in this array). - */ - _handleChannelMessageNotification: function (messageData) { - var self = this; - var def; - let notify = true; - if (messageData.channel_ids.length === 1) { - const channel = this.getChannel(messageData.channel_ids[0]); - // Message from mailing channel should not make a notification in Odoo for users - // with notification "Handled by Email", but web client should receive the message. - // Channel has been marked as read server-side in this case, so it should not display - // a notification by incrementing the unread counter. - notify = channel && (!channel.isMassMailing() || session.notification_type !== 'email'); - def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false }); - } else { - def = Promise.resolve(); - } - def.then(function () { - // don't increment unread if channel wasn't in cache yet as - // its unread counter has just been fetched - return self.addMessage(messageData, { - showNotification: true, - incrementUnread: notify, - }); - }); - }, - /** - * Called when a channel has been seen, and the server responses with the - * last message seen. Useful in order to track last message seen. - * - * @private - * @param {integer} channelID - * @param {Object} data - * @param {string} data.info 'channel_seen' - * @param {integer} data.last_message_id - * @param {integer} data.partner_id - */ - _handleChannelSeenNotification: function (channelID, data) { - var channel = this.getChannel(channelID); - if (!channel) { - return; - } - if (channel.hasSeenFeature()) { - channel.updateSeenPartnersInfo(data); - } - if (session.partner_id !== data.partner_id) { - return; - } - channel.setLastSeenMessageID(data.last_message_id); - if (channel.hasUnreadMessages()) { - channel.resetUnreadCounter(); - } - }, - /** - * Called when someone starts or stops typing a message in a channel - * - * @private - * @param {integer} channelID - * @param {Object} typingData - * @param {boolean} typingData.is_typing - * @param {boolean} typingData.is_website_user - * @param {integer} typingData.partner_id - */ - _handleChannelTypingNotification: function (channelID, typingData) { - var isWebsiteUser = typingData.is_website_user; - var partnerID = typingData.partner_id; - if (partnerID === session.partner_id && !isWebsiteUser) { - return; // ignore typing notification of myself - } - var channel = this.getChannel(channelID); - if (!channel) { - return; - } - var typingID = { - isWebsiteUser: typingData.is_website_user, - partnerID: typingData.partner_id, - }; - if (typingData.is_typing) { - channel.registerTyping(typingID); - } else { - channel.unregisterTyping(typingID); - } - }, - /** - * On message becoming a need action (pinned to inbox) - * - * @private - * @param {Object} messageData - * @param {integer[]} messageData.channel_ids - */ - _handleNeedactionNotification: function (messageData) { - var self = this; - var inbox = this.getMailbox('inbox'); - this.addMessage(messageData, { - incrementUnread: true, - showNotification: true, - }).then(function (message) { - inbox.incrementMailboxCounter(); - _.each(message.getThreadIDs(), function (threadID) { - var channel = self.getChannel(threadID); - if (channel) { - channel.incrementNeedactionCounter(); - } - }); - self._mailBus.trigger('update_needaction', inbox.getMailboxCounter()); - }); - }, - /** - * Called when an activity record has been updated on the server - * - * @private - * @param {Object} data key, value to decide activity created or deleted - */ - _handlePartnerActivityUpdateNotification: function (data) { - this._mailBus.trigger('activity_updated', data); - }, - /** - * Called to open the channel in detach mode (minimized) even if no new message: - * - * @private - * @param {Object} channelData - * @param {integer} channelData.id - * @param {string} [channelData.info] - * @param {boolean} channelData.is_minimized - * @param {string} channelData.state - */ - _handlePartnerChannelMinimizeNotification: function (channelData) { - var self = this; - this._addChannel(channelData).then(function (channelID){ - self.getChannel(channelID).detach() - }); - }, - /** - * Called when receiving a channel state as a partner notification: - * - * - if it is a new channel, it means we have been invited to this channel - * - if it is an existing channel, it means the window state of the channel - * may have changed (as this state is stored server-side) - * - * @private - * @param {Object} channelData - * @param {integer} channelData.id - * @param {string} [channelData.info] - * @param {boolean} channelData.is_minimized - * @param {string} channelData.state - */ - _handlePartnerChannelNotification: function (channelData) { - if ( - (channelData.channel_type === 'channel') && - (channelData.state === 'open') - ) { - // invited to a new channel - this._addChannel(channelData, { autoswitch: false }); - if ( - !channelData.is_minimized && - channelData.info !== 'creation' - ) { - this.do_notify( - false, - _t("You have been invited to: ") + channelData.name); - } - } - // Prevent to open/close a channel on mobile when you open/close it on desktop. - if (!config.device.isMobile) { - const channel = this.getChannel(channelData.id); - if (channel && channelData.info !== 'join') { - channel.updateWindowState({ - folded: channelData.state === 'folded' ? true : false, - detached: channelData.is_minimized, - }); - } - } - }, - /** - * Called when receiving a multi_user_channel seen notification. Only - * the current user is notified. This must be handled as if this is a - * channel seen notification. - * - * Note that this is a 'res.partner' notification because only the current - * user is notified on channel seen. This is a consequence from disabling - * the seen feature on multi_user_channel, because we still need to get - * the last seen message ID in order to display the "New Messages" separator - * in Discuss. - * - * @private - * @param {Object} data - * @param {integer} data.channel_id - * @param {string} data.info 'channel_seen' - * @param {integer} data.last_message_id - * @param {integer} data.partner_id - */ - _handlePartnerChannnelSeenNotification: function (data) { - this._handleChannelSeenNotification(data.channel_id, data); - }, - /** - * Add or remove failure when receiving a failure update message - * - * @private - * @param {Object} datas - * @param {Object[]} datas.elements list of mail failure data - * @param {string} datas.elements[].message_id ID of related message that - * has a mail failure. - * @param {Array} datas.elements[].notifications list of notifications - * that is related to a mail failure. - * @param {string} datas.elements[].notifications[0] sending state of a mail - * failure (e.g. 'exception'). - */ - _handlePartnerMessageNotificationUpdateNotification(datas) { - var self = this; - _.each(datas.elements, function (data) { - var isNewFailure = _.some(data.notifications, function (notif) { - return notif[0] === 'exception' || notif[0] === 'bounce'; - }); - var matchedFailure = _.find(self._mailFailures, function (failure) { - return failure.getMessageID() === data.message_id; - }); - if (matchedFailure) { - var index = _.findIndex(self._mailFailures, matchedFailure); - if (isNewFailure) { - self._mailFailures[index] = new MailFailure(self, data); - } else { - self._mailFailures.splice(index, 1); - } - } else if (isNewFailure) { - self._mailFailures.push(new MailFailure(self, data)); - } - var message = _.find(self._messages, function (msg) { - return msg.getID() === data.message_id; - }); - if (message) { - message.setNotifications(data.notifications); - self._mailBus.trigger('update_message', message); - } - }); - this._mailBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * Updates mailbox_inbox when a message has marked as read. - * - * @private - * @param {Object} data - * @param {integer[]} [data.channel_ids] - * @param {integer[]} [data.message_ids] - * @param {string} [data.type] - */ - _handlePartnerMarkAsReadNotification: function (data) { - var self = this; - var history = this.getMailbox('history'); - _.each(data.message_ids, function (messageID) { - var message = _.find(self._messages, function (msg) { - return msg.getID() === messageID; - }); - if (message) { - self._removeMessageFromThread('mailbox_inbox', message); - history.addMessage(message); - self._mailBus.trigger('update_message', message, data.type); - } - }); - if (data.channel_ids) { - _.each(data.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.decrementNeedactionCounter(data.message_ids.length, 0); - } - }); - } else { - // if no channel_ids specified, this is a 'mark all read' in inbox - _.each(this.getChannels(), function (channel) { - channel.resetNeedactionCounter(); - }); - } - var inbox = this.getMailbox('inbox'); - inbox.decrementMailboxCounter(data.message_ids.length); - this._mailBus.trigger('update_needaction', inbox.getMailboxCounter()); - }, - /** - * On receiving a message made by the current user, on a moderated channel, - * which is pending moderation. - * - * @private - * @param {Object} data - * @param {Object} data.message server-side data of the message - */ - _handlePartnerMessageAuthorNotification: function (data) { - this.addMessage(data.message); - }, - /** - * Notification to delete several messages locally - * Useful when a pending moderation message has been rejected, so that - * this message should not be displayed anymore. - * - * @private - * @param {Object} data - * @param {Object[]} [data.message_ids] IDs of messages to delete locally. - */ - _handlePartnerMessageDeletionNotification: function (data) { - var self = this; - _.each(data.message_ids, function (messageID) { - var message = self.getMessage(messageID); - if (message) { - message.setModerationStatus('rejected'); - } - }); - }, - /** - * On receiving a message pending moderation, and current user is moderator - * of such message. - * - * @param {Object} data notification data - * @param {Object} data.message data of message - */ - _handlePartnerMessageModeratorNotification: function (data) { - var self = this; - this.addMessage(data.message).then(function () { - self._mailBus.trigger('update_moderation_counter'); - }); - }, - /** - * On receiving a notification that is specific to a user - * - * @private - * @param {Object} data structure depending on the type - * @param {integer} data.id - */ - _handlePartnerNotification: function (data) { - if (data.info === 'unsubscribe') { - this._handlePartnerUnsubscribeNotification(data); - } else if (data.type === 'toggle_star') { - this._handlePartnerToggleStarNotification(data); - } else if (data.type === 'mark_as_read') { - this._handlePartnerMarkAsReadNotification(data); - } else if (data.type === 'moderator') { - this._handlePartnerMessageModeratorNotification(data); - } else if (data.type === 'author') { - this._handlePartnerMessageAuthorNotification(data); - } else if (data.type === 'deletion') { - this._handlePartnerMessageDeletionNotification(data); - } else if (data.info === 'transient_message') { - this._handlePartnerTransientMessageNotification(data); - } else if (data.type === 'activity_updated') { - this._handlePartnerActivityUpdateNotification(data); - } else if (data.type === 'message_notification_update') { - this._handlePartnerMessageNotificationUpdateNotification(data); - } else if (data.type === 'user_connection') { - this._handlePartnerUserConnectionNotification(data); - } else if (data.info === 'channel_seen') { - this._handlePartnerChannnelSeenNotification(data); - } else if (data.type === 'simple_notification') { - var title = _.escape(data.title), message = _.escape(data.message); - data.warning ? this.do_warn(title, message, data.sticky) : this.do_notify(title, message, data.sticky); - } else if (data.info === 'channel_minimize') { - this._handlePartnerChannelMinimizeNotification(data); - } else { - this._handlePartnerChannelNotification(data); - } - }, - /** - * On toggling on or off the star status of one or several messages. - * As the information is stored server-side, the web client must adapt - * itself from server's data on the messages. - * - * @private - * @param {Object} data - * @param {integer[]} data.message_ids IDs of messages that have a change - * of their starred status. - * @param {boolean} data.starred states whether the messages with id in - * `data.message_ids` have become either starred or unstarred - */ - _handlePartnerToggleStarNotification: function (data) { - var self = this; - var starred = this.getMailbox('starred'); - _.each(data.message_ids, function (messageID) { - var message = _.find(self._messages, function (msg) { - return msg.getID() === messageID; - }); - if (message) { - message.setStarred(data.starred); - if (!message.isStarred()) { - self._removeMessageFromThread('mailbox_starred', message); - } else { - self._addMessageToThreads(message, []); - var channelStarred = self.getMailbox('starred'); - channelStarred.invalidateCaches(); - } - self._mailBus.trigger('update_message', message); - } - }); - - if (data.starred) { - // increase starred counter if message is marked as star - starred.incrementMailboxCounter(data.message_ids.length); - } else { - // decrease starred counter if message is remove from starred - // if unstar_all then it will set to 0. - starred.decrementMailboxCounter(data.message_ids.length); - } - - this._mailBus.trigger('update_starred', starred.getMailboxCounter()); - }, - /** - * On receiving a transient message, i.e. a message which does not come from - * a member of the channel. Usually a log message, such as one generated - * from a command with ('/'). - * - * @private - * @param {Object} data - */ - _handlePartnerTransientMessageNotification: function (data) { - var lastMessage = _.last(this._messages); - data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01; - data.author_id = this.getOdoobotID(); - this.addMessage(data); - }, - /** - * On receiving a unsubscribe from channel notification, confirm - * unsubscription from channel and adapt screen accordingly. - * - * @private - * @param {Object} data - * @param {Object} data.id ID of the unsubscribed channel - */ - _handlePartnerUnsubscribeNotification: function (data) { - var channel = this.getChannel(data.id); - if (channel) { - var message; - if (_.contains(['public', 'private'], channel.getType())) { - message = _.str.sprintf( - _t("You unsubscribed from <b>%s</b>."), - channel.getName() - ); - } else { - message = _.str.sprintf( - _t("You unpinned your conversation with <b>%s</b>."), - channel.getName() - ); - } - this._removeChannel(channel); - this._mailBus.trigger('unsubscribe_from_channel', data.id); - this.do_notify(false, message); - } - }, - /** - * Shows a popup to notify a user connection - * - * @private - * @param {Object} data - * @param {Object[]} data.partner_id id of the connected partner - * @param {string} data.title title to display on notification - * @param {Array} data.messages message to display on notification - */ - _handlePartnerUserConnectionNotification: function (data) { - var self = this; - var partnerID = data.partner_id; - this.call('bus_service', 'sendNotification', data.title, data.message, function ( ){ - self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID); - }); - }, - /** - * @override - * @private - */ - _listenOnBuses: function () { - this._super.apply(this, arguments); - this.call('bus_service', 'onNotification', this, this._onNotification); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Notification handlers - * Sometimes, the web client receives unsubscribe notification and an extra - * notification on that channel. This is then followed by an attempt to - * rejoin the channel that we just left. The next few lines remove the - * extra notification to prevent that situation to occur. - * - * @private - * @param {Array} notifs - */ - _onNotification: function (notifs) { - var self = this; - notifs = this._filterNotificationsOnUnsubscribe(notifs); - _.each(notifs, function (notif) { - var model = notif[0][1]; - if (model === 'ir.needaction') { - self._handleNeedactionNotification(notif[1]); - } else if (model === 'mail.channel') { - // new message in a channel - self._handleChannelNotification({ - channelID: notif[0][2], - data: notif[1], - }); - } else if (model === 'res.partner') { - self._handlePartnerNotification(notif[1]); - } - }); - }, -}); - -return MailManager; - -}); diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js deleted file mode 100644 index 2256e696c2f..00000000000 --- a/addons/mail/static/src/js/services/mail_service.js +++ /dev/null @@ -1,17 +0,0 @@ -odoo.define('mail.Service', function (require) { -"use strict"; - -var core = require('web.core'); - -var MailManager = require('mail.Manager'); - -require('mail.Manager.Status'); -require('mail.Manager.Notification'); -require('mail.Manager.Window'); -require('mail.Manager.DocumentThread'); - -core.serviceRegistry.add('mail_service', MailManager); - -return MailManager; - -}); diff --git a/addons/mail/static/src/js/services/mail_status_manager.js b/addons/mail/static/src/js/services/mail_status_manager.js deleted file mode 100644 index 184499e8bb4..00000000000 --- a/addons/mail/static/src/js/services/mail_status_manager.js +++ /dev/null @@ -1,232 +0,0 @@ -odoo.define('mail.Manager.Status', function (require) { -"use strict"; - -var core = require('web.core'); -var MailManager = require('mail.Manager'); -var mailUtils = require('mail.utils'); -var QWeb = core.qweb; - -/** - * Mail Manager: IM Status - * - * This component handles im status of partners, which is useful for DM Chats, - * partner mention suggestions, and chatter messages that display the user icon. - */ -MailManager.include({ - _UPDATE_INTERVAL: 50, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Returns the uin cache im status, and triggers an asynchronous throttled - * update of im_status cache for missings - * - * @param {Object} data - * @param {integer} data.partnerID - * @return {String} - */ - getImStatus: function (data) { - var self = this; - var partnerID = data.partnerID; - if (partnerID === this.getOdoobotID()[0]) { - return 'bot'; - } - if (!this._imStatus[partnerID]) { - // Add to list to call it in next bus update or _fetchMissingImStatus - this._imStatus[partnerID] = undefined; - // fetch after some time if no other getImStatus occurs - mailUtils.clearTimeout(this._fetchStatusTimeout); - this._fetchStatusTimeout = mailUtils.setTimeout(function () { - self._fetchMissingImStatus(); - }, 500); - } - return this._imStatus[partnerID]; - }, - /** - * Update status manually, to avoid to do a rpc and an asynchronous update - * after getImStatus. Can be done by any caller knowing the last im_status - * state. - * - * @param {Object[]} statusList, A list of {id, im_status} - */ - updateImStatus: function (statusList) { - var updatedIDs = []; - var self = this; - var dmChat; - var toUpdateOutOfOfficeChatIDs = []; - _.each(statusList, function (status) { - if (self._imStatus[status.id] === status.im_status) { - return; - } - if ( - status.im_status.indexOf('leave') !== -1 || - ( - self._imStatus[status.id] && - self._imStatus[status.id].indexOf('leave') !== -1 - ) - ) { - dmChat = self.getDMChatFromPartnerID(status.id); - if (dmChat) { - toUpdateOutOfOfficeChatIDs.push(dmChat.getID()); - } - } - updatedIDs.push(status.id); - self._imStatus[status.id] = status.im_status; - }); - if (!_.isEmpty(toUpdateOutOfOfficeChatIDs)) { - this._rpc({ - model: 'mail.channel', - method: 'channel_info', - args: [toUpdateOutOfOfficeChatIDs], - }).then(function (channelsInfo) { - _.each(channelsInfo, function (channelInfo) { - dmChat = self.getChannel(channelInfo.id); - if (!dmChat) { - return; - } - dmChat.updateOutOfOfficeInfo({ - outOfOfficeDateEnd: channelInfo.direct_partner[0].out_of_office_date_end, - }); - }); - }); - } - if (! _.isEmpty(updatedIDs)) { - this._mailBus.trigger('updated_im_status', updatedIDs); // useful for thread window header - this._renderImStatus(updatedIDs); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Fetch the list of im_status for partner with id in ids list and triggers - * an update. - * - * @private - * @param {Object} data - * @param {integer[]} data.partnerIDs - * @return {Deferred} - */ - _fetchImStatus: function (data) { - var self = this; - var partnerIDs = data.partnerIDs; - if (_.isEmpty(partnerIDs)) { - return Promise.resolve(); - } - return this._rpc({ - route: '/longpolling/im_status', - params: { partner_ids: partnerIDs }, - }, { - shadow: true, - }).then( function (results) { - self.updateImStatus(results); - }); - }, - /** - * Fetch the list of im_status for partner with an unknown im_status and - * triggers an update. - * - * @private - * @return {Deferred} - */ - _fetchMissingImStatus: function () { - var missing = []; - _.each(this._imStatus, function (value, key) { - if (value === undefined) { - missing.push(Number(key)); - } - }); - return this._fetchImStatus({ partnerIDs: missing }); - }, - /** - * @private - * @return {integer[]} a list of partner ids that needs update - */ - _getImStatusToUpdate: function () { - var toUpdate = []; - _.each(this._imStatus, function (status, key) { - //filter on im_partner and bot: useless to update them, status won't change - if (['im_partner', 'bot'].indexOf(status) === -1) { - toUpdate.push(Number(key)); - } - }); - return toUpdate; - }, - /** - * @override - * @private - */ - _initializeInternalState: function () { - this._super.apply(this, arguments); - this._fetchStatusTimeout = undefined; - this._imStatus = {}; - this._isTabFocused = true; - this._updateImStatusLoop(); - }, - /** - * @override - * @private - */ - _listenOnBuses: function () { - this._super.apply(this, arguments); - $(window).on("focus", this._onWindowFocusChange.bind(this, true)); - $(window).on("blur", this._onWindowFocusChange.bind(this, false)); - $(window).on("unload", this._onWindowFocusChange.bind(this, false)); - }, - /** - * @private - * @param {integer[]} updatedIds - */ - _renderImStatus: function (updatedIds) { - var self = this; - $('.o_updatable_im_status').each(function () { - var $this = $(this); - var partnerID = $this.data('partner-id'); - if (partnerID !== undefined && updatedIds.indexOf(partnerID) !== -1) { // todo instead add id on o_updatable_im_status and select only concerned ones - var status = QWeb.render('mail.UserStatus', { - status: self.getImStatus({ partnerID: partnerID }), - partnerID: partnerID, - }); - $this.replaceWith(status); - } - }); - }, - /** - * Once initialised, this loop will update the im_status of registered - * users. - * - * @private - * @param {integer} [counter=0] The recursion loop counter - */ - _updateImStatusLoop: function (counter) { - var self = this; - if (!_.isNumber(counter)) { - counter = 0; - } - mailUtils.setTimeout(function () { - if (counter >= self._UPDATE_INTERVAL && self._isTabFocused) { - self._fetchImStatus({ partnerIDs: self._getImStatusToUpdate() }); - counter = 0; - } - self._updateImStatusLoop(counter+1); - }, 1000); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {boolean} focused - */ - _onWindowFocusChange: function (focused) { - this._isTabFocused = focused; - }, -}); - -}); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js deleted file mode 100644 index cb0eb0b7dca..00000000000 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ /dev/null @@ -1,780 +0,0 @@ -odoo.define('mail.Manager.Window', function (require) { -"use strict"; - -var MailManager = require('mail.Manager'); -var ThreadWindow = require('mail.ThreadWindow'); - -var config = require('web.config'); -var core = require('web.core'); -var utils = require('web.utils'); - -var QWeb = core.qweb; -var _t = core._t; - -/** - * Mail Window Manager - * - * This part of the mail manager is responsible for the management of thread - * windows. - */ -MailManager.include({ - custom_events: _.extend({}, MailManager.prototype.custom_events, { - close_blank_thread_window: '_onCloseBlankThreadWindow', - }), - - // width of 'hidden thread window' dropdown button - HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH: 50, - // where thread windows are appended - THREAD_WINDOW_APPENDTO: 'body', - // width of a thread window (+ 5 pixels between windows) - THREAD_WINDOW_WIDTH: 325 + 5, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Open the blank thread window (i.e. the thread window without any thread - * linked to it). Make it if there is no blank thread window yet. - */ - openBlankThreadWindow: function () { - var blankThreadWindow = this._getBlankThreadWindow(); - if (!blankThreadWindow) { - blankThreadWindow = new ThreadWindow(this, null); - this._addThreadWindow(blankThreadWindow); - blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO) - .then(this._repositionThreadWindows.bind(this)); - } else { - if (blankThreadWindow.isHidden()) { - this._makeThreadWindowVisible(blankThreadWindow); - } else if (blankThreadWindow.isFolded()) { - blankThreadWindow.toggleFold(false); - } - } - }, - /** - * Open a DM chat in a thread window. - * - * @param {integer} partnerID - * @returns {Promise} - */ - openDMChatWindow: async function (partnerID) { - let dmChat = this.call('mail_service', 'getDMChatFromPartnerID', partnerID); - if (!dmChat) { - const dmChatId = await this.call('mail_service', 'createChannel', partnerID, 'dm_chat'); - dmChat = this.call('mail_service', 'getChannel', dmChatId); - } - dmChat.detach(); - }, - /** - * Open a DM chat in a thread window. This is useful when selecting a DM - * chatin the blank thread window, so that it replaces it with the DM chat - * window. - * - * @param {integer} partnerID - */ - openDMChatWindowFromBlankThreadWindow: function (partnerID) { - var dmChat = this.getDMChatFromPartnerID(partnerID); - if (!dmChat) { - this._openAndDetachDMChat(partnerID); - } else { - this.openThreadWindow(dmChat.getID()); - } - this._closeBlankThreadWindow(); - }, - /** - * Open the thread window if discuss is not opened - * - * @override - * @param {integer|string} threadID - */ - openThread: function (threadID) { - if (!this._isDiscussOpen()) { - var thread = this.getThread(threadID); - if (thread) { - thread.detach(); - } - } else { - this._super.apply(this, arguments); - } - }, - /** - * Open a thread in a thread window - * - * @param {integer} threadID a valid thread ID - * @param {Object} [options] - * @param {boolean} [options.passively] if set to true, open the thread - * window without focusing the input and marking messages as read if it - * is not open yet, and do nothing otherwise. - * @param {boolean} [options.keepFoldState=false] if set to true, keep the - * fold state of the thread - * @param {boolean} [options.skipCrossTabSync=false] if set, thread - * should not notify other tabs from new document thread chat window state. - * @returns {Promise} - */ - openThreadWindow: function (threadID, options) { - var self = this; - options = options || {}; - // valid threadID, therefore no check - var thread = this.getThread(threadID); - if (thread.isCreatingWindow) { - // abort creating a chat window, to prevent open chat window twice. - // This may happen due to concurrent calls to this method from - // messaging menu preview click and handling of longpolling chat - // window state. - return Promise.resolve(); - } - var threadWindow = this._getThreadWindow(threadID); - var prom = Promise.resolve(); - if (!threadWindow) { - thread.isCreatingWindow = true; - prom = thread.fetchMessages().then(function () { - threadWindow = self._makeNewThreadWindow(thread, options); - return threadWindow.appendTo($(self.THREAD_WINDOW_APPENDTO)); - }).then(function () { - self._placeNewThreadWindow(threadWindow, options.passively); - self._repositionThreadWindows(); - threadWindow.render(); - threadWindow.scrollToBottom(); - if ( - !self._areAllThreadWindowsHidden() && - !thread.isFolded() && - !threadWindow.isPassive() - ) { - thread.markAsRead(); - } - thread.isCreatingWindow = false; - }).guardedCatch(function () { - // thread window could not be open, which may happen due to - // access error while fetching messages to the document. - // abort opening the thread window in this case. - thread.close({ - skipCrossTabSync: true, - }); - thread.isCreatingWindow = false; - }); - } - return prom.then(function () { - threadWindow.updateVisualFoldState(); - }); - }, - /** - * Called when a thread has its window state that has been changed, so its - * thread window view should be changed to match the model. - * - * @param {integer|string} threadID - * @param {Object} options option to be applied on opening thread window, if - * the thread is detached - * @param {boolean} [options.passively=false] if set, the window will behave - * passively. - */ - updateThreadWindow: function (threadID, options) { - var thread = this.getThread(threadID); - var prom = Promise.resolve(); - if (thread) { - if (thread.isDetached()) { - _.extend(options, { keepFoldState: true }); - prom = this.openThreadWindow(threadID, options); - } else { - this._closeThreadWindow(threadID); - } - } - return prom; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add the thread window such that it will be the left-most visible window. - * Note that adding a new thread window may decrement the amount of - * available slots for visible thread windows. - * For example: - * - * - global width of 800px - * - button width of 100px - * - thread width of 250px - * - * Without button: 3 thread windows (3*250px = 750px < 800px) - * With button: 2 thread windows (2*250px + 100px = 600px < 800px) - * - * So in order for the thread window to be the left-most visible window, - * we should compute available slots after insertion of the thread window. - * - * @private - * @param {mail.ThreadWindow} threadWindow - */ - _addThreadWindow: function (threadWindow) { - this._threadWindows.push(threadWindow); - this._computeAvailableSlotsForThreadWindows(); - this._threadWindows.pop(); - this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow); - }, - /** - * States whether all the thread windows are hidden or not. - * When discuss is open, the thread windows are hidden. - * - * @private - * @returns {boolean} - */ - _areAllThreadWindowsHidden: function () { - return $(this.THREAD_WINDOW_APPENDTO).hasClass('o_no_thread_window'); - }, - /** - * Close the thread window linked to the thread with ID `threadID`. - * If there is no window linked to this thread, do nothing. - * - * @private - * @param {integer|string} threadID - */ - _closeThreadWindow: function (threadID) { - var threadWindow = _.find(this._threadWindows, function (threadWindow) { - return threadWindow.getID() === threadID; - }); - if (threadWindow) { - this._threadWindows = _.without(this._threadWindows, threadWindow); - this._repositionThreadWindows(); - threadWindow.destroy(); - } - }, - /** - * Close the blank thread window. - */ - _closeBlankThreadWindow: function () { - var blankThreadWindow = this._getBlankThreadWindow(); - if (blankThreadWindow) { - this._closeThreadWindow(blankThreadWindow.getID()); - } - }, - /** - * Compute the number of available slots to display thread windows on the - * screen. This is based on the width of the screen, and the width of a - * single thread window. - * - * The available slots attributes are updated as a consequence of this - * method call. - * - * @private - */ - _computeAvailableSlotsForThreadWindows: function () { - if (config.device.isMobile) { - // one thread window full screen in mobile - this._availableSlotsForThreadWindows = 1; - return; - } - var GLOBAL_WIDTH = this._getGlobalWidth(); - var THREAD_WINDOW_NUM = this._threadWindows.length; - var availableSlots = Math.floor(GLOBAL_WIDTH/this.THREAD_WINDOW_WIDTH); - var spaceLeft = GLOBAL_WIDTH - (Math.min(availableSlots, THREAD_WINDOW_NUM)*this.THREAD_WINDOW_WIDTH); - if (availableSlots < THREAD_WINDOW_NUM && spaceLeft < this.HIDDEN_THREAD_WINDOW_DROPDOWN_BUTTON_WIDTH) { - // leave at least space for the hidden windows dropdown button - availableSlots--; - spaceLeft += this.THREAD_WINDOW_WIDTH; - } - - this._availableSlotsForThreadWindows = availableSlots; - this._spaceLeftForThreadWindows = spaceLeft; - }, - /** - * Get the blank thread window, which is the special thread window that has - * no thread linked to it. - * - * This is useful in case a DM chat window may replace the blank thread - * window, when we want to open a DM chat from the blank thread window. - * - * @private - * @returns {mail.ThreadWindow|undefined} the "blank thread" window, - * if it exists, otherwise undefined - */ - _getBlankThreadWindow: function () { - return _.find(this._threadWindows, function (threadWindow) { - return threadWindow.getID() === '_blank'; - }); - }, - /** - * Get the width of the browser, which is useful to determine how many - * open thread windows are visible or hidden. - * - * @private - * @returns {integer} - */ - _getGlobalWidth: function () { - return window.innerWidth; - }, - /** - * Get thread window in the hidden windows matching ID `threadID`. - * - * Note: hidden windows are open windows that cannot be displayed - * due to having more thread windows open than available slots for thread - * windows on the screen. These thread windows are displayed in the hidden - * thread window dropdown menu. - * - * @private - * @param {integer|string} threadID - * @returns {mail.ThreadWindow|undefined} the hidden thread window, - * if exists - */ - _getHiddenThreadWindow: function (threadID) { - return _.find(this._hiddenThreadWindows, function (threadWindow) { - return threadWindow.getID() === threadID; - }); - }, - /** - * Get thread window matching ID `threadID` - * - * @private - * @param {integer} threadID - * @returns {mail.ThreadWindow|undefined} the thread window, if exists - */ - _getThreadWindow: function (threadID) { - return _.find(this._threadWindows, function (threadWindow) { - return threadWindow.getID() === threadID; - }); - }, - /** - * @override - * @private - */ - _initializeInternalState: function () { - this._super.apply(this, arguments); - - this._availableSlotsForThreadWindows = 0; - this._hiddenThreadWindows = []; - // used to keep dropdown open when closing thread windows - this._keepHiddenThreadWindowsDropdownOpen = false; - this._spaceLeftForThreadWindows = 0; - this._threadWindows = []; - // jQuery element for the dropdown of hidden thread windows - // see _renderHiddenThreadWindowsDropdown - this._$hiddenThreadWindowsDropdown = null; - }, - /** - * @override - * @private - */ - _listenOnBuses: function () { - this._super.apply(this, arguments); - this._mailBus - .on('update_message', this, this._onUpdateMessage) - .on('new_message', this, this._onNewMessage) - .on('new_channel', this, this._onNewChannel) - .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) - .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) - .on('updated_im_status', this, this._onUpdatedImStatus) - .on('updated_out_of_office', this, this._onUpdatedOutOfOffice) - .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter); - - core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); - }, - /** - * Make the hidden thread window dropdown menu, that is render it and set - * event listener on this dropdown menu DOM element. - * - * @private - */ - _makeHiddenThreadWindowsDropdown: function () { - var self = this; - if (this._$hiddenThreadWindowsDropdown) { - this._$hiddenThreadWindowsDropdown.remove(); - } - if (this._hiddenThreadWindows.length) { - this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown(); - var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown; - $hiddenWindowsDropdown.css({right: this.THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 }) - .appendTo(this.THREAD_WINDOW_APPENDTO); - this._repositionHiddenWindowsDropdown(); - this._keepHiddenThreadWindowsDropdownOpen = false; - - $hiddenWindowsDropdown - .on('click', '.o_thread_window_header', function (ev) { - var threadID = $(ev.currentTarget).data('thread-id'); - var threadWindow = self._getHiddenThreadWindow(threadID); - if (threadWindow) { - self._makeThreadWindowVisible(threadWindow); - } - }) - .on('click', '.o_thread_window_close', function (ev) { - ev.preventDefault(); - var threadID = $(ev.currentTarget).closest('.o_thread_window_header') - .data('thread-id'); - var threadWindow = self._getHiddenThreadWindow(threadID); - if (threadWindow) { - threadWindow.close(); - // keep the dropdown open - self._keepHiddenThreadWindowsDropdownOpen = true; - } - }); - } - }, - /** - * Make a new thread window linked to a thread. - * - * @private - * @param {mail.model.Thread} thread - * @param {Object} options - * @param {boolean} [options.passively=false] - */ - _makeNewThreadWindow: function (thread, options) { - return new ThreadWindow(this, thread, _.extend(options, { - autofocus: !options.passively, - })); - }, - /** - * Make an open thread window fully visible on screen. - * - * This method assumes that the thread window is hidden (i.e. in the hidden - * dropdown menu). To make it visible, it swap the position of this thread - * window with the last thread window that is visible (i.e. the left-most - * visible thread window). - * - * @private - * @param {mail.ThreadWindow} threadWindow - */ - _makeThreadWindowVisible: function (threadWindow) { - utils.swap( - this._threadWindows, - threadWindow, - this._threadWindows[this._availableSlotsForThreadWindows-1] - ); - this._repositionThreadWindows(); - threadWindow.toggleFold(false); - }, - /** - * Open and detach the DM chat in a thread window. - * - * This method assumes that no such DM chat exists locally, so it is kind - * of a "create DM chat and open DM chat window" operation - * - * @private - * @param {integer} partnerID - * @returns {Promise<integer>} resolved with ID of the DM chat - */ - _openAndDetachDMChat: async function (partnerID) { - const data = await this._rpc({ - model: 'mail.channel', - method: config.device.isMobile ? 'channel_get' : 'channel_get_and_minimize', - args: [[partnerID]], - }); - const channelID = await this._addChannel(data); - const channel = this.getChannel(channelID); - channel.detach(); - }, - /** - * On opening a new thread window, place it with other thread windows: - * - * - if it has been open with the blank thread window, replace the blank - * thread window with this one - * - if it has been open passively, simply but it after all windows - * - otherwise, make it the left-most visible thread window - * - * @param {mail.ThreadWindow} threadWindow a thread window that is linked - * to a thread (this must not be the blank thread window) - * @param {boolean} [passively=false] if set, if the thread window does not - * replace the blank thread window, it is add at the tail of the list of - * thread windows, which might be put in the thread window hidden dropdown - * menu if there are not enough space on the screen. - */ - _placeNewThreadWindow: function (threadWindow, passively) { - var thread = this.getThread(threadWindow.getID()); - // replace the blank thread window? - // the thread window should be a DM - var blankThreadWindow = this._getBlankThreadWindow(); - if ( - blankThreadWindow && - thread.getType() === 'dm_chat' && - thread.getDirectPartnerID() === blankThreadWindow.directPartnerID - ) { - // the window takes the place of the 'blank' thread window - var index = _.indexOf(this._threadWindows, blankThreadWindow); - this._threadWindows[index] = threadWindow; - blankThreadWindow.destroy(); - } else if (passively) { - // simply insert the window to the left - this._threadWindows.push(threadWindow); - } else { - // add window such that it is visible - this._addThreadWindow(threadWindow); - } - }, - /** - * Unfold dropdown to the left if there is enough space on the screen. - * - * @private - */ - _repositionHiddenWindowsDropdown: function () { - var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul'); - if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) { - $dropdownUL.addClass('dropdown-menu-right'); - } - }, - /** - * Load the template of the hidden thread window dropdown - * - * @private - * @returns {jQuery.Element} - */ - _renderHiddenThreadWindowsDropdown: function () { - var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', { - threadWindows: this._hiddenThreadWindows, - open: this._keepHiddenThreadWindowsDropdownOpen, - unreadCounter: this._hiddenThreadWindowsUnreadCounter, - widget: { - isMobile: function () { - return config.device.isMobile; - }, - }, - })); - return $dropdown; - }, - /** - * Reposition the thread windows that should be hidden on the screen. - * Thread windows that have an index equal or greater than `index` in the - * attribute `threadWindows` should be hidden. Those thread windows are put - * in the hidden thread window dropdown menu. - * - * This function assumes that we have already computed the available slots. - * - * @private - */ - _repositionHiddenThreadWindows: function () { - var hiddenWindows = []; - var hiddenUnreadCounter = 0; - var index = this._availableSlotsForThreadWindows; - while (index < this._threadWindows.length) { - var threadWindow = this._threadWindows[index]; - hiddenWindows.push(threadWindow); - hiddenUnreadCounter += threadWindow.getUnreadCounter(); - threadWindow.do_hide(); - index++; - } - this._hiddenThreadWindows = hiddenWindows; - this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter; - - this._makeHiddenThreadWindowsDropdown(); - }, - /** - * Reposition the thread windows, based on the size of the screen: - * - * - display thread windows by increasing order of index in - * `_threadWindows` attribute, from right to left on the screen - * - if there is no enough space to show all windows at once, display - * a dropdown menu for hidden windows. - * - * This method should be called whenever there is a change of state of a - * thread in a window. Also, when this method is called, all the windows - * are visible and stacked in the top-left corner of the screen. - * - * @private - */ - _repositionThreadWindows: function () { - this._computeAvailableSlotsForThreadWindows(); - - this._repositionVisibleThreadWindows(); - this._repositionHiddenThreadWindows(); - }, - /** - * Reposition the thread windows that should be visible on the screen. - * - * This function assumes that we have already computed the available slots. - * - * @private - */ - _repositionVisibleThreadWindows: function () { - var index = 0; - while ( - index < this._availableSlotsForThreadWindows && - index < this._threadWindows.length - ) { - var threadWindow = this._threadWindows[index]; - var cssProps = {bottom: 0}; - cssProps[_t.database.parameters.direction === 'rtl' ? 'left' : 'right'] = this.THREAD_WINDOW_WIDTH*index; - threadWindow.$el.css(cssProps); - threadWindow.do_show(); - index++; - } - }, - /** - * Update thread windows state of threads that have `message`. - * This is either a new message or an updated message. - * - * @private - * @param {mail.model.Message} message - * @param {Object} options - * @param {boolean} [options.keepBottom=false] if set, thread windows with - * this message should scroll to the bottom if their bottoms are currently - * visible. - * @param {boolean} [options.passively=false] if set, thread windows with - * this message become passive, so that they are marked as read only when - * the focus is on the thread window. - */ - _updateThreadWindowsFromMessage: function (message, options) { - _.each(this._threadWindows, function (threadWindow) { - if (_.contains(message.getThreadIDs(), threadWindow.getID())) { - threadWindow.update(options); - } - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Called when manually closing the blank thread window. - * - * @private - */ - _onCloseBlankThreadWindow: function () { - this._closeBlankThreadWindow(); - }, - /** - * @private - * @param {boolean} open - */ - _onDiscussOpen: function (open) { - this._super.apply(this, arguments); - - if (open) { - $(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window'); - } else { - $(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window'); - this._repositionThreadWindows(); - } - }, - /** - * Called when someone asks window manager whether the bottom of `thread` is - * visible or not. An object `query` is provided in order to reponse on the - * key `isVisible`. - * - * @private - * @param {mail.model.Thread} thread - * @param {Object} query - * @param {boolean} query.isVisible write on it - */ - _onIsThreadBottomVisible: function (thread, query) { - _.each(this._threadWindows, function (threadWindow) { - if ( - thread.getID() === threadWindow.getID() && - threadWindow.isAtBottom() && - !threadWindow.isHidden() - ) { - query.isVisible = true; - } - }); - }, - /** - * Show or hide window of this channel when a new channel is added. - * - * @private - * @param {mail.model.Channel} channel - * @param {Promise[]} proms used to synchronize async operations on the - * channel (like the rendering of its window) - */ - _onNewChannel: function (channel, proms) { - if (channel.isDetached()) { - if (!config.device.isMobile) { - var prom = this.openThreadWindow(channel.getID(), { - keepFoldState: true, - passively: true, - }); - proms.push(prom); - } - } else { - this._closeThreadWindow(channel.getID()); - } - }, - /** - * Update thread window containing this message when a new message is added. - * - * @private - * @param {Object} message - */ - _onNewMessage: function (message) { - this._updateThreadWindowsFromMessage(message, { keepBottom: true, passively: true }); - }, - /** - * Close the thread window when unsusbscribe from a channel. - * - * @private - * @param {integer} channelID - */ - _onUnsubscribeFromChannel: function (channelID) { - this._closeThreadWindow(channelID); - }, - /** - * Called when there is a change of the im status of the partner. - * The header of the thread window should be updated accordingly, - * in order to display the correct new im status of this users. - * - * @private - * @param {integer} partnerID - */ - _onUpdatedImStatus: function (partnerIDs) { - var self = this; - _.each(partnerIDs, function (partnerID) { - var thread = self.getDMChatFromPartnerID(partnerID); - if (! thread) { - return; - } - var threadWindow = self._getThreadWindow(thread.getID()); - if (!threadWindow) { - return; - } - threadWindow.renderHeader(); - }); - }, - /** - * @private - * @param {Object} data - * @param {integer} data.threadID - */ - _onUpdatedOutOfOffice: function (data) { - var threadWindow = this._getThreadWindow(data.threadID); - if (!threadWindow) { - return; - } - threadWindow.renderOutOfOffice(); - }, - /** - * Called when a thread has its unread counter that has changed. - * The unread counter on the thread windows should be updated. - * - * @private - * @param {mail.model.Thread} thread - */ - _onUpdateThreadUnreadCounter: function (thread) { - var self = this; - this._hiddenThreadWindowsUnreadCounter = 0; - _.each(this._threadWindows, function (threadWindow) { - if (thread.getID() === threadWindow.getID()) { - threadWindow.renderHeader(); - if (thread.getUnreadCounter() === 0) { - threadWindow.removePassive(); - } - } - if (threadWindow.isHidden()) { - self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter(); - } - }); - if (this._$hiddenThreadWindowsDropdown) { - this._$hiddenThreadWindowsDropdown.html( - this._renderHiddenThreadWindowsDropdown().html()); - this._repositionHiddenWindowsDropdown(); - } - }, - /** - * Called when a message has been updated. - * - * @private - * @param {Object} message - */ - _onUpdateMessage: function (message) { - this._updateThreadWindowsFromMessage(message, { keepBottom: false }); - }, - -}); - -return MailManager; - -}); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js index 5491c0f48a0..97b0c227523 100644 --- a/addons/mail/static/src/js/systray/systray_activity_menu.js +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -7,6 +7,8 @@ var SystrayMenu = require('web.SystrayMenu'); var Widget = require('web.Widget'); var QWeb = core.qweb; +const { Component } = owl; + /** * Menu item appended in the systray part of the navbar, redirects to the next * activities of all app @@ -22,7 +24,7 @@ var ActivityMenu = Widget.extend({ }, start: function () { this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items'); - this.call('mail_service', 'getMailBus').on('activity_updated', this, this._updateCounter); + Component.env.bus.on('activity_updated', this, this._updateCounter); this._updateCounter(); this._updateActivityPreview(); return this._super(); diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js deleted file mode 100644 index 88e339b32b0..00000000000 --- a/addons/mail/static/src/js/systray/systray_messaging_menu.js +++ /dev/null @@ -1,394 +0,0 @@ -odoo.define('mail.systray.MessagingMenu', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var SystrayMenu = require('web.SystrayMenu'); -var Widget = require('web.Widget'); -var QWeb = core.qweb; - -/** - * Menu item appended in the systray part of the navbar - * - * The menu item indicates the counter of needactions + unread messages in chat - * channels. When clicking on it, it toggles a dropdown containing a preview of - * each pinned channels (except mailbox and mass mailing channels) with a quick - * link to open them in chat windows. It also contains a direct link to the - * Inbox in Discuss. - **/ -var MessagingMenu = Widget.extend({ - name: 'messaging_menu', - template:'mail.systray.MessagingMenu', - events: { - 'click .o_mail_preview': '_onClickPreview', - 'click .o_filter_button': '_onClickFilterButton', - 'click .o_new_message': '_onClickNewMessage', - 'click .o_mail_preview_mark_as_read': '_onClickPreviewMarkAsRead', - 'click .o_thread_window_expand': '_onClickExpand', - 'show.bs.dropdown': '_onShowDropdown', - 'hide.bs.dropdown': '_onHideDropdown', - }, - /** - * @override - */ - start: function () { - this._$filterButtons = this.$('.o_filter_button'); - this._$previews = this.$('.o_mail_systray_dropdown_items'); - this._filter = false; - this._isMessagingReady = this.call('mail_service', 'isReady'); - this._updateCounter(); - var mailBus = this.call('mail_service', 'getMailBus'); - mailBus.on('messaging_ready', this, this._onMessagingReady); - mailBus.on('update_needaction', this, this._updateCounter); - mailBus.on('new_channel', this, this._updateCounter); - mailBus.on('update_thread_unread_counter', this, this._updateCounter); - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * States whether the widget is in mobile mode or not. - * This is used by the template. - * - * @returns {boolean} - */ - isMobile: function () { - return config.device.isMobile; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Called when clicking on a preview related to a mail failure - * - * @private - * @param {$.Element} $target DOM of preview element clicked - */ - _clickMailFailurePreview: function ($target) { - var documentID = $target.data('document-id'); - var documentModel = $target.data('document-model'); - if (documentModel && documentID) { - this._openDocument(documentModel, documentID); - } else if (documentModel !== 'mail.channel') { - // preview of mail failures grouped to different document of same model - this.do_action({ - name: "Mail failures", - type: 'ir.actions.act_window', - view_mode: 'kanban,list,form', - views: [[false, 'kanban'], [false, 'list'], [false, 'form']], - target: 'current', - res_model: documentModel, - domain: [['message_has_error', '=', true]], - }); - } - }, - /** - * Compute the counter next to the systray messaging menu. This counter is - * the sum of unread messages in channels, the counter of the mailbox inbox, - * and the amount of mail failures. - * - * @private - * @returns {integer} - */ - _computeCounter: function () { - var channels = this.call('mail_service', 'getChannels'); - var channelUnreadCounters = _.map(channels, function (channel) { - return channel.getUnreadCounter(); - }); - var unreadChannelCounter = _.reduce(channelUnreadCounters, function (acc, c) { - return c > 0 ? acc + 1 : acc; - }, 0); - var inboxCounter = this.call('mail_service', 'getMailbox', 'inbox').getMailboxCounter(); - var mailFailureCounter = this.call('mail_service', 'getMailFailures').length; - - return unreadChannelCounter + inboxCounter + mailFailureCounter; - }, - /** - * @private - * @returns {Promise<Object[]>} resolved with list of previews that are - * compatible with the 'mail.Preview' template. - */ - _getPreviews: function () { - return this.call('mail_service', 'getSystrayPreviews', this._filter); - }, - /** - * @private - * @return {boolean} whether the messaging menu is shown or not. - */ - _isShown: function () { - return this.$el.hasClass('show'); - }, - /** - * Process Preview Mark As Read - * - * @private - * @param {Element} $preview - */ - _markAsRead: function ($preview) { - var previewID = $preview.data('preview-id'); - if (previewID === 'mailbox_inbox') { - var messageIDs = $preview.data('message-ids'); - - if (typeof messageIDs === 'string') { - messageIDs = messageIDs.split(',').map(id => Number(id)); - } else { - messageIDs = [$preview.data('message-ids')]; - } - - this.call('mail_service', 'markMessagesAsRead', messageIDs); - } else if (previewID === 'mail_failure') { - var documentModel = $preview.data('document-model'); - var unreadCounter = $preview.data('unread-counter'); - this.do_action('mail.mail_resend_cancel_action', { - additional_context: { - default_model: documentModel, - unread_counter: unreadCounter - } - }); - } else { - // this is mark as read on a thread - var thread = this.call('mail_service', 'getThread', previewID); - if (thread) { - thread.markAsRead(); - } - } - }, - /** - * Open discuss - * - * @private - * @param {integer} [channelID] if set, auto-select this channel when - * opening the discuss app. - */ - _openDiscuss: function (channelID) { - var self = this; - var discussOptions = { clear_breadcrumbs: true }; - - if (channelID) { - discussOptions.active_id = channelID; - } - - this.do_action('mail.action_discuss', discussOptions) - .then(function () { - // we cannot 'go back to previous page' otherwise - self.trigger_up('hide_home_menu'); - core.bus.trigger('change_menu_section', - self.call('mail_service', 'getDiscussMenuID')); - }); - }, - /** - * Open the document - * - * @private - * @param {string} documentModel the model of the document - * @param {integer} documentID - */ - _openDocument: function (documentModel, documentID) { - if (documentModel === 'mail.channel') { - this._openDiscuss(documentID); - } else { - this.do_action({ - type: 'ir.actions.act_window', - res_model: documentModel, - views: [[false, 'form']], - res_id: documentID - }); - } - }, - /** - * Render the list of conversation previews - * - * @private - * @param {Object} previews list of valid objects for preview rendering - * (see mail.Preview template) - */ - _renderPreviews: function (previews) { - this._$previews.html(QWeb.render('mail.systray.MessagingMenu.Previews', { - previews: previews, - })); - }, - /** - * Get and render list of previews, based on the selected filter - * - * preview shows the last message of a channel with inline format. - * There is a hack where filter "All" also shows preview of chatter - * messages (which are not channels). - * - * List of filters: - * - * 1. All - * - filter: undefined - * - previews: last messages of all non-mailbox channels, in addition - * to last messages of chatter (get from inbox) - * - * 2. Channel - * - filter: "Channels" - * - previews: last messages of all non-mailbox and non-DM channels - * - * 3. Chat - * - filter: "Chat" - * - previews: last messages of all DM channels - * - * @private - */ - _updatePreviews: function () { - // Display spinner while waiting for conversations preview - this._$previews.html(QWeb.render('Spinner')); - if (!this._isMessagingReady) { - return; - } - this._getPreviews() - .then(this._renderPreviews.bind(this)); - }, - /** - * Update the counter on the systray messaging menu icon. - * The counter display the number of unread messages in channels (DM included), the number of - * messages in Inbox mailbox, and the number of mail failures. - * Also updates the previews if the messaging menu is open. - * - * Note that the number of unread messages in document thread are ignored, because they are - * already considered in the number of messages in Inbox with the current design. - * Also, some unread messages in channel can also be in inbox, so they are considered twice in - * the counter. This is intended, as the number of needaction messages in a channel are - * separately considered in the messaging menu from the unread messages, even though a message - * can be both unread and needaction (such a message increments the counter twice). - * - * The global counter of the messaging menu should match the counter next to each of the preview - * item when the messaging menu is open. - * - * @private - */ - _updateCounter: function () { - if (!this._isMessagingReady) { - return; - } - var counter = this._computeCounter(); - this.$('.o_mail_messaging_menu_icon').removeClass('fa-spinner fa-spin'); - this.$('.o_notification_counter').text(counter); - this.$el.toggleClass('o_no_notification', !counter); - if (this._isShown()) { - this._updatePreviews(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onShowDropdown: function () { - document.body.classList.add('modal-open'); - this._updatePreviews(); - }, - /** - * @private - */ - _onHideDropdown: function () { - document.body.classList.remove('modal-open'); - }, - /** - * Opens the related document - * - * @private - * @param {MouseEvent} ev - */ - _onClickExpand: function (ev) { - ev.stopPropagation(); - var $preview = $(ev.currentTarget).closest('.o_mail_preview'); - var documentModel = $preview.data('document-model'); - var documentID = $preview.data('document-id'); - this._openDocument(documentModel, documentID); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickFilterButton: function (ev) { - ev.stopPropagation(); - this._$filterButtons.removeClass('active'); - var $target = $(ev.currentTarget); - $target.addClass('active'); - this._filter = $target.data('filter'); - this._updatePreviews(); - }, - /** - * @private - */ - _onClickNewMessage: function () { - this.call('mail_service', 'openBlankThreadWindow'); - }, - /** - * When a preview is clicked on, we want to open the related object - * (thread, mail failure, etc.) - * - * @private - * @param {MouseEvent} ev - */ - _onClickPreview: function (ev) { - var $target = $(ev.currentTarget); - var previewID = $target.data('preview-id'); - - if (previewID === 'mail_failure') { - this._clickMailFailurePreview($target); - } else if (previewID === 'mailbox_inbox') { - // inbox preview for non-document thread, - // e.g. needaction message of channel - var documentID = $target.data('document-id'); - var documentModel = $target.data('document-model'); - if (!documentModel) { - this._openDiscuss('mailbox_inbox'); - } else { - this._openDocument(documentModel, documentID); - } - } else { - // preview of thread - this.call('mail_service', 'openThread', previewID); - } - }, - /** - * When a preview "Mark as read" button is clicked on, we want mark message - * as read - * - * @private - * @param {MouseEvent} ev - */ - _onClickPreviewMarkAsRead: function (ev) { - ev.stopPropagation(); - var $preview = $(ev.currentTarget).closest('.o_mail_preview'); - this._markAsRead($preview); - }, - /** - * @private - */ - _onMessagingReady: function () { - if (this._isMessagingReady) { - return; - } - this._isMessagingReady = true; - this._updateCounter(); - }, -}); - -// Systray menu items display order matches order in the list -// lower index comes first, and display is from right to left. -// For messagin menu, it should come before activity menu, if any -// otherwise, it is the next systray item. -var activityMenuIndex = _.findIndex(SystrayMenu.Items, function (SystrayMenuItem) { - return SystrayMenuItem.prototype.name === 'activity_menu'; -}); -if (activityMenuIndex > 0) { - SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu); -} else { - SystrayMenu.Items.push(MessagingMenu); -} - -return MessagingMenu; - -}); diff --git a/addons/mail/static/src/js/thread_field.js b/addons/mail/static/src/js/thread_field.js deleted file mode 100644 index a34582c1dde..00000000000 --- a/addons/mail/static/src/js/thread_field.js +++ /dev/null @@ -1,265 +0,0 @@ -odoo.define('mail.ThreadField', function (require) { -"use strict"; - -var CreateModeDocumentThread = require('mail.model.CreateModeDocumentThread'); -var ThreadWidget = require('mail.widget.Thread'); - -var AbstractField = require('web.AbstractField'); -var core = require('web.core'); -var field_registry = require('web.field_registry'); -var concurrency = require('web.concurrency'); - -var _t = core._t; - -/** - * 'mail_thread' widget: displays the thread of messages - */ -var ThreadField = AbstractField.extend({ - /** - * @override - */ - init: function () { - this._super.apply(this, arguments); - this._isMessagingReady = this.call('mail_service', 'isReady'); - this._isStarted = false; - this.call('mail_service', 'getMailBus').on('messaging_ready', this, this._onMessagingReady); - // Used to automatically mark document thread as read at the moment we - // access the document and render the thread. - this._markAsReadOnNextRender = false; - this._setDocumentThread(); - }, - /** - * @override - */ - start: function () { - var self = this; - - this._isStarted = true; - this.dp = new concurrency.DropPrevious(); - - this._threadWidget = new ThreadWidget(this, { - displayOrder: ThreadWidget.ORDER.DESC, - displayDocumentLinks: false, - displayMarkAsRead: false, - hasMessageAttachmentDeletable: true, - squashCloseMessages: false, - }); - - this._threadWidget.on('load_more_messages', this, this._onLoadMoreMessages); - this._threadWidget.on('redirect', this, this._onRedirect); - this._threadWidget.on('redirect_to_channel', this, this._onRedirectToChannel); - this._threadWidget.on('toggle_star_status', this, function (messageID) { - var message = self.call('mail_service', 'getMessage', messageID); - message.toggleStarStatus(); - }); - - var def1 = this._threadWidget.appendTo(this.$el); - var def2 = this._super.apply(this, arguments); - - return this.alive(Promise.all([def1, def2])).then(function () { - // unwrap the thread to remove an unnecessary level on div - self.setElement(self._threadWidget.$el); - var mailBus = self.call('mail_service', 'getMailBus'); - mailBus.on('new_message', self, self._onNewMessage); - mailBus.on('update_message', self, self._onUpdateMessage); - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {integer[]} attachmentIDs - */ - removeAttachments: function (attachmentIDs) { - this._documentThread.removeAttachmentsFromMessages(attachmentIDs); - }, - /** - * @override - * @return {boolean} - */ - isSet: function () { - return true; - }, - /** - * @param {Object} message - * @param {integer[]} message.partner_ids - * @return {Promise} - */ - postMessage: function (message) { - var self = this; - return this._documentThread.postMessage(message) - .then(function () { - if (message.partner_ids.length) { - self.trigger_up('reload_mail_fields', { followers: true }); - } - }).guardedCatch(function () { - self.do_notify(false, _t("Message not sent")); - return Promise.reject(); - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Object} [options] - * @param {boolean} [options.forceFetch] - * @return {Promise} - */ - _fetchAndRenderThread: function (options) { - var self = this; - if (!this._isMessagingReady) { - self._threadWidget.renderLoading(); - return Promise.resolve(); - } else if (!this._documentThread) { - var thread = new CreateModeDocumentThread(); - options = { isCreateMode: true }; - self._threadWidget.render(thread, options); - return Promise.resolve(); - } else { - var fetchDef = this.dp.add(this._documentThread.fetchMessages(options)); - return fetchDef.then(function () { - self._threadWidget.render(self._documentThread, { - displayLoadMore: self._documentThread.getMessages().length < self._documentThread.getMessageIDs().length, - }); - if (self._markAsReadOnNextRender) { - self._markAsReadOnNextRender = false; - return self._documentThread.markAsRead(); - } - }); - } - }, - /** - * @override - * @private - * @returns {Promise} - */ - _render: function () { - return this._fetchAndRenderThread(); - }, - /** - * The mail widget being persistent, one needs to update the res_id and - * to set the correct DocumentThread on reset. - * - * @override - * @private - * @param {any} record - */ - _reset: function (record) { - this._super.apply(this, arguments); - this.res_id = record.res_id; - this._setDocumentThread(); - }, - /** - * Sets this._documentThread, the DocumentThread associated with the current - * model and resID. - * - * If it is a new document in create mode, unset the document thread. - */ - _setDocumentThread: function () { - if (!this._isMessagingReady) { - return; - } - var params = { - messageIDs: this.value.res_ids, - name: this.recordData.display_name, - resID: this.res_id, - resModel: this.model, - }; - if (!params.resID) { - this._documentThread = null; - } else { - this._documentThread = this.call('mail_service', 'getOrAddDocumentThread', params); - this._markAsReadOnNextRender = true; - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * When a new message arrives, fetch its data to render it - * - * @private - */ - _onLoadMoreMessages: function () { - this._fetchAndRenderThread({ forceFetch: true }); - }, - /** - * @private - */ - _onMessagingReady: function () { - if (this._isMessagingReady) { - return; - } - this._isMessagingReady = true; - if (!this._isStarted) { - return; - } - this._setDocumentThread(); - this._render(); - }, - /** - * @private - * @param {mail.model.Message} - */ - _onNewMessage: function (message) { - if ( - message.isLinkedToDocumentThread() && - message.getDocumentModel() === this.model && - message.getDocumentID() === this.res_id - ) { - this.trigger_up('new_message', { - id: this.value.id, - messageIDs: this._documentThread.getMessageIDs(), - }); - this._fetchAndRenderThread(); - } - }, - /** - * @private - * @param {integer} channelID - */ - _onRedirectToChannel: function (channelID) { - var self = this; - this.call('mail_service', 'joinChannel', channelID).then(function () { - // Execute Discuss with 'channel' as default channel - self.do_action('mail.action_discuss', { active_id: channelID }); - }); - }, - /** - * @private - * @param {string} resModel - * @param {integer} resID - */ - _onRedirect: function (resModel, resID) { - this.trigger_up('redirect', { - res_id: resID, - res_model: resModel, - }); - }, - /** - * @private - * @param {mail.model.Message} - */ - _onUpdateMessage: function (message) { - if ( - message.isLinkedToDocumentThread() && - message.getDocumentModel() === this.model && - message.getDocumentID() === this.res_id - ) { - this._fetchAndRenderThread(); - } - }, -}); - -field_registry.add('mail_thread', ThreadField); - -return ThreadField; - -}); diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js deleted file mode 100644 index 583d14e949a..00000000000 --- a/addons/mail/static/src/js/thread_widget.js +++ /dev/null @@ -1,686 +0,0 @@ -odoo.define('mail.widget.Thread', function (require) { -"use strict"; - -var DocumentViewer = require('mail.DocumentViewer'); -var mailUtils = require('mail.utils'); - -var core = require('web.core'); -var time = require('web.time'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; -var _lt = core._lt; - -var ORDER = { - ASC: 1, // visually, ascending order of message IDs (from top to bottom) - DESC: -1, // visually, descending order of message IDs (from top to bottom) -}; - -var READ_MORE = _lt("read more"); -var READ_LESS = _lt("read less"); - -/** - * This is a generic widget to render a thread. - * Any thread that extends mail.model.AbstractThread can be used with this - * widget. - */ -var ThreadWidget = Widget.extend({ - className: 'o_mail_thread', - - events: { - 'click a': '_onClickRedirect', - 'click img': '_onClickRedirect', - 'click strong': '_onClickRedirect', - 'click .o_thread_show_more': '_onClickShowMore', - 'click .o_attachment_download': '_onAttachmentDownload', - 'click .o_attachment_view': '_onAttachmentView', - 'click .o_attachment_delete_cross': '_onDeleteAttachment', - 'click .o_thread_message_needaction': '_onClickMessageNeedaction', - 'click .o_thread_message_star': '_onClickMessageStar', - 'click .o_thread_message_reply': '_onClickMessageReply', - 'click .oe_mail_expand': '_onClickMailExpand', - 'click .o_thread_message': '_onClickMessage', - 'click': '_onClick', - 'click .o_thread_message_notification_error': '_onClickMessageNotificationError', - 'click .o_thread_message_moderation': '_onClickMessageModeration', - 'change .moderation_checkbox': '_onChangeModerationCheckbox', - }, - - /** - * @override - * @param {widget} parent - * @param {Object} options - */ - init: function (parent, options) { - this._super.apply(this, arguments); - this.attachments = []; - // options when the thread is enabled (e.g. can send message, - // interact on messages, etc.) - this._enabledOptions = _.defaults(options || {}, { - displayOrder: ORDER.ASC, - displayMarkAsRead: true, - displayModerationCommands: false, - displayStars: true, - displayDocumentLinks: true, - displayAvatars: true, - squashCloseMessages: true, - displayNotificationIcons: true, - displayReplyIcons: false, - loadMoreOnScroll: false, - hasMessageAttachmentDeletable: false, - }); - // options when the thread is disabled - this._disabledOptions = { - displayOrder: this._enabledOptions.displayOrder, - displayMarkAsRead: false, - displayModerationCommands: false, - displayStars: false, - displayDocumentLinks: false, - displayAvatars: this._enabledOptions.displayAvatars, - squashCloseMessages: false, - displayNotificationIcons: false, - displayReplyIcons: false, - loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, - hasMessageAttachmentDeletable: false, - }; - this._selectedMessageID = null; - this._currentThreadID = null; - this._messageMailPopover = null; - this._messageSeenPopover = null; - // used to track popover IDs to destroy on re-rendering of popovers - this._openedSeenPopoverIDs = []; - }, - /** - * The message mail popover may still be shown at this moment. If we do not - * remove it, it stays visible on the page until a page reload. - * - * @override - */ - destroy: function () { - clearInterval(this._updateTimestampsInterval); - if (this._messageMailPopover) { - this._messageMailPopover.popover('hide'); - } - if (this._messageSeenPopover) { - this._messageSeenPopover.popover('hide'); - } - this._destroyOpenSeenPopoverIDs(); - this._super(); - }, - /** - * @param {mail.model.AbstractThread} thread the thread to render. - * @param {Object} [options] - * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying - * messages in the thread: - * - ORDER.ASC: last message is at the bottom of the thread - * - ORDER.DESC: last message is at the top of the thread - * @param {boolean} [options.displayLoadMore] - * @param {Array} [options.domain=[]] the domain for the messages in the - * thread. - * @param {boolean} [options.isCreateMode] - * @param {boolean} [options.scrollToBottom=false] - * @param {boolean} [options.squashCloseMessages] - */ - render: function (thread, options) { - var self = this; - - var shouldScrollToBottomAfterRendering = false; - if (this._currentThreadID === thread.getID() && this.isAtBottom()) { - shouldScrollToBottomAfterRendering = true; - } - this._currentThreadID = thread.getID(); - - // copy so that reverse do not alter order in the thread object - var messages = _.clone(thread.getMessages({ domain: options.domain || [] })); - - var modeOptions = options.isCreateMode ? this._disabledOptions : - this._enabledOptions; - - // attachments ordered by messages order (increasing ID) - this.attachments = _.uniq(_.flatten(_.map(messages, function (message) { - return message.getAttachments(); - }))); - - options = _.extend({}, modeOptions, options, { - selectedMessageID: this._selectedMessageID, - }); - - // dict where key is message ID, and value is whether it should display - // the author of message or not visually - var displayAuthorMessages = {}; - - // Hide avatar and info of a message if that message and the previous - // one are both comments wrote by the same author at the same minute - // and in the same document (users can now post message in documents - // directly from a channel that follows it) - var prevMessage; - _.each(messages, function (message) { - if ( - // is first message of thread - !prevMessage || - // more than 1 min. elasped - (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) || - prevMessage.getType() !== 'comment' || - message.getType() !== 'comment' || - // from a different author - (prevMessage.getAuthorID() !== message.getAuthorID()) || - ( - // messages are linked to a document thread - ( - prevMessage.isLinkedToDocumentThread() && - message.isLinkedToDocumentThread() - ) && - ( - // are from different documents - prevMessage.getDocumentModel() !== message.getDocumentModel() || - prevMessage.getDocumentID() !== message.getDocumentID() - ) - ) - ) { - displayAuthorMessages[message.getID()] = true; - } else { - displayAuthorMessages[message.getID()] = !options.squashCloseMessages; - } - prevMessage = message; - }); - - if (modeOptions.displayOrder === ORDER.DESC) { - messages.reverse(); - } - - this.$el.html(QWeb.render('mail.widget.Thread', { - thread: thread, - displayAuthorMessages: displayAuthorMessages, - options: options, - ORDER: ORDER, - dateFormat: time.getLangDatetimeFormat(), - })); - - _.each(messages, function (message) { - var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]'); - $message.find('.o_mail_timestamp').data('date', message.getDate()); - - self._insertReadMore($message); - }); - - if (shouldScrollToBottomAfterRendering) { - this.scrollToBottom(); - } - - if (!this._updateTimestampsInterval) { - this.updateTimestampsInterval = setInterval(function () { - self._updateTimestamps(); - }, 1000*60); - } - - this._renderMessageNotificationPopover(messages); - if (thread.hasSeenFeature()) { - this._renderMessageSeenPopover(thread, messages); - } - }, - - /** - * Render thread widget when loading, i.e. when messaging is not yet ready. - * @see /mail/init_messaging - */ - renderLoading: function () { - this.$el.html(QWeb.render('mail.widget.ThreadLoading')); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - getScrolltop: function () { - return this.$el.scrollTop(); - }, - /** - * State whether the bottom of the thread is visible or not, - * with a tolerance of 5 pixels - * - * @return {boolean} - */ - isAtBottom: function () { - var fullHeight = this.el.scrollHeight; - var topHiddenHeight = this.$el.scrollTop(); - var visibleHeight = this.$el.outerHeight(); - var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight; - return bottomHiddenHeight < 5; - }, - /** - * Removes a message and re-renders the thread - * - * @param {integer} [messageID] the id of the removed message - * @param {mail.model.AbstractThread} thread the thread which contains - * updated list of messages (so it does not contain any message with ID - * `messageID`). - * @param {Object} [options] options for the thread rendering - */ - removeMessageAndRender: function (messageID, thread, options) { - var self = this; - this._currentThreadID = thread.getID(); - return new Promise(function (resolve, reject) { - self.$('.o_thread_message[data-message-id="' + messageID + '"]') - .fadeOut({ - done: function () { - if (self._currentThreadID === thread.getID()) { - self.render(thread, options); - } - resolve(); - }, - duration: 200, - }); - }); - }, - /** - * Scroll to the bottom of the thread - */ - scrollToBottom: function () { - this.$el.scrollTop(this.el.scrollHeight); - }, - /** - * Scrolls the thread to a given message - * - * @param {integer} options.msgID the ID of the message to scroll to - * @param {integer} [options.duration] - * @param {boolean} [options.onlyIfNecessary] - */ - scrollToMessage: function (options) { - var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]'); - if (options.onlyIfNecessary) { - var delta = $target.parent().height() - $target.height(); - var offset = delta < 0 ? - 0 : - delta - ($target.offset().top - $target.offsetParent().offset().top); - offset = - Math.min(offset, 0); - this.$el.scrollTo("+=" + offset + "px", options.duration); - } else if ($target.length) { - this.$el.scrollTo($target); - } - }, - /** - * Scroll to the specific position in pixel - * - * If no position is provided, scroll to the bottom of the thread - * - * @param {integer} [position] distance from top to position in pixels. - * If not provided, scroll to the bottom. - */ - scrollToPosition: function (position) { - if (position) { - this.$el.scrollTop(position); - } else { - this.scrollToBottom(); - } - }, - /** - * Toggle all the moderation checkboxes in the thread - * - * @param {boolean} checked if true, check the boxes, - * otherwise uncheck them. - */ - toggleModerationCheckboxes: function (checked) { - this.$('.moderation_checkbox').prop('checked', checked); - }, - /** - * Unselect the selected message - */ - unselectMessage: function () { - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this._selectedMessageID = null; - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _destroyOpenSeenPopoverIDs: function () { - _.each(this._openedSeenPopoverIDs, function (popoverID) { - $('#' + popoverID).remove(); - }); - this._openedSeenPopoverIDs = []; - }, - /** - * Modifies $element to add the 'read more/read less' functionality - * All element nodes with 'data-o-mail-quote' attribute are concerned. - * All text nodes after a ``#stopSpelling`` element are concerned. - * Those text nodes need to be wrapped in a span (toggle functionality). - * All consecutive elements are joined in one 'read more/read less'. - * - * @private - * @param {jQuery} $element - */ - _insertReadMore: function ($element) { - var self = this; - - var groups = []; - var readMoreNodes; - - // nodeType 1: element_node - // nodeType 3: text_node - var $children = $element.contents() - .filter(function () { - return this.nodeType === 1 || - this.nodeType === 3 && - this.nodeValue.trim(); - }); - - _.each($children, function (child) { - var $child = $(child); - - // Hide Text nodes if "stopSpelling" - if ( - child.nodeType === 3 && - $child.prevAll('[id*="stopSpelling"]').length > 0 - ) { - // Convert Text nodes to Element nodes - $child = $('<span>', { - text: child.textContent, - 'data-o-mail-quote': '1', - }); - child.parentNode.replaceChild($child[0], child); - } - - // Create array for each 'read more' with nodes to toggle - if ( - $child.attr('data-o-mail-quote') || - ( - $child.get(0).nodeName === 'BR' && - $child.prev('[data-o-mail-quote="1"]').length > 0 - ) - ) { - if (!readMoreNodes) { - readMoreNodes = []; - groups.push(readMoreNodes); - } - $child.hide(); - readMoreNodes.push($child); - } else { - readMoreNodes = undefined; - self._insertReadMore($child); - } - }); - - _.each(groups, function (group) { - // Insert link just before the first node - var $readMore = $('<a>', { - class: 'o_mail_read_more', - href: '#', - text: READ_MORE, - }).insertBefore(group[0]); - - // Toggle All next nodes - var isReadMore = true; - $readMore.click(function (e) { - e.preventDefault(); - isReadMore = !isReadMore; - _.each(group, function ($child) { - $child.hide(); - $child.toggle(!isReadMore); - }); - $readMore.text(isReadMore ? READ_MORE : READ_LESS); - }); - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onDeleteAttachment: function (ev) { - ev.stopPropagation(); - var $target = $(ev.currentTarget); - this.trigger_up('delete_attachment', { - attachmentId: $target.data('id'), - attachmentName: $target.data('name') - }); - }, - /** - * @private - * @param {Object} options - * @param {integer} [options.channelID] - * @param {string} options.model - * @param {integer} options.id - */ - _redirect: _.debounce(function (options) { - if ('channelID' in options) { - this.trigger('redirect_to_channel', options.channelID); - } else { - this.trigger('redirect', options.model, options.id); - } - }, 500, true), - /** - * Render the popover when mouse-hovering on the notification icon of a - * message in the thread. - * There is at most one such popover at any given time. - * - * @private - * @param {mail.model.AbstractMessage[]} messages list of messages in the - * rendered thread, for which popover on mouseover interaction is - * permitted. - */ - _renderMessageNotificationPopover(messages) { - if (this._messageMailPopover) { - this._messageMailPopover.popover('hide'); - } - if (!this.$('.o_thread_tooltip').length) { - return; - } - this._messageMailPopover = this.$('.o_thread_tooltip').popover({ - html: true, - boundary: 'viewport', - placement: 'auto', - trigger: 'hover', - offset: '0, 1', - content: function () { - var messageID = $(this).data('message-id'); - var message = _.find(messages, function (message) { - return message.getID() === messageID; - }); - return QWeb.render('mail.widget.Thread.Message.MailTooltip', { - notifications: message.getNotifications(), - }); - }, - }); - }, - /** - * Render the popover when mouse hovering on the seen icon of a message - * in the thread. Only seen icons in non-squashed message have popover, - * because squashed messages hides this icon on message mouseover. - * - * @private - * @param {mail.model.AbstractThread} thread with thread seen mixin, - * @see {mail.model.ThreadSeenMixin} - * @param {mail.model.Message[]} messages list of messages in the - * rendered thread. - */ - _renderMessageSeenPopover: function (thread, messages) { - var self = this; - this._destroyOpenSeenPopoverIDs(); - if (this._messageSeenPopover) { - this._messageSeenPopover.popover('hide'); - } - if (!this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').length) { - return; - } - this._messageSeenPopover = this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').popover({ - html: true, - boundary: 'viewport', - placement: 'auto', - trigger: 'hover', - offset: '0, 1', - content: function () { - var $this = $(this); - self._openedSeenPopoverIDs.push($this.attr('aria-describedby')); - var messageID = $this.data('message-id'); - var message = _.find(messages, function (message) { - return message.getID() === messageID; - }); - return QWeb.render('mail.widget.Thread.Message.SeenIconPopoverContent', { - thread: thread, - message: message, - }); - }, - }); - }, - /** - * @private - */ - _updateTimestamps: function () { - var isAtBottom = this.isAtBottom(); - this.$('.o_mail_timestamp').each(function () { - var date = $(this).data('date'); - $(this).html(mailUtils.timeFromNow(date)); - }); - if (isAtBottom && !this.isAtBottom()) { - this.scrollToBottom(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} event - */ - _onAttachmentDownload: function (event) { - event.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onAttachmentView: function (event) { - event.stopPropagation(); - var activeAttachmentID = $(event.currentTarget).data('id'); - if (activeAttachmentID) { - var attachmentViewer = new DocumentViewer(this, this.attachments, activeAttachmentID); - attachmentViewer.appendTo($('body')); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onChangeModerationCheckbox: function (ev) { - this.trigger_up('update_moderation_buttons'); - }, - /** - * @private - */ - _onClick: function () { - if (this._selectedMessageID) { - this.unselectMessage(); - this.trigger('unselect_message'); - } - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMailExpand: function (ev) { - ev.preventDefault(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessage: function (ev) { - $(ev.currentTarget).toggleClass('o_thread_selected_message'); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageNeedaction: function (ev) { - var messageID = $(ev.currentTarget).data('message-id'); - this.trigger('mark_as_read', messageID); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageNotificationError(ev) { - const messageID = $(ev.currentTarget).data('message-id'); - this.do_action('mail.mail_resend_message_action', { - additional_context: { - mail_message_to_resend: messageID, - } - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageReply: function (ev) { - this._selectedMessageID = $(ev.currentTarget).data('message-id'); - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]') - .addClass('o_thread_selected_message'); - this.trigger('select_message', this._selectedMessageID); - ev.stopPropagation(); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageStar: function (ev) { - var messageID = $(ev.currentTarget).data('message-id'); - this.trigger('toggle_star_status', messageID); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMessageModeration: function (ev) { - var $button = $(ev.currentTarget); - var messageID = $button.data('message-id'); - var decision = $button.data('decision'); - this.trigger_up('message_moderation', { - messageID: messageID, - decision: decision, - }); - }, - /** - * @private - * @param {MouseEvent} ev - */ - _onClickRedirect: function (ev) { - // ignore inherited branding - if ($(ev.target).data('oe-field') !== undefined) { - return; - } - var id = $(ev.target).data('oe-id'); - if (id) { - ev.preventDefault(); - var model = $(ev.target).data('oe-model'); - var options; - if (model && (model !== 'mail.channel')) { - options = { - model: model, - id: id - }; - } else { - options = { channelID: id }; - } - this._redirect(options); - } - }, - /** - * @private - */ - _onClickShowMore: function () { - this.trigger('load_more_messages'); - }, -}); - -ThreadWidget.ORDER = ORDER; - -return ThreadWidget; - -}); diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js deleted file mode 100644 index 7e39f9c7e2e..00000000000 --- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js +++ /dev/null @@ -1,519 +0,0 @@ -odoo.define('mail.AbstractThreadWindow', function (require) { -"use strict"; - -var ThreadWidget = require('mail.widget.Thread'); - -var config = require('web.config'); -var core = require('web.core'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -/** - * This is an abstract widget for rendering thread windows. - * - * It contains logic that are shared between mail.ThreadWindow and - * im_livechat.WebsiteLivechatWindow. - * - * The reason for having two different implementation of thread windows is - * that mail.ThreadWindow makes use of mail.Manager, which is used in the - * backend, while im_livechat.WebsiteLivechatWindow must work without this - * mail service. - */ -var AbstractThreadWindow = Widget.extend({ - template: 'mail.AbstractThreadWindow', - custom_events: { - escape_pressed: '_onEscapePressed', - document_viewer_closed: '_onDocumentViewerClose', - }, - events: { - 'click .o_thread_window_close': '_onClickClose', - 'click .o_thread_window_title': '_onClickFold', - 'click .o_composer_text_field': '_onComposerClick', - 'click .o_mail_thread': '_onThreadWindowClicked', - 'keydown .o_composer_text_field': '_onKeydown', - 'keypress .o_composer_text_field': '_onKeypress', - }, - FOLD_ANIMATION_DURATION: 200, // duration in ms for (un)fold transition - HEIGHT_OPEN: '400px', // height in px of thread window when open - HEIGHT_FOLDED: '34px', // height, in px, of thread window when folded - /** - * Children of this class must make use of `thread`, which is an object that - * represent the thread that is linked to this thread window. - * - * If no thread is provided, this will represent the "blank" thread window. - * - * @abstract - * @param {Widget} parent - * @param {mail.model.AbstractThread} [thread=null] the thread that this - * thread window is linked to. If not set, it is the "blank" thread - * window. - * @param {Object} [options={}] - * @param {mail.model.AbstractThread} [options.thread] - */ - init: function (parent, thread, options) { - this._super(parent); - - this.options = _.defaults(options || {}, { - autofocus: true, - displayStars: true, - displayReplyIcons: false, - displayNotificationIcons: false, - placeholder: _t("Say something"), - }); - - this._hidden = false; - this._thread = thread || null; - - this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100); - - if (!this.hasThread()) { - // internal fold state of thread window without any thread - this._folded = false; - } - }, - start: function () { - var self = this; - this.$input = this.$('.o_composer_text_field'); - this.$header = this.$('.o_thread_window_header'); - var options = { - displayMarkAsRead: false, - displayStars: this.options.displayStars, - }; - if (this._thread && this._thread._type === 'document_thread') { - options.displayDocumentLinks = false; - } - this._threadWidget = new ThreadWidget(this, options); - - // animate the (un)folding of thread windows - this.$el.css({transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear'}); - if (this.isFolded()) { - this.$el.css('height', this.HEIGHT_FOLDED); - } else if (this.options.autofocus) { - this._focusInput(); - } - if (!config.device.isMobile) { - var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right"; - this.$el.css(margin_dir, $.position.scrollbarWidth()); - } - var def = this._threadWidget.replace(this.$('.o_thread_window_content')).then(function () { - self._threadWidget.$el.on('scroll', self, self._debouncedOnScroll); - }); - return Promise.all([this._super(), def]); - }, - /** - * @override - */ - do_hide: function () { - this._hidden = true; - this._super.apply(this, arguments); - }, - /** - * @override - */ - do_show: function () { - this._hidden = false; - this._super.apply(this, arguments); - }, - /** - * @override - */ - do_toggle: function (display) { - this._hidden = _.isBoolean(display) ? !display : !this._hidden; - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Close this window - * - * @abstract - */ - close: function () {}, - /** - * Get the ID of the thread window, which is equivalent to the ID of the - * thread related to this window - * - * @returns {integer|string} - */ - getID: function () { - return this._getThreadID(); - }, - /** - * @returns {mail.model.Thread|undefined} - */ - getThread: function () { - if (!this.hasThread()) { - return undefined; - } - return this._thread; - }, - /** - * Get the status of the thread, such as the im status of a DM chat - * ('online', 'offline', etc.). If this window has no thread, returns - * `undefined`. - * - * @returns {string|undefined} - */ - getThreadStatus: function () { - if (!this.hasThread()) { - return undefined; - } - return this._thread.getStatus(); - }, - /** - * Get the title of the thread window, which usually contains the name of - * the thread. - * - * @returns {string} - */ - getTitle: function () { - if (!this.hasThread()) { - return _t("Undefined"); - } - return this._thread.getTitle(); - }, - /** - * Get the unread counter of the related thread. If there are no thread - * linked to this window, returns 0. - * - * @returns {integer} - */ - getUnreadCounter: function () { - if (!this.hasThread()) { - return 0; - } - return this._thread.getUnreadCounter(); - }, - /** - * States whether this thread window is related to a thread or not. - * - * This is useful in order to provide specific behaviour for thread windows - * without any thread, e.g. let them open a thread from this "blank" thread - * window. - * - * @returns {boolean} - */ - hasThread: function () { - return !! this._thread; - }, - /** - * Tells whether the bottom of the thread in the thread window is visible - * or not. - * - * @returns {boolean} - */ - isAtBottom: function () { - return this._threadWidget.isAtBottom(); - }, - /** - * State whether the related thread is folded or not. If there are no - * thread related to this window, it means this is the "blank" thread - * window, therefore we use the internal folded state. - * - * @returns {boolean} - */ - isFolded: function () { - if (!this.hasThread()) { - return this._folded; - } - return this._thread.isFolded(); - }, - /** - * States whether the current environment is in mobile or not. This is - * useful in order to customize the template rendering for mobile view. - * - * @returns {boolean} - */ - isMobile: function () { - return config.device.isMobile; - }, - /** - * States whether the thread window is hidden or not. - * - * @returns {boolean} - */ - isHidden: function () { - return this._hidden; - }, - /** - * States whether the input of the thread window should be displayed or not. - * By default, any thread window with a thread needs a composer. - * - * @returns {boolean} - */ - needsComposer: function () { - return this.hasThread(); - }, - /** - * Render the thread window - */ - render: function () { - this.renderHeader(); - if (this.hasThread()) { - this._threadWidget.render(this._thread, { displayLoadMore: false }); - } - }, - /** - * Render the header of this thread window. - * This is useful when some information on the header have be updated such - * as the status or the title of the thread that have changed. - * - * @private - */ - renderHeader: function () { - var options = this._getHeaderRenderingOptions(); - this.$header.html( - QWeb.render('mail.AbstractThreadWindow.HeaderContent', options)); - }, - /** - * Scroll to the bottom of the thread in the thread window - */ - scrollToBottom: function () { - this._threadWidget.scrollToBottom(); - }, - /** - * Toggle the fold state of this thread window. Also update the fold state - * of the thread model. If the boolean parameter `folded` is provided, it - * folds/unfolds the window when it is set/unset. - * - * @param {boolean} [folded] if not a boolean, toggle the fold state. - * Otherwise, fold/unfold the window if set/unset. - */ - toggleFold: function (folded) { - if (!_.isBoolean(folded)) { - folded = !this.isFolded(); - } - this._updateThreadFoldState(folded); - }, - /** - * Update the visual state of the window so that it matched the internal - * fold state. This is useful in case the related thread has its fold state - * that has been changed. - */ - updateVisualFoldState: function () { - if (!this.isFolded()) { - this._threadWidget.scrollToBottom(); - if (this.options.autofocus) { - this._focusInput(); - } - } - var height = this.isFolded() ? this.HEIGHT_FOLDED : this.HEIGHT_OPEN; - this.$el.css({height: height}); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Set the focus on the composer of the thread window. This operation is - * ignored in mobile context. - * - * @private - * Set the focus on the input of the window - */ - _focusInput: function () { - if ( - config.device.touch && - config.device.size_class <= config.device.SIZES.SM - ) { - return; - } - this.$input.focus(); - }, - /** - * Returns the options used by the rendering of the window's header - * - * @private - * @returns {Object} - */ - _getHeaderRenderingOptions: function () { - return { - status: this.getThreadStatus(), - thread: this.getThread(), - title: this.getTitle(), - unreadCounter: this.getUnreadCounter(), - widget: this, - }; - }, - /** - * Get the ID of the related thread. - * If this window is not related to a thread, it means this is the "blank" - * thread window, therefore it returns "_blank" as its ID. - * - * @private - * @returns {integer|string} the threadID, or '_blank' for the window that - * is not related to any thread. - */ - _getThreadID: function () { - if (!this.hasThread()) { - return '_blank'; - } - return this._thread.getID(); - }, - /** - * Tells whether there is focus on this thread. Note that a thread that has - * the focus means the input has focus. - * - * @private - * @returns {boolean} - */ - _hasFocus: function () { - return this.$input.is(':focus'); - }, - /** - * Post a message on this thread window, and auto-scroll to the bottom of - * the thread. - * - * @private - * @param {Object} messageData - */ - _postMessage: function (messageData) { - var self = this; - if (!this.hasThread()) { - return; - } - this._thread.postMessage(messageData) - .then(function () { - self._threadWidget.scrollToBottom(); - }); - }, - /** - * Update the fold state of the thread. - * - * This function is called when toggling the fold state of this window. - * If there is no thread linked to this window, it means this is the - * "blank" thread window, therefore we use the internal state 'folded' - * - * @private - * @param {boolean} folded - */ - _updateThreadFoldState: function (folded) { - if (this.hasThread()) { - this._thread.fold(folded); - } else { - this._folded = folded; - this.updateVisualFoldState(); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Close the thread window. - * Mark the thread as read if the thread window was open. - * - * @private - * @param {MouseEvent} ev - */ - _onClickClose: function (ev) { - ev.stopPropagation(); - ev.preventDefault(); - if ( - this.hasThread() && - this._thread.getUnreadCounter() > 0 && - !this.isFolded() - ) { - this._thread.markAsRead(); - } - this.close(); - }, - /** - * Fold/unfold the thread window. - * Also mark the thread as read. - * - * @private - */ - _onClickFold: function () { - if (!config.device.isMobile) { - this.toggleFold(); - } - }, - /** - * Called when the composer is clicked -> forces focus on input even if - * jquery's blockUI is enabled. - * - * @private - * @param {Event} ev - */ - _onComposerClick: function (ev) { - if ($(ev.target).closest('a, button').length) { - return; - } - this._focusInput(); - }, - /** - * @private - */ - _onDocumentViewerClose: function () { - this._focusInput(); - }, - /** - * @private - */ - _onEscapePressed: function () { - if (!this.isFolded()) { - this.close(); - } - }, - /** - * Called when typing something on the composer of this thread window. - * - * @private - * @param {KeyboardEvent} ev - */ - _onKeydown: function (ev) { - ev.stopPropagation(); // to prevent jquery's blockUI to cancel event - // ENTER key (avoid requiring jquery ui for external livechat) - if (ev.which === 13) { - var content = _.str.trim(this.$input.val()); - var messageData = { - content: content, - attachment_ids: [], - partner_ids: [], - }; - this.$input.val(''); - if (content) { - this._postMessage(messageData); - } - } - }, - /** - * @private - * @param {KeyboardEvent} ev - */ - _onKeypress: function (ev) { - ev.stopPropagation(); // to prevent jquery's blockUI to cancel event - }, - /** - * @private - */ - _onScroll: function () { - if (this.hasThread() && this.isAtBottom()) { - this._thread.markAsRead(); - } - }, - /** - * When a thread window is clicked on, we want to give the focus to the main - * input. An exception is made when the user is selecting something. - * - * @private - */ - _onThreadWindowClicked: function () { - var selectObj = window.getSelection(); - if (selectObj.anchorOffset === selectObj.focusOffset) { - this.$input.focus(); - } - }, -}); - -return AbstractThreadWindow; - -}); diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js deleted file mode 100644 index 7280f704fa3..00000000000 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ /dev/null @@ -1,403 +0,0 @@ -odoo.define('mail.ThreadWindow', function (require) { -"use strict"; - -var AbstractThreadWindow = require('mail.AbstractThreadWindow'); -var BasicComposer = require('mail.composer.Basic'); -var ExtendedComposer = require('mail.composer.Extended'); - -var config = require('web.config'); -var core = require('web.core'); - -var QWeb = core.qweb; -var _t = core._t; - -/** - * This is the main widget for rendering small windows for mail.model.Thread. - * Almost all instances of this class are linked to a thread. The sole - * exception is the "blank" thread window. This window let us open another - * thread window, using this "blank" thread window. - */ -var ThreadWindow = AbstractThreadWindow.extend({ - template: 'mail.ThreadWindow', - custom_events: AbstractThreadWindow.prototype.custom_events, - events: _.extend({}, AbstractThreadWindow.prototype.events, { - 'click .o_mail_thread': '_onThreadWindowFocus', - 'click .o_thread_composer': '_onThreadWindowFocus', - 'click .o_thread_window_expand': '_onClickExpand', - }), - /** - * Version of thread window that supports {mail.model.Thread} - * - * @override - * @param {mail.Manager} parent - * @param {mail.model.Thread} [thread = null] if not set, this is a "blank" - * thread window. It lets us open a DM chat by providing the name of a - * user. - * @param {Object} [options={}] - * @param {boolean} [options.passively=false] - */ - init: function (parent, thread, options) { - this._super.apply(this, arguments); - - // don't automatically mark unread messages as seen when at the bottom - // of the thread - this._passive = this.options.passively || false; - - if (!this.hasThread()) { - // remembered partner ID of "blank" thread window in order to be - // replaced with newly opened DM chat window - this.directPartnerID = null; - } - }, - - /** - * @override - */ - start: function () { - var self = this; - - var superDef = this._super().then(this._listenThreadWidget.bind(this)); - - this.call('mail_service', 'getMailBus') - .on('update_typing_partners', this, this._onUpdateTypingPartners) - .on('update_channel', this, this._onUpdateChannel); - - var composerDef; - if (!this.hasThread()) { - this._startWithoutThread(); - } else if (this.needsComposer()) { - var composer; - var composerParams = { - mentionPartnersRestricted: this._thread.getType() !== 'document_thread', - thread: this._thread, - }; - if ( - config.device.isMobile && - this._thread.isMassMailing() - ) { - composer = new ExtendedComposer(this, composerParams); - } else { - composer = new BasicComposer(this, _.extend({ isMini: true }, composerParams)); - } - composer.on('post_message', this, this._postMessage); - composer.once('input_focused', this, function () { - var commands = this._thread.getCommands(); - var partners = this._thread.getMentionPartnerSuggestions(); - composer.mentionSetCommands(commands); - composer.mentionSetPrefetchedPartners(partners); - }); - composerDef = composer.replace(this.$('.o_thread_composer')); - composerDef.then(function () { - self.$input = self.$('.o_composer_text_field'); - }); - } - return Promise.all([superDef, composerDef]); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - close: function () { - if (this.hasThread()) { - this._thread.close(); - } else { - this.trigger_up('close_blank_thread_window'); - } - }, - /** - * Set the focus on the input of this thread window - */ - focusInput: function () { - this._focusInput(); - }, - /** - * Overrides so that if this thread window is not linked to any thread - * (= "blank" thread window), displays "New message" as its title. - * - * @override - * @returns {string} - */ - getTitle: function () { - if (!this.hasThread()) { - return _t("New message"); - } - return this._super.apply(this, arguments); - }, - /** - * Tell whether the thread window is passive or not. A passive thread window - * does not auto-mark the thread as read when scrolling at the bottom. - * - * @returns {boolean} - */ - isPassive: function () { - return this._passive; - }, - /** - * Turn the thread window in active mode, so that when the bottom of the - * thread is visible, it is automatically marked as read. - */ - removePassive: function () { - this._passive = false; - }, - renderOutOfOffice: function () { - var $outOfOffice = this.$('.o_out_of_office'); - const thread = this.getThread(); - if (!thread.hasOutOfOffice()) { - if ($outOfOffice.length) { - $outOfOffice.remove(); - } - return; - } - const $newOutOfOffice = $(QWeb.render('mail.thread.OutOfOffice', { thread })); - if ($outOfOffice.length) { - $outOfOffice.replaceWith($newOutOfOffice); - } else { - $newOutOfOffice.insertAfter(this.$('.o_thread_window_header')); - } - }, - /** - * Update this thread window - * - * @param {Object} options - * @param {boolean} [options.keepBottom=false] if set, this thread window - * should scroll to the bottom if it was at the bottom before update - * @param {boolean} [options.passively=false] if set, this thread window - * becomes passive, so that it is marked as read only when the focus is - * on it. Ignore this option if the focus is already on the thread window. - */ - update: function (options) { - var self = this; - if (options.passively && !this._hasFocus()) { - this._setPassive(); - } - var bottomVisible = !this.isFolded() && - !this.isHidden() && - this.isAtBottom(); - if (bottomVisible && !this.isPassive()) { - this._thread.markAsRead(); - } - this._thread.fetchMessages().then(function () { - self.render(); - if (bottomVisible && options.keepBottom) { - self.scrollToBottom(); - } - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - * @private - */ - _getHeaderRenderingOptions: function () { - var options = this._super.apply(this, arguments); - if (this._thread.getType() === 'document_thread') { - options.expandTitle = _t("Open document"); - } else { - options.expandTitle = _t("Open in Discuss"); - } - return options; - }, - /** - * Listen on thread widget events - * - * @private - */ - _listenThreadWidget: function () { - this._threadWidget - .on('redirect', this, this._onRedirect) - .on('redirect_to_channel', this, this._onRedirectToChannel) - .on('toggle_star_status', this, this._onToggleStarStatus); - }, - /** - * Open this thread window. - * This private method exists only for the purpose of providing a callback - * function on redirect to a partner. - * - * @private - */ - _open: function (channelID) { - this.call('mail_service', 'openThreadWindow', channelID || this.getID()); - }, - /** - * Set the thread window in passive mode, so that new received message will - * keep the thread window as unread until there is focus on the thread - * window. - * - * @private - */ - _setPassive: function () { - this._passive = true; - }, - /** - * @private - */ - _startWithoutThread: function () { - var self = this; - this.$el.addClass('o_thread_less'); - this.$('.o_thread_search_input input') - .autocomplete({ - autoFocus: true, - source: function (request, response) { - self.call('mail_service', 'searchPartner', request.term, 10) - .then(response); - }, - select: function (event, ui) { - // remember partner ID so that we can replace this window - // with new DM chat window - var partnerID = ui.item.id; - self.directPartnerID = partnerID; - self.call('mail_service', 'openDMChatWindowFromBlankThreadWindow', partnerID); - }, - }) - .focus(); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Expand the thread, which will open the thread in discuss. - * - * If there is no thread linked to this window, it means this is the "blank" - * thread window, therefore it opens discuss with the default thread (which - * should be the mailbox 'inbox') - * - * @private - * @param {MouseEvent} ev - */ - _onClickExpand: _.debounce(function (ev) { - var self = this; - ev.preventDefault(); - if (this.hasThread() && this._thread.getType() === 'document_thread') { - this.do_action({ - type: 'ir.actions.act_window', - res_model: this._thread.getDocumentModel(), - views: [[false, 'form']], - res_id: this._thread.getDocumentID(), - }); - } else { - this.do_action('mail.action_discuss', { - clear_breadcrumbs: false, - active_id: this.hasThread() ? this._getThreadID() : undefined, - on_reverse_breadcrumb: function () { - var mailBus = self.call('mail_service', 'getMailBus'); - if (mailBus) { - mailBus.trigger('discuss_open', false); - } - }, - }); - } - }, 1000, true), - /** - * @override - * @private - * @param {KeyboardEvent} ev - * - * Override _onKeydown to only prevent jquery's blockUI to cancel event, - * but without sending the message on ENTER keydown as this is handled by - * the BasicComposer - */ - _onKeydown: function (ev) { - ev.stopPropagation(); - }, - /** - * callback function receives the threadID linked to resModel and resID - * Note: this callback function is only called for 'res.partner' - * - * @private - * @param {string} resModel - * @param {integer} resID - */ - _onRedirect: function (resModel, resID) { - var callback = this._open.bind(this); - this.call('mail_service', 'redirect', resModel, resID, callback); - }, - /** - * @private - * @param {integer} channelID - */ - _onRedirectToChannel: function (channelID) { - var self = this; - var thread = this.call('mail_service', 'getThread', channelID); - if (!thread) { - this.call('mail_service', 'joinChannel', channelID) - .then(function (channelID) { - var channel = self.call('mail_service', 'getThread', channelID); - channel.detach(); - }); - } else { - this.toggleFold(false); - } - }, - /** - * Override it so that passive thread windows do not mark thread as read - * on scroll - * - * @override - * @private - */ - _onScroll: function () { - if (this.isPassive()) { - return; - } - return this._super.apply(this, arguments); - }, - /** - * Called when focusing the thread window (click on thread or composer) - * - * @private - */ - _onThreadWindowFocus: function () { - if (this.isPassive()) { - this.removePassive(); - this._thread.markAsRead(); - } - }, - /** - * @private - * @param {integer} messageID - */ - _onToggleStarStatus: function (messageID) { - var message = this.call('mail_service', 'getMessage', messageID); - message.toggleStarStatus(); - }, - /** - * @private - * @param {integer|string} threadID - */ - _onUpdateTypingPartners: function (threadID) { - if (!this.hasThread()) { - return; - } - if (this._thread.getID() !== threadID) { - return; - } - this.renderHeader(); - }, - /** - * @private - * @param {integer} channelID - */ - _onUpdateChannel: function (channelID) { - if (!this.hasThread()) { - return; - } - if (this._thread.getID() !== channelID) { - return; - } - this.render(); - }, -}); - -return ThreadWindow; - -}); diff --git a/addons/mail/static/src/js/tools/debug_manager.js b/addons/mail/static/src/js/tools/debug_manager.js index 3a4c1d3c372..798765eb364 100644 --- a/addons/mail/static/src/js/tools/debug_manager.js +++ b/addons/mail/static/src/js/tools/debug_manager.js @@ -11,7 +11,6 @@ var _t = core._t; */ DebugManager.include({ getMailMessages: function () { - var self = this; var selectedIDs = this._controller.getSelectedIds(); if (!selectedIDs.length) { console.warn(_t("No message available")); diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js index e63647540a0..b37ed70751f 100644 --- a/addons/mail/static/src/js/tours/mail.js +++ b/addons/mail/static/src/js/tours/mail.js @@ -7,26 +7,26 @@ var tour = require('web_tour.tour'); var _t = core._t; tour.register('mail_tour', { - url: "/web#action=mail.discuss", + url: "/web#action=mail.widgets.discuss", }, [{ - trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="multi_user_channel"]', + trigger: '.o_DiscussSidebar_groupChannel .o_DiscussSidebar_groupHeaderItemAdd', content: _t("<p>Channels make it easy to organize information across different topics and groups.</p> <p>Try to <b>create your first channel</b> (e.g. sales, marketing, product XYZ, after work party, etc).</p>"), position: 'bottom', }, { - trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_mail_add_thread[data-type="multi_user_channel"]', + trigger: '.o_DiscussSidebar_itemNewInput', content: _t("<p>Create a channel here.</p>"), position: 'bottom', auto: true, run: function (actions) { var t = new Date().getTime(); - actions.text("SomeChannel_" + t, this.$anchor.find("input")); + actions.text("SomeChannel_" + t, this.$anchor); }, }, { - trigger: ".ui-autocomplete .ui-menu-item > a:contains(Private)", + trigger: ".ui-autocomplete .ui-menu-item > a:contains(Create):has(.fa-lock)", content: _t("<p> Create a private channel.</p>"), position: 'bottom', }, { - trigger: '.o_mail_discuss .o_composer_text_field', + trigger: '.o_Discuss_thread .o_ComposerTextInput_textarea', content: _t("<p><b>Write a message</b> to the members of the channel here.</p> <p>You can notify someone with <i>'@'</i> or link another channel with <i>'#'</i>. Start your message with <i>'/'</i> to get the list of possible commands.</p>"), position: "top", width: 350, @@ -35,19 +35,19 @@ tour.register('mail_tour', { actions.text("SomeText_" + t, this.$anchor); }, }, { - trigger: '.o_mail_discuss .o_thread_composer .o_composer_button_send', + trigger: '.o_Discuss_thread .o_Composer_buttonSend', content: _t("Post your message on the thread"), position: "top", }, { - trigger: '.o_mail_discuss .o_mail_thread .o_thread_message_star', + trigger: '.o_Discuss_thread .o_Message_commandStar', content: _t("Messages can be <b>starred</b> to remind you to check back later."), position: "bottom", }, { - trigger: '.o_mail_discuss .o_mail_discuss_item[data-thread-id="mailbox_starred"]', + trigger: '.o_DiscussSidebarItem.o-starred-box', content: _t("Once a message has been starred, you can come back and review it at any time here."), position: "bottom", }, { - trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm_chat"]', + trigger: '.o_DiscussSidebar_groupChat .o_DiscussSidebar_groupHeaderItemAdd', content: _t("<p><b>Chat with coworkers</b> in real-time using direct messages.</p><p><i>You might need to invite users from the Settings app first.</i></p>"), position: 'bottom', }]); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index d4b7d8e7875..d54f5932619 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -5,6 +5,15 @@ var core = require('web.core'); var _t = core._t; +/** + * WARNING: this is not enough to unescape potential XSS contained in htmlString, transformFunction + * should handle it or it should be handled after/before calling parseAndTransform. So if the result + * of this function is used in a t-raw, be very careful. + * + * @param {string} htmlString + * @param {function} transformFunction + * @returns {string} + */ function parseAndTransform(htmlString, transformFunction) { var openToken = "OPEN" + Date.now(); var string = htmlString.replace(/</g, openToken); @@ -17,6 +26,7 @@ function parseAndTransform(htmlString, transformFunction) { return _parseAndTransform(children, transformFunction) .replace(new RegExp(openToken, "g"), "<"); } + /** * @param {Node[]} nodes * @param {function} transformFunction with: @@ -127,6 +137,23 @@ function parseEmail(text) { } } +/** + * Returns an escaped conversion of a content. + * + * @param {string} content + * @returns {string} + */ +function escapeAndCompactTextContent(content) { + //Removing unwanted extra spaces from message + let value = _.escape(content).trim(); + value = value.replace(/(\r|\n){2,}/g, '<br/><br/>'); + value = value.replace(/(\r|\n)/g, '<br/>'); + + // prevent html space collapsing + value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); + return value; +} + // Replaces textarea text into html text (add <p>, <a>) // TDE note : should be done server-side, in Python -> use mail.compose.message ? function getTextToHTML(text) { @@ -142,14 +169,6 @@ function timeFromNow(date) { return date.fromNow(); } -function o_clearTimeout(id) { - return clearTimeout(id); -} - -function o_setTimeout(func, delay) { - return setTimeout(func, delay); -} - return { addLink: addLink, getTextToHTML: getTextToHTML, @@ -160,8 +179,7 @@ return { parseEmail: parseEmail, stripHTML: stripHTML, timeFromNow: timeFromNow, - clearTimeout: o_clearTimeout, - setTimeout: o_setTimeout, + escapeAndCompactTextContent, }; }); diff --git a/addons/mail/static/src/model/model_core.js b/addons/mail/static/src/model/model_core.js new file mode 100644 index 00000000000..0198e47ee7d --- /dev/null +++ b/addons/mail/static/src/model/model_core.js @@ -0,0 +1,125 @@ +odoo.define('mail/static/src/model/model_core.js', function (require) { +'use strict'; + +/** + * Module that contains registry for adding new models or patching models. + * Useful for model manager in order to generate model classes. + * + * This code is not in model manager because other JS modules should populate + * a registry, and it's difficult to ensure availability of the model manager + * when these JS modules are deployed. + */ + +const registry = {}; + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * @private + * @param {string} modelName + * @returns {Object} + */ +function _getEntryFromModelName(modelName) { + if (!registry[modelName]) { + registry[modelName] = { + dependencies: [], + factory: undefined, + name: modelName, + patches: [], + }; + } + return registry[modelName]; +} + +/** + * @private + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + * @param {Object} [param3={}] + * @param {string} [param3.type='instance'] 'instance', 'class' or 'field' + */ +function _registerPatchModel(modelName, patchName, patch, { type = 'instance' } = {}) { + const entry = _getEntryFromModelName(modelName); + Object.assign(entry, { + patches: (entry.patches || []).concat([{ + name: patchName, + patch, + type, + }]), + }); +} + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +/** + * Register a patch for static methods in model. + * + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + */ +function registerClassPatchModel(modelName, patchName, patch) { + _registerPatchModel(modelName, patchName, patch, { type: 'class' }); +} + +/** + * Register a patch for fields in model. + * + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + */ +function registerFieldPatchModel(modelName, patchName, patch) { + _registerPatchModel(modelName, patchName, patch, { type: 'field' }); +} + +/** + * Register a patch for instance methods in model. + * + * @param {string} modelName + * @param {string} patchName + * @param {Object} patch + */ +function registerInstancePatchModel(modelName, patchName, patch) { + _registerPatchModel(modelName, patchName, patch, { type: 'instance' }); +} + +/** + * @param {string} name + * @param {function} factory + * @param {string[]} [dependencies=[]] + */ +function registerNewModel(name, factory, dependencies = []) { + const entry = _getEntryFromModelName(name); + let entryDependencies = [...dependencies]; + if (name !== 'mail.model') { + entryDependencies = [...new Set(entryDependencies.concat(['mail.model']))]; + } + if (entry.factory) { + throw new Error(`Model "${name}" has already been registered!`); + } + Object.assign(entry, { + dependencies: entryDependencies, + factory, + name, + }); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + registerClassPatchModel, + registerFieldPatchModel, + registerInstancePatchModel, + registerNewModel, + registry, +}; + +}); diff --git a/addons/mail/static/src/model/model_errors.js b/addons/mail/static/src/model/model_errors.js new file mode 100644 index 00000000000..17ecc96013c --- /dev/null +++ b/addons/mail/static/src/model/model_errors.js @@ -0,0 +1,22 @@ +odoo.define('mail/static/src/model/model_errors.js', function (require) { +'use strict'; + +class RecordDeletedError extends Error { + + /** + * @override + * @param {string} recordLocalId local id of record that has been deleted + * @param {...any} args + */ + constructor(recordLocalId, ...args) { + super(...args); + this.recordLocalId = recordLocalId; + this.name = 'RecordDeletedError'; + } +} + +return { + RecordDeletedError, +}; + +}); diff --git a/addons/mail/static/src/model/model_field.js b/addons/mail/static/src/model/model_field.js new file mode 100644 index 00000000000..589eee5c782 --- /dev/null +++ b/addons/mail/static/src/model/model_field.js @@ -0,0 +1,988 @@ +odoo.define('mail/static/src/model/model_field.js', function (require) { +'use strict'; + +/** + * Class whose instances represent field on a model. + * These field definitions are generated from declared fields in static prop + * `fields` on the model. + */ +class ModelField { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + constructor({ + autocreate = false, + compute, + default: def, + dependencies = [], + dependents = [], + fieldName, + fieldType, + hashes: extraHashes = [], + inverse, + isCausal = false, + modelManager, + related, + relationType, + to, + } = {}) { + const id = _.uniqueId('field_'); + /** + * This prop only makes sense for fields of type "relation". If set, + * it automatically creates a new record for this field on creation of + * record, and auto-links with this record. + */ + this.autocreate = autocreate; + /** + * If set, this field acts as a computed field, and this prop + * contains the name of the instance method that computes the value + * for this field. This compute method is called on creation of record + * and whenever some of its dependencies change. @see dependencies + */ + this.compute = compute; + /** + * Default value for this field. Used on creation of this field, to + * set a value by default. + */ + this.default = def; + /** + * List of field on current record that this field depends on for its + * `compute` method. Useful to determine whether this field should be + * registered for recomputation when some record fields have changed. + * This list must be declared in model definition, or compute method + * is only computed once. + */ + this.dependencies = dependencies; + /** + * List of fields that are dependent of this field. They should never + * be declared, and are automatically generated while processing + * declared fields. This is populated by compute `dependencies` and + * `related`. + */ + this.dependents = dependents; + /** + * Name of the field in the definition of fields on model. + */ + this.fieldName = fieldName; + /** + * Type of this field. 2 types of fields are currently supported: + * + * 1. 'attribute': fields that store primitive values like integers, + * booleans, strings, objects, array, etc. + * + * 2. 'relation': fields that relate to some other records. + */ + this.fieldType = fieldType; + /** + * List of hashes registered on this field definition. Technical + * prop that is specifically used in processing of dependent + * fields, useful to clearly identify which fields of a relation are + * dependents and must be registered for computed. Indeed, not all + * related records may have a field that depends on changed field, + * especially when dependency is defined on sub-model on a relation in + * a super-model. + * + * To illustrate the purpose of this hash, suppose following definition + * of models and fields: + * + * - 3 models (A, B, C) and 3 fields (x, y, z) + * - A.fields: { x: one2one(C, inverse: x') } + * - B extends A + * - B.fields: { z: related(x.y) } + * - C.fields: { y: attribute } + * + * Visually: + * x' + * <----------- + * A -----------> C { y } + * ^ x + * | + * | (extends) + * | + * B { z = x.y } + * + * If z has a dependency on x.y, it means y has a dependent on x'.z. + * Note that field z exists on B but not on all A. To determine which + * kinds of records in relation x' are dependent on y, y is aware of an + * hash on this dependent, and any dependents who has this hash in list + * of hashes are actual dependents. + */ + this.hashes = extraHashes.concat([id]); + /** + * Identification for this field definition. Useful to map a dependent + * from a dependency. Indeed, declared field definitions use + * 'dependencies' but technical process need inverse as 'dependents'. + * Dependencies just need name of fields, but dependents cannot just + * rely on inverse field names because these dependents are a subset. + */ + this.id = id; + /** + * This prop only makes sense in a relational field. This contains + * the name of the field name in the inverse relation. This may not + * be defined in declared field definitions, but processed relational + * field definitions always have inverses. + */ + this.inverse = inverse; + /** + * This prop only makes sense in a relational field. If set, when this + * relation is removed, the related record is automatically deleted. + */ + this.isCausal = isCausal; + /** + * Reference to the model manager. + */ + this.modelManager = modelManager; + /** + * If set, this field acts as a related field, and this prop contains + * a string that references the related field. It should have the + * following format: '<relationName>.<relatedFieldName>', where + * <relationName> is a relational field name on this model or a parent + * model (note: could itself be computed or related), and + * <relatedFieldName> is the name of field on the records that are + * related to current record from this relation. When there are more + * than one record in the relation, it maps all related fields per + * record in relation. + * + * FIXME: currently flatten map due to bug, improvement is planned + * see Task-id 2261221 + */ + this.related = related; + /** + * This prop only makes sense in a relational field. Determine which + * type of relation there is between current record and other records. + * 4 types of relation are supported: 'one2one', 'one2many', 'many2one' + * and 'many2many'. + */ + this.relationType = relationType; + /** + * This prop only makes sense in a relational field. Determine which + * model name this relation refers to. + */ + this.to = to; + } + + /** + * Define an attribute field. + * + * @param {Object} [options] + * @returns {Object} + */ + static attr(options) { + return Object.assign({ fieldType: 'attribute' }, options); + } + + /** + * Define a many2many field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static many2many(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2many' })); + } + + /** + * Define a many2one field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static many2one(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'many2one' })); + } + + /** + * Define a one2many field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static one2many(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2many' })); + } + + /** + * Define a one2one field. + * + * @param {string} modelName + * @param {Object} [options] + * @returns {Object} + */ + static one2one(modelName, options) { + return ModelField._relation(modelName, Object.assign({}, options, { relationType: 'one2one' })); + } + + /** + * Combine current field definition with provided field definition and + * return the combined field definition. Useful to track list of hashes of + * a given field, which is necessary for the working of dependent fields + * (computed and related fields). + * + * @param {ModelField} field + * @returns {ModelField} + */ + combine(field) { + return new ModelField(Object.assign({}, this, { + dependencies: this.dependencies.concat(field.dependencies), + hashes: this.hashes.concat(field.hashes), + })); + } + + /** + * Perform computation of this field, which is either a computed or related + * field. + * + * @param {mail.model} record + */ + doCompute(record) { + if (this.compute) { + this.set(record, record[this.compute]()); + return; + } + if (this.related) { + this.set(record, this._computeRelated(record)); + return; + } + throw new Error("No compute method defined on this field definition"); + } + + /** + * Get the env with messaging. + * + * @returns {mail/static/src/env/env.js} + */ + get env() { + return this.modelManager.env; + } + + /** + * Get the value associated to this field. Relations must convert record + * local ids to records. + * + * @param {mail.model} record + * @returns {any} + */ + get(record) { + if (this.fieldType === 'attribute') { + return this.read(record); + } + if (this.fieldType === 'relation') { + const OtherModel = this.env.models[this.to]; + if (['one2one', 'many2one'].includes(this.relationType)) { + return OtherModel.get(this.read(record)); + } + return this.read(record) + .map(localId => OtherModel.get(localId)) + /** + * FIXME: Stored relation may still contain + * outdated records. + */ + .filter(record => !!record); + } + throw new Error(`cannot get field with unsupported type ${this.fieldType}.`); + } + + /** + * Get the raw value associated to this field. For relations, this means + * the local id or list of local ids of records in this relational field. + * + * @param {mail.model} record + * @returns {any} + */ + read(record) { + return record.__state[this.fieldName]; + } + + /** + * Set a value on this field. The format of the value comes from business + * code. + * + * @param {mail.model} record + * @param {any} newVal + */ + set(record, newVal) { + if (this.fieldType === 'attribute') { + this.write(record, newVal); + } + if (this.fieldType === 'relation') { + for (const val of newVal) { + switch (val[0]) { + case 'create': + this._setRelationCreate(record, val[1]); + break; + case 'insert': + this._setRelationInsert(record, val[1]); + break; + case 'insert-and-replace': + this._setRelationInsertAndReplace(record, val[1]); + break; + case 'link': + this._setRelationLink(record, val[1]); + break; + case 'replace': + // TODO IMP replace should not unlink-all (task-2270780) + this._setRelationUnlink(record, null); + this._setRelationLink(record, val[1]); + break; + case 'unlink': + this._setRelationUnlink(record, val[1]); + break; + case 'unlink-all': + this._setRelationUnlink(record, null); + break; + } + } + } + } + + /** + * Set a value in state associated to this field. Value corresponds exactly + * that what is stored on this field, like local id or list of local ids + * for a relational field. If the value changes, dependent fields are + * automatically registered for (re-)computation. + * + * @param {mail.model} record + * @param {any} newVal + * @param {Object} [param2={}] + * @param {Object} [param2.registerDependents=true] If set, write + * on this field with changed value registers dependent fields for compute. + * Of course, we almost always want to register them, so that they reflect + * the value with their dependencies. Disabling this feature prevents + * useless potentially heavy computation, like when setting default values. + */ + write(record, newVal, { registerDependents = true } = {}) { + if (this.read(record) === newVal) { + return; + } + const prevStringified = JSON.stringify(this.read(record)); + record.__state[this.fieldName] = newVal; + const newStringified = JSON.stringify(this.read(record)); + if (this._containsRecords(newVal)) { + throw new Error("Forbidden write operation with records in the __state!!"); + } + if (newStringified === prevStringified) { + // value unchanged, don't need to compute dependent fields + return; + } + if (!registerDependents) { + return; + } + + // flag all dependent fields for compute + for (const dependent of this.dependents) { + const [hash, currentFieldName, relatedFieldName] = dependent.split( + this.modelManager.DEPENDENT_INNER_SEPARATOR + ); + if (relatedFieldName) { + const Model = record.constructor; + const relationField = Model.fields[currentFieldName]; + if (['one2many', 'many2many'].includes(relationField.relationType)) { + for (const otherRecord of record[currentFieldName]) { + const OtherModel = otherRecord.constructor; + const field = OtherModel.fields[relatedFieldName]; + if (field && field.hashes.includes(hash)) { + this.modelManager.registerToComputeField(otherRecord, field); + } + } + } else { + const otherRecord = record[currentFieldName]; + if (!otherRecord) { + continue; + } + const OtherModel = otherRecord.constructor; + const field = OtherModel.fields[relatedFieldName]; + if (field && field.hashes.includes(hash)) { + this.modelManager.registerToComputeField(otherRecord, field); + } + } + } else { + const Model = record.constructor; + const field = Model.fields[currentFieldName]; + if (field && field.hashes.includes(hash)) { + this.modelManager.registerToComputeField(record, field); + } + } + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} modelName + * @param {Object} [options] + */ + static _relation(modelName, options) { + return Object.assign({ + fieldType: 'relation', + to: modelName, + }, options); + } + + /** + * Compute method when this field is related. + * + * @private + * @param {mail.model} record + */ + _computeRelated(record) { + const [relationName, relatedFieldName] = this.related.split('.'); + const Model = record.constructor; + const relationField = Model.fields[relationName]; + if (['one2many', 'many2many'].includes(relationField.relationType)) { + const newVal = []; + for (const otherRecord of record[relationName]) { + const OtherModel = otherRecord.constructor; + const otherField = OtherModel.fields[relatedFieldName]; + const otherValue = otherField.get(otherRecord); + if (otherValue) { + if (otherValue instanceof Array) { + // avoid nested array if otherField is x2many too + // TODO IMP task-2261221 + for (const v of otherValue) { + newVal.push(v); + } + } else { + newVal.push(otherValue); + } + } + } + if (this.fieldType === 'relation') { + return [['replace', newVal]]; + } + return newVal; + } + const otherRecord = record[relationName]; + if (otherRecord) { + const OtherModel = otherRecord.constructor; + const otherField = OtherModel.fields[relatedFieldName]; + const newVal = otherField.get(otherRecord); + if (this.fieldType === 'relation') { + if (newVal) { + return [['replace', newVal]]; + } else { + return [['unlink-all']]; + } + } + return newVal; + } + if (this.fieldType === 'relation') { + return []; + } + } + + /** + * Determines whether the provided value contains some records. Useful to + * prevent writing records directly in state of this field, which should be + * treated as buggy design. Indeed, state of field should only contain + * either a primitive type or a simple datastructure containing itself + * simple datastructures too. + * + * @private + * @param {any} val + * @returns {boolean} + */ + _containsRecords(val) { + if (!val) { + return false; + } + if (val instanceof this.env.models['mail.model']) { + return true; + } + if (!(val instanceof Array)) { + return false; + } + if (val.length > 0 && val[0] instanceof this.env.models['mail.model']) { + return true; + } + return false; + } + + /** + * Converts given value to expected format for x2many processing, which is + * an array of localId. + * + * @private + * @param {string|mail.model|<mail.model|string>[]} newValue + * @returns {string[]} + */ + _setRelationConvertX2ManyValue(newValue) { + if (newValue instanceof Array) { + return newValue.map(v => this._setRelationConvertX2OneValue(v)); + } + return [this._setRelationConvertX2OneValue(newValue)]; + } + + /** + * Converts given value to expected format for x2one processing, which is + * a localId. + * + * @private + * @param {string|mail.model} newValue + * @returns {string} + */ + _setRelationConvertX2OneValue(newValue) { + return newValue instanceof this.env.models['mail.model'] ? newValue.localId : newValue; + } + + /** + * Set on this relational field in 'create' mode. Basically data provided + * during set on this relational field contain data to create new records, + * which themselves must be linked to record of this field by means of + * this field. + * + * @private + * @param {mail.model} record + * @param {Object|Object[]} data + */ + _setRelationCreate(record, data) { + const OtherModel = this.env.models[this.to]; + let other; + if (['one2one', 'many2one'].includes(this.relationType)) { + other = OtherModel.create(data); + } else { + if (data instanceof Array) { + other = data.map(d => OtherModel.create(d)); + } else { + other = OtherModel.create(data); + } + } + this._setRelationLink(record, other); + } + + /** + * Set on this relational field in 'insert' mode. Basically data provided + * during set on this relational field contain data to insert records, + * which themselves must be linked to record of this field by means of + * this field. + * + * @private + * @param {mail.model} record + * @param {Object|Object[]} data + */ + _setRelationInsert(record, data) { + const OtherModel = this.env.models[this.to]; + let other; + if (['one2one', 'many2one'].includes(this.relationType)) { + other = OtherModel.insert(data); + } else { + if (data instanceof Array) { + other = data.map(d => OtherModel.insert(d)); + } else { + other = OtherModel.insert(data); + } + } + this._setRelationLink(record, other); + } + + /** + * Set on this relational field in 'insert-and-repalce' mode. Basically + * data provided during set on this relational field contain data to insert + * records, which themselves must replace value on this field. + * + * @private + * @param {mail.model} record + * @param {Object|Object[]} data + */ + _setRelationInsertAndReplace(record, data) { + // unlink must be done before insert: + // because unlink might trigger delete due to causality and new data + // shouldn't be deleted just after being inserted + // TODO IMP insert-and-replace should not unlink-all (task-2270780) + this._setRelationUnlink(record, null); + const OtherModel = this.env.models[this.to]; + let other; + if (['one2one', 'many2one'].includes(this.relationType)) { + other = OtherModel.insert(data); + } else { + if (data instanceof Array) { + other = data.map(d => OtherModel.insert(d)); + } else { + other = OtherModel.insert(data); + } + } + this._setRelationLink(record, other); + } + + /** + * Set a 'link' operation on this relational field. + * + * @private + * @param {string|string[]|mail.model|mail.model[]} newValue + */ + _setRelationLink(record, newValue) { + const Model = record.constructor; + if (!Model.get(record)) { + // current record may be deleted due to causality + return; + } + switch (this.relationType) { + case 'many2many': + this._setRelationLinkMany2Many(record, newValue); + break; + case 'many2one': + this._setRelationLinkMany2One(record, newValue); + break; + case 'one2many': + this._setRelationLinkOne2Many(record, newValue); + break; + case 'one2one': + this._setRelationLinkOne2One(record, newValue); + break; + } + } + + /** + * Handling of a `set` 'link' of a many2many relational field. + * + * @private + * @param {mail.model} record + * @param {string|mail.model|<mail.model|string>[]} newValue + */ + _setRelationLinkMany2Many(record, newValue) { + // convert newValue to array of localId + const newLocalIds = this._setRelationConvertX2ManyValue(newValue); + const OtherModel = this.env.models[this.to]; + + for (const newLocalId of newLocalIds) { + // read in loop to catch potential changes from previous iteration + const prevLocalIds = this.read(record); + + // other record already linked, avoid linking twice + if (prevLocalIds.includes(newLocalId)) { + continue; + } + + const newOtherRecord = OtherModel.get(newLocalId); + // other record may be deleted due to causality, avoid linking + // deleted records + if (!newOtherRecord) { + continue; + } + + // link other record to current record + this.write(record, prevLocalIds.concat([newLocalId])); + + // link current record to other record + newOtherRecord.update({ + [this.inverse]: [['link', record]], + }); + } + } + + /** + * Handling of a `set` 'link' of a many2one relational field. + * + * @private + * @param {mail.model} record + * @param {string|mail.model} newValue + */ + _setRelationLinkMany2One(record, newValue) { + // convert newValue to localId + const newLocalId = this._setRelationConvertX2OneValue(newValue); + const OtherModel = this.env.models[this.to]; + const prevLocalId = this.read(record); + + // other record already linked, avoid linking twice + if (prevLocalId === newLocalId) { + return; + } + + // unlink previous other record from current record + this.write(record, undefined); + + const prevOtherRecord = OtherModel.get(prevLocalId); + // there may be no previous other record or the previous other record + // may be deleted due to causality + if (prevOtherRecord) { + // unlink current record from previous other record + prevOtherRecord.update({ + [this.inverse]: [['unlink', record]], + }); + } + + const newOtherRecord = OtherModel.get(newLocalId); + // other record may be deleted due to causality, avoid linking + // deleted records + if (!newOtherRecord) { + return; + } + + // link other record to current records + this.write(record, newLocalId); + + // link current record to other record + newOtherRecord.update({ + [this.inverse]: [['link', record]], + }); + } + + /** + * Handling of a `set` 'link' of an one2many relational field. + * + * @private + * @param {mail.model} record + * @param {string|mail.model|<string|mail.model>[]} newValue + */ + _setRelationLinkOne2Many(record, newValue) { + // convert newValue to array of localId + const newLocalIds = this._setRelationConvertX2ManyValue(newValue); + const OtherModel = this.env.models[this.to]; + + for (const newLocalId of newLocalIds) { + // read in loop to catch potential changes from previous iteration + const prevLocalIds = this.read(record); + + // other record already linked, avoid linking twice + if (prevLocalIds.includes(newLocalId)) { + continue; + } + + const newOtherRecord = OtherModel.get(newLocalId); + // other record may be deleted due to causality, avoid linking + // deleted records + if (!newOtherRecord) { + continue; + } + + // link other record to current record + this.write(record, prevLocalIds.concat([newLocalId])); + + // link current record to other record + newOtherRecord.update({ + [this.inverse]: [['link', record]], + }); + } + } + + /** + * Handling of a `set` 'link' of an one2one relational field. + * + * @private + * @param {mail.model} record + * @param {string|mail.model} value + */ + _setRelationLinkOne2One(record, newValue) { + // convert newValue to localId + const newLocalId = this._setRelationConvertX2OneValue(newValue); + const prevLocalId = this.read(record); + const OtherModel = this.env.models[this.to]; + + // other record already linked, avoid linking twice + if (prevLocalId === newLocalId) { + return; + } + + // unlink previous other record from current record + this.write(record, undefined); + + const prevOtherRecord = OtherModel.get(prevLocalId); + // there may be no previous other record or the previous other record + // may be deleted due to causality + if (prevOtherRecord) { + // unlink current record from previous other record + prevOtherRecord.update({ + [this.inverse]: [['unlink', record]], + }); + // apply causality + if (this.isCausal) { + prevOtherRecord.delete(); + } + } + + const newOtherRecord = OtherModel.get(newLocalId); + // other record may be deleted due to causality, avoid linking deleted + // records + if (!newOtherRecord) { + return; + } + + // link other record to current record + this.write(record, newLocalId); + + // link current record to other record + newOtherRecord.update({ + [this.inverse]: [['link', record]], + }); + } + + /** + * Set an 'unlink' operation on this relational field. + * + * @private + * @param {mail.model} record + * @param {string|string[]|mail.model|mail.model[]|null} newValue + */ + _setRelationUnlink(record, newValue) { + const Model = record.constructor; + if (!Model.get(record)) { + // current record may be deleted due to causality + return; + } + switch (this.relationType) { + case 'many2many': + this._setRelationUnlinkMany2Many(record, newValue); + break; + case 'many2one': + this._setRelationUnlinkMany2One(record); + break; + case 'one2many': + this._setRelationUnlinkOne2Many(record, newValue); + break; + case 'one2one': + this._setRelationUnlinkOne2One(record); + break; + } + } + + /** + * Handling of a `set` 'unlink' of a many2many relational field. + * + * @private + * @param {mail.model} record + * @param {string|mail.model|<string|mail.model>[]|null} newValue + */ + _setRelationUnlinkMany2Many(record, newValue) { + // convert newValue to array of localId, null is considered unlink all + const otherLocalIds = newValue === null + ? [...this.read(record)] + : this._setRelationConvertX2ManyValue(newValue); + const OtherModel = this.env.models[this.to]; + + for (const otherLocalId of otherLocalIds) { + // read in loop to catch potential changes from previous iteration + const prevLocalIds = this.read(record); + + // other record already unlinked, avoid useless processing + if (!prevLocalIds.includes(otherLocalId)) { + continue; + } + + // unlink other record from current record + this.write(record, prevLocalIds.filter( + localId => localId !== otherLocalId + )); + + const otherRecord = OtherModel.get(otherLocalId); + // other record may be deleted due to causality, avoid useless + // processing + if (otherRecord) { + // unlink current record from other record + otherRecord.update({ + [this.inverse]: [['unlink', record]], + }); + } + } + } + + /** + * Handling of a `set` 'unlink' of a many2one relational field. + * + * @private + * @param {mail.model} record + */ + _setRelationUnlinkMany2One(record) { + const otherLocalId = this.read(record); + const OtherModel = this.env.models[this.to]; + + // other record already unlinked, avoid useless processing + if (!otherLocalId) { + return; + } + + // unlink other record from current record + this.write(record, undefined); + + const otherRecord = OtherModel.get(otherLocalId); + // other record may be deleted due to causality, avoid useless + // processing + if (otherRecord) { + // unlink current record from other record + otherRecord.update({ + [this.inverse]: [['unlink', record]], + }); + } + } + + /** + * Handling of a `set` 'unlink' of an one2many relational field. + * + * @private + * @param {mail.model} record + * @param {string|mail.model|<string|mail.model>[]|null} newValue + * if null, unlink all items in the relation of provided record. + */ + _setRelationUnlinkOne2Many(record, newValue) { + // convert newValue to array of localId, null is considered unlink all + const otherLocalIds = newValue === null + ? [...this.read(record)] + : this._setRelationConvertX2ManyValue(newValue); + const OtherModel = this.env.models[this.to]; + + for (const otherLocalId of otherLocalIds) { + // read in loop to catch potential changes from previous iteration + const prevLocalIds = this.read(record); + + // other record already unlinked, avoid useless processing + if (!prevLocalIds.includes(otherLocalId)) { + continue; + } + + // unlink other record from current record + this.write(record, prevLocalIds.filter( + localId => localId !== otherLocalId + )); + + const otherRecord = OtherModel.get(otherLocalId); + // other record may be deleted due to causality, avoid useless + // processing + if (otherRecord) { + // unlink current record from other record + otherRecord.update({ + [this.inverse]: [['unlink', record]], + }); + } + } + } + + /** + * Handling of a `set` 'unlink' of an one2one relational field. + * + * @private + * @param {mail.model} record + */ + _setRelationUnlinkOne2One(record) { + const otherLocalId = this.read(record); + const OtherModel = this.env.models[this.to]; + + // other record already unlinked, avoid useless processing + if (!otherLocalId) { + return; + } + + // unlink other record from current record + this.write(record, undefined); + + const otherRecord = OtherModel.get(otherLocalId); + // other record may be deleted due to causality, avoid useless + // processing + if (otherRecord) { + // unlink current record from other record + otherRecord.update({ + [this.inverse]: [['unlink', record]], + }); + } + } + +} + +return ModelField; + +}); diff --git a/addons/mail/static/src/model/model_manager.js b/addons/mail/static/src/model/model_manager.js new file mode 100644 index 00000000000..37ce2d2808c --- /dev/null +++ b/addons/mail/static/src/model/model_manager.js @@ -0,0 +1,947 @@ +odoo.define('mail/static/src/model/model_manager.js', function (require) { +'use strict'; + +const { registry } = require('mail/static/src/model/model_core.js'); +const ModelField = require('mail/static/src/model/model_field.js'); +const { patchClassMethods, patchInstanceMethods } = require('mail/static/src/utils/utils.js'); + +/** + * Inner separator used between 2 bits of information in string that is used to + * identify record and field to be computed during an update cycle. + */ +const COMPUTE_RECORD_FIELD_INNER_SEPARATOR = "--||--||--"; +/** + * Inner separator used between bits of information in string that is used to + * identify a dependent of a field. Useful to determine which record and field + * to register for compute during this update cycle. + */ +const DEPENDENT_INNER_SEPARATOR = "--//--//--"; + +/** + * Object that manage models and records, notably their update cycle: whenever + * some records are requested for update (either with model static method + * `create()` or record method `update()`), this object processes them with + * direct field & and computed field updates. + */ +class ModelManager { + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + constructor(env) { + /** + * Inner separator used inside string to represent dependents. + * Set as public attribute so that it can be used by model field. + */ + this.DEPENDENT_INNER_SEPARATOR = DEPENDENT_INNER_SEPARATOR; + + /** + * The messaging env. It is passed on start(), due to the way mocking + * of OWL env in tests work... + */ + this.env = undefined; + + /** + * Whether this is currently handling an "update after" on a record. + * Useful to determine if we should process computed/related fields. + */ + this._isHandlingToUpdateAfters = false; + /** + * Determine whether an update cycle is currently in progress. + * Useful to determine whether an update should initiate an update + * cycle or not. An update cycle basically prioritizes processing + * of all direct updates (i.e. explicit from `data`) before + * processing computes. + */ + this._isInUpdateCycle = false; + /** + * Contains all records. key is local id, while value is the record. + */ + this._records = {}; + /** + * Fields flagged to call compute during an update cycle. + * For instance, when a field with dependents got update, dependent + * fields should update themselves by invoking compute at end of + * update cycle. Key is of format + * <record-local-id><DEPENDENT_INNER_SEPARATOR><fieldName>, and + * determine record and field to be computed. Keys are strings because + * it must contain only one occurrence of pair record/field, and we want + * O(1) reads/writes. + */ + this._toComputeFields = new Map(); + /** + * List of "update after" on records that have been registered. + * These are processed after any explicit update and computed/related + * fields. + */ + this._toUpdateAfters = []; + } + + /** + * Called when all JS modules that register or patch models have been + * done. This launches generation of models. + */ + start(env) { + this.env = env; + /** + * Generate the models. + */ + Object.assign(this.env.models, this._generateModels()); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns all records of provided model that match provided criteria. + * + * @param {mail.model} Model class + * @param {function} [filterFunc] + * @returns {mail.model[]} records matching criteria. + */ + all(Model, filterFunc) { + const allRecords = Object.values(this._records) + .filter(e => e instanceof Model); + if (filterFunc) { + return allRecords.filter(filterFunc); + } + return allRecords; + } + + /** + * Register a record that has been created, and manage update of records + * from this record creation. + * + * @param {mail.model} Model class + * @param {Object} [data={}] + * @returns {mail.model} newly created record + */ + create(Model, data = {}) { + const record = new Model({ valid: true }); + Object.defineProperty(record, 'env', { get: () => Model.env }); + record.localId = record._createRecordLocalId(data); + + // Make state, which contain field values of record that have to + // be observed in store. + this.env.store.state[record.localId] = {}; + record.__state = this.env.store.state[record.localId]; + + // Make proxified record, so that access to field redirects + // to field getter. + const proxifiedRecord = this._makeProxifiedRecord(record); + this._records[record.localId] = proxifiedRecord; + proxifiedRecord.init(); + this._makeDefaults(proxifiedRecord); + + const data2 = Object.assign({}, data); + for (const field of Object.values(Model.fields)) { + if (field.fieldType !== 'relation') { + continue; + } + if (!field.autocreate) { + continue; + } + data2[field.fieldName] = [['create']]; + } + + for (const field of Object.values(Model.fields)) { + if (field.compute || field.related) { + // new record should always invoke computed fields. + this.registerToComputeField(record, field); + } + } + + this.update(proxifiedRecord, data2); + + return proxifiedRecord; + } + + /** + * Delete the record. After this operation, it's as if this record never + * existed. Note that relation are removed, which may delete more relations + * if some of them are causal. + * + * @param {mail.model} record + */ + delete(record) { + const Model = record.constructor; + if (!this.get(Model, record)) { + // Record has already been deleted. + // (e.g. unlinking one of its reverse relation was causal) + return; + } + const data = {}; + const recordRelations = Object.values(Model.fields) + .filter(field => field.fieldType === 'relation'); + for (const relation of recordRelations) { + if (relation.isCausal) { + switch (relation.relationType) { + case 'one2one': + case 'many2one': + if (record[relation.fieldName]) { + record[relation.fieldName].delete(); + } + break; + case 'one2many': + case 'many2many': + for (const relatedRecord of record[relation.fieldName]) { + relatedRecord.delete(); + } + break; + } + } + data[relation.fieldName] = [['unlink-all']]; + } + record.update(data); + delete this._records[record.localId]; + delete this.env.store.state[record.localId]; + } + + /** + * Delete all records. + */ + deleteAll() { + for (const record of Object.values(this._records)) { + record.delete(); + } + } + + /** + * Get the record of provided model that has provided + * criteria, if it exists. + * + * @param {mail.model} Model class + * @param {function} findFunc + * @returns {mail.model|undefined} the record of model matching criteria, if + * exists. + */ + find(Model, findFunc) { + return this.all(Model).find(findFunc); + } + + /** + * This method returns the record of provided model that matches provided + * record/local id. Useful to convert a local id to a record, and also to + * determine whether the record is still "alive" (i.e. not deleted). Note + * that even if there's a record in the system having provided local id, if + * the resulting record is not an instance of this model, this getter + * assumes the record does not exist. + * + * @param {mail.model} Model class + * @param {string|mail.model|undefined} recordOrLocalId + * @returns {mail.model|undefined} record, if exists + */ + get(Model, recordOrLocalId) { + if (recordOrLocalId === undefined) { + return undefined; + } + const record = this._records[ + recordOrLocalId instanceof this.env.models['mail.model'] + ? recordOrLocalId.localId + : recordOrLocalId + ]; + if (!(record instanceof Model)) { + return; + } + return record; + } + + /** + * This method creates a record or updates one of provided Model, based on + * provided data. This method assumes that records are uniquely identifiable + * per "unique find" criteria from data on Model. + * + * @param {mail.model} Model class + * @param {Object} data + * @returns {mail.model} created or updated record. + */ + insert(Model, data) { + let record = Model.find(Model._findFunctionFromData(data)); + if (!record) { + record = Model.create(data); + } else { + record.update(data); + } + return record; + } + + /** + * Process an update on provided record with provided data. Updating + * a record consists of applying direct updates first (i.e. explicit + * ones from `data`) and then indirect ones (i.e. compute/related fields + * and "after updates"). + * + * @param {mail.model} record + * @param {Object} data + */ + update(record, data) { + if (!this._isInUpdateCycle) { + this._isInUpdateCycle = true; + this._updateDirect(record, data); + while ( + this._toComputeFields.size > 0 || + this._toUpdateAfters.length > 0 + ) { + if (this._toComputeFields.size > 0) { + this._updateComputes(); + } else { + this._isHandlingToUpdateAfters = true; + // process one update after + const [recordToUpdate, previous] = this._toUpdateAfters.pop(); + const RecordToUpdateModel = recordToUpdate.constructor; + if (this.get(RecordToUpdateModel, recordToUpdate)) { + recordToUpdate._updateAfter(previous); + } + this._isHandlingToUpdateAfters = false; + } + } + this._toComputeFields.clear(); + this._isInUpdateCycle = false; + } else { + this._updateDirect(record, data); + if (this._isHandlingToUpdateAfters) { + this._updateComputes(); + } + } + } + + /** + * Register a pair record/field for the compute step of the update cycle in + * progress. + * + * @param {mail.model} record + * @param {ModelField} field + */ + registerToComputeField(record, field) { + const entry = [record.localId, field.fieldName].join(COMPUTE_RECORD_FIELD_INNER_SEPARATOR); + this._toComputeFields.set(entry, true); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {mail.model} Model class + * @param {Object} patch + */ + _applyModelPatchFields(Model, patch) { + for (const [fieldName, field] of Object.entries(patch)) { + if (!Model.fields[fieldName]) { + Model.fields[fieldName] = field; + } else { + Object.assign(Model.fields[fieldName].dependencies, field.dependencies); + } + } + } + + /** + * @private + * @param {Object} Models + * @throws {Error} in case some declared fields are not correct. + */ + _checkDeclaredFieldsOnModels(Models) { + for (const Model of Object.values(Models)) { + for (const fieldName in Model.fields) { + const field = Model.fields[fieldName]; + // 0. Get parented declared fields + const parentedMatchingFields = []; + let TargetModel = Model.__proto__; + while (Models[TargetModel.modelName]) { + if (TargetModel.fields) { + const matchingField = TargetModel.fields[fieldName]; + if (matchingField) { + parentedMatchingFields.push(matchingField); + } + } + TargetModel = TargetModel.__proto__; + } + // 1. Field type is required. + if (!(['attribute', 'relation'].includes(field.fieldType))) { + throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`); + } + // 2. Invalid keys based on field type. + if (field.fieldType === 'attribute') { + const invalidKeys = Object.keys(field).filter(key => + ![ + 'autocreate', + 'compute', + 'default', + 'dependencies', + 'fieldType', + 'related', + ].includes(key) + ); + if (invalidKeys.length > 0) { + throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`); + } + } + if (field.fieldType === 'relation') { + const invalidKeys = Object.keys(field).filter(key => + ![ + 'autocreate', + 'compute', + 'dependencies', + 'fieldType', + 'inverse', + 'isCausal', + 'related', + 'relationType', + 'to', + ].includes(key) + ); + if (invalidKeys.length > 0) { + throw new Error(`Field "${Model.modelName}/${fieldName}" contains some invalid keys: "${invalidKeys.join(", ")}".`); + } + if (!Models[field.to]) { + throw new Error(`Relational field "${Model.modelName}/${fieldName}" targets to unknown model name "${field.to}".`); + } + if (field.isCausal && !(['one2many', 'one2one'].includes(field.relationType))) { + throw new Error(`Relational field "${Model.modelName}/${fieldName}" has "isCausal" true with a relation of type "${field.relationType}" but "isCausal" is only supported for "one2many" and "one2one".`); + } + } + // 3. Computed field. + if (field.compute && !(typeof field.compute === 'string')) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" must be a string (instance method name).`); + } + if (field.compute && !(Model.prototype[field.compute])) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "compute" does not refer to an instance method of this Model.`); + } + if ( + field.dependencies && + (!field.compute && !parentedMatchingFields.some(field => field.compute)) + ) { + throw new Error(`Field "${Model.modelName}/${fieldName} contains dependendencies but no compute method in itself or parented matching fields (dependencies only make sense for compute fields)."`); + } + if ( + (field.compute || parentedMatchingFields.some(field => field.compute)) && + (field.dependencies || parentedMatchingFields.some(field => field.dependencies)) + ) { + if (!(field.dependencies instanceof Array)) { + throw new Error(`Compute field "${Model.modelName}/${fieldName}" dependencies must be an array of field names.`); + } + const unknownDependencies = field.dependencies.every(dependency => !(Model.fields[dependency])); + if (unknownDependencies.length > 0) { + throw new Error(`Compute field "${Model.modelName}/${fieldName}" contains some unknown dependencies: "${unknownDependencies.join(", ")}".`); + } + } + // 4. Related field. + if (field.compute && field.related) { + throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`); + } + if (field.related) { + if (!(typeof field.related === 'string')) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`); + } + const [relationName, relatedFieldName, other] = field.related.split('.'); + if (!relationName || !relatedFieldName || other) { + throw new Error(`Field "${Model.modelName}/${fieldName}" property "related" has invalid format.`); + } + // find relation on self or parents. + let relatedRelation; + let TargetModel = Model; + while (Models[TargetModel.modelName] && !relatedRelation) { + if (TargetModel.fields) { + relatedRelation = TargetModel.fields[relationName]; + } + TargetModel = TargetModel.__proto__; + } + if (!relatedRelation) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown relation name "${relationName}".`); + } + if (relatedRelation.fieldType !== 'relation') { + throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to non-relational field "${relationName}".`); + } + // Assuming related relation is valid... + // find field name on related model or any parents. + const RelatedModel = Models[relatedRelation.to]; + let relatedField; + TargetModel = RelatedModel; + while (Models[TargetModel.modelName] && !relatedField) { + if (TargetModel.fields) { + relatedField = TargetModel.fields[relatedFieldName]; + } + TargetModel = TargetModel.__proto__; + } + if (!relatedField) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" relates to unknown related model field "${relatedFieldName}".`); + } + if (relatedField.fieldType !== field.fieldType) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch type with its related model field.`); + } + if ( + relatedField.fieldType === 'relation' && + relatedField.to !== field.to + ) { + throw new Error(`Related field "${Model.modelName}/${fieldName}" has mismatch target model name with its related model field.`); + } + } + } + } + } + + /** + * @private + * @param {Object} Models + * @throws {Error} in case some fields are not correct. + */ + _checkProcessedFieldsOnModels(Models) { + for (const Model of Object.values(Models)) { + for (const fieldName in Model.fields) { + const field = Model.fields[fieldName]; + if (!(['attribute', 'relation'].includes(field.fieldType))) { + throw new Error(`Field "${Model.modelName}/${fieldName}" has unsupported type ${field.fieldType}.`); + } + if (field.compute && field.related) { + throw new Error(`Field "${Model.modelName}/${fieldName}" cannot be a related and compute field at the same time.`); + } + if (field.fieldType === 'attribute') { + continue; + } + if (!field.relationType) { + throw new Error( + `Field "${Model.modelName}/${fieldName}" must define a relation type in "relationType".` + ); + } + if (!(['one2one', 'one2many', 'many2one', 'many2many'].includes(field.relationType))) { + throw new Error( + `Field "${Model.modelName}/${fieldName}" has invalid relation type "${field.relationType}".` + ); + } + if (!field.inverse) { + throw new Error( + `Field "${ + Model.modelName + }/${ + fieldName + }" must define an inverse relation name in "inverse".` + ); + } + if (!field.to) { + throw new Error( + `Relation "${ + Model.modelNames + }/${ + fieldName + }" must define a model name in "to" (1st positional parameter of relation field helpers).` + ); + } + const RelatedModel = Models[field.to]; + if (!RelatedModel) { + throw new Error( + `Model name of relation "${Model.modelName}/${fieldName}" does not exist.` + ); + } + const inverseField = RelatedModel.fields[field.inverse]; + if (!inverseField) { + throw new Error( + `Relation "${ + Model.modelName + }/${ + fieldName + }" has no inverse field "${RelatedModel.modelName}/${field.inverse}".` + ); + } + if (inverseField.inverse !== fieldName) { + throw new Error( + `Inverse field name of relation "${ + Model.modelName + }/${ + fieldName + }" does not match with field name of relation "${ + RelatedModel.modelName + }/${ + inverseField.inverse + }".` + ); + } + const allSelfAndParentNames = []; + let TargetModel = Model; + while (TargetModel) { + allSelfAndParentNames.push(TargetModel.modelName); + TargetModel = TargetModel.__proto__; + } + if (!allSelfAndParentNames.includes(inverseField.to)) { + throw new Error( + `Relation "${ + Model.modelName + }/${ + fieldName + }" has inverse relation "${ + RelatedModel.modelName + }/${ + field.inverse + }" misconfigured (currently "${ + inverseField.to + }", should instead refer to this model or parented models: ${ + allSelfAndParentNames.map(name => `"${name}"`).join(', ') + }?)` + ); + } + if ( + (field.relationType === 'many2many' && inverseField.relationType !== 'many2many') || + (field.relationType === 'one2one' && inverseField.relationType !== 'one2one') || + (field.relationType === 'one2many' && inverseField.relationType !== 'many2one') || + (field.relationType === 'many2one' && inverseField.relationType !== 'one2many') + ) { + throw new Error( + `Mismatch relations types "${ + Model.modelName + }/${ + fieldName + }" (${ + field.relationType + }) and "${ + RelatedModel.modelName + }/${ + field.inverse + }" (${ + inverseField.relationType + }).` + ); + } + } + } + } + + /** + * @private + * @returns {Object} + * @throws {Error} in case it cannot generate models. + */ + _generateModels() { + const allNames = Object.keys(registry); + const Models = {}; + const generatedNames = []; + let toGenerateNames = [...allNames]; + while (toGenerateNames.length > 0) { + const generatable = toGenerateNames.map(name => registry[name]).find(entry => { + let isGenerateable = true; + for (const dependencyName of entry.dependencies) { + if (!generatedNames.includes(dependencyName)) { + isGenerateable = false; + } + } + return isGenerateable; + }); + if (!generatable) { + throw new Error(`Cannot generate following Model: ${toGenerateNames.join(', ')}`); + } + // Make environment accessible from Model. + const Model = generatable.factory(Models); + Object.defineProperty(Model, 'env', { get: () => this.env }); + for (const patch of generatable.patches) { + switch (patch.type) { + case 'class': + patchClassMethods(Model, patch.name, patch.patch); + break; + case 'instance': + patchInstanceMethods(Model, patch.name, patch.patch); + break; + case 'field': + this._applyModelPatchFields(Model, patch.patch); + break; + } + } + if (!Model.hasOwnProperty('modelName')) { + throw new Error(`Missing static property "modelName" on Model class "${Model.name}".`); + } + if (generatedNames.includes(Model.modelName)) { + throw new Error(`Duplicate model name "${Model.modelName}" shared on 2 distinct Model classes.`); + } + Models[Model.modelName] = Model; + generatedNames.push(Model.modelName); + toGenerateNames = toGenerateNames.filter(name => name !== Model.modelName); + } + /** + * Check that declared model fields are correct. + */ + this._checkDeclaredFieldsOnModels(Models); + /** + * Process declared model fields definitions, so that these field + * definitions are much easier to use in the system. For instance, all + * relational field definitions have an inverse, or fields track all their + * dependents. + */ + this._processDeclaredFieldsOnModels(Models); + /** + * Check that all model fields are correct, notably one relation + * should have matching reversed relation. + */ + this._checkProcessedFieldsOnModels(Models); + return Models; + } + + /** + * Make default values of its fields for newly created record. + * + * @private + * @param {mail.model} record + */ + _makeDefaults(record) { + const Model = record.constructor; + for (const field of Object.values(Model.fields)) { + if (field.fieldType === 'attribute') { + field.write(record, field.default, { registerDependents: false }); + } + if (field.fieldType === 'relation') { + if (['one2many', 'many2many'].includes(field.relationType)) { + // Ensure X2many relations are arrays by defaults. + field.write(record, [], { registerDependents: false }); + } else { + field.write(record, undefined, { registerDependents: false }); + } + } + } + } + + /** + * @private + * @param {mail.model} Model class + * @param {ModelField} field + * @returns {ModelField} + */ + _makeInverseRelationField(Model, field) { + const relFunc = + field.relationType === 'many2many' ? ModelField.many2many + : field.relationType === 'many2one' ? ModelField.one2many + : field.relationType === 'one2many' ? ModelField.many2one + : field.relationType === 'one2one' ? ModelField.one2one + : undefined; + if (!relFunc) { + throw new Error(`Cannot compute inverse Relation of "${Model.modelName}/${field.fieldName}".`); + } + const inverseField = new ModelField(Object.assign( + {}, + relFunc(Model.modelName, { inverse: field.fieldName }), + { + fieldName: `_inverse_${Model.modelName}/${field.fieldName}`, + modelManager: this, + } + )); + return inverseField; + } + + /** + * Wrap record that has just been created in a proxy. Proxy is useful for + * auto-getting records when accessing relational fields. + * + * @private + * @param {mail.model} record + * @return {Proxy<mail.model>} proxified record + */ + _makeProxifiedRecord(record) { + const proxifiedRecord = new Proxy(record, { + get: (target, k) => { + if (k === 'constructor') { + return target[k]; + } + const field = target.constructor.fields[k]; + if (!field) { + // No crash, we allow these reads due to patch() + // implementation details that read on `this._super` even + // if not set before-hand. + return target[k]; + } + return field.get(proxifiedRecord); + }, + set: (target, k, newVal) => { + if (target.constructor.fields[k]) { + throw new Error("Forbidden to write on record field without .update()!!"); + } else { + // No crash, we allow these writes due to following concerns: + // - patch() implementation details that write on `this._super` + // - record listeners that need setting on this with `.bind(this)` + target[k] = newVal; + } + return true; + }, + }); + return proxifiedRecord; + } + + /** + * This function processes definition of declared fields in provided models. + * Basically, models have fields declared in static prop `fields`, and this + * function processes and modifies them in place so that they are fully + * configured. For instance, model relations need bi-directional mapping, but + * inverse relation may be omitted in declared field: this function auto-fill + * this inverse relation. + * + * @private + * @param {Object} Models + */ + _processDeclaredFieldsOnModels(Models) { + /** + * 1. Prepare fields. + */ + for (const Model of Object.values(Models)) { + if (!Model.hasOwnProperty('fields')) { + Model.fields = {}; + } + Model.inverseRelations = []; + // Make fields aware of their field name. + for (const [fieldName, fieldData] of Object.entries(Model.fields)) { + Model.fields[fieldName] = new ModelField(Object.assign({}, fieldData, { + fieldName, + modelManager: this, + })); + } + } + /** + * 2. Auto-generate definitions of undeclared inverse relations. + */ + for (const Model of Object.values(Models)) { + for (const field of Object.values(Model.fields)) { + if (field.fieldType !== 'relation') { + continue; + } + if (field.inverse) { + continue; + } + const RelatedModel = Models[field.to]; + const inverseField = this._makeInverseRelationField(Model, field); + field.inverse = inverseField.fieldName; + RelatedModel.fields[inverseField.fieldName] = inverseField; + } + } + /** + * 3. Generate dependents and inverse-relates on fields. + * Field definitions are not yet combined, so registration of `dependents` + * may have to walk structural hierarchy of models in order to find + * the appropriate field. Also, while dependencies are defined just with + * field names, dependents require an additional data called a "hash" + * (= field id), which is a way to identify dependents in an inverse + * relation. This is necessary because dependents are a subset of an inverse + * relation. + */ + for (const Model of Object.values(Models)) { + for (const field of Object.values(Model.fields)) { + for (const dependencyFieldName of field.dependencies) { + let TargetModel = Model; + let dependencyField = TargetModel.fields[dependencyFieldName]; + while (!dependencyField) { + TargetModel = TargetModel.__proto__; + dependencyField = TargetModel.fields[dependencyFieldName]; + } + const dependent = [field.id, field.fieldName].join(DEPENDENT_INNER_SEPARATOR); + dependencyField.dependents = [ + ...new Set(dependencyField.dependents.concat([dependent])) + ]; + } + if (field.related) { + const [relationName, relatedFieldName] = field.related.split('.'); + let TargetModel = Model; + let relationField = TargetModel.fields[relationName]; + while (!relationField) { + TargetModel = TargetModel.__proto__; + relationField = TargetModel.fields[relationName]; + } + const relationFieldDependent = [ + field.id, + field.fieldName, + ].join(DEPENDENT_INNER_SEPARATOR); + relationField.dependents = [ + ...new Set(relationField.dependents.concat([relationFieldDependent])) + ]; + const OtherModel = Models[relationField.to]; + let OtherTargetModel = OtherModel; + let relatedField = OtherTargetModel.fields[relatedFieldName]; + while (!relatedField) { + OtherTargetModel = OtherTargetModel.__proto__; + relatedField = OtherTargetModel.fields[relatedFieldName]; + } + const relatedFieldDependent = [ + field.id, + relationField.inverse, + field.fieldName, + ].join(DEPENDENT_INNER_SEPARATOR); + relatedField.dependents = [ + ...new Set( + relatedField.dependents.concat([relatedFieldDependent]) + ) + ]; + } + } + } + /** + * 4. Extend definition of fields of a model with the definition of + * fields of its parents. Field definitions on self has precedence over + * parented fields. + */ + for (const Model of Object.values(Models)) { + Model.__combinedFields = {}; + for (const field of Object.values(Model.fields)) { + Model.__combinedFields[field.fieldName] = field; + } + let TargetModel = Model.__proto__; + while (TargetModel && TargetModel.fields) { + for (const targetField of Object.values(TargetModel.fields)) { + const field = Model.__combinedFields[targetField.fieldName]; + if (field) { + Model.__combinedFields[targetField.fieldName] = field.combine(targetField); + } else { + Model.__combinedFields[targetField.fieldName] = targetField; + } + } + TargetModel = TargetModel.__proto__; + } + } + for (const Model of Object.values(Models)) { + Model.fields = Model.__combinedFields; + delete Model.__combinedFields; + } + } + + /** + * Process registered computed fields in the current update cycle. + * + * @private + */ + _updateComputes() { + while (this._toComputeFields.size > 0) { + // process one compute field + const key = this._toComputeFields.keys().next().value; + const [recordLocalId, fieldName] = key.split(COMPUTE_RECORD_FIELD_INNER_SEPARATOR); + this._toComputeFields.delete(key); + const record = this.env.models['mail.model'].get(recordLocalId); + if (record) { + const Model = record.constructor; + const field = Model.fields[fieldName]; + field.doCompute(record); + } + } + } + + /** + * Process a direct update on given record with provided data. + * + * @private + * @param {mail.model} record + * @param {Object} data + */ + _updateDirect(record, data) { + const existing = this._toUpdateAfters.find(entry => entry[0] === record); + if (!existing) { + // queue updateAfter before calling field.set to ensure previous + // contains the value at the start of update cycle + this._toUpdateAfters.push([record, record._updateBefore()]); + } + for (const [k, v] of Object.entries(data)) { + const Model = record.constructor; + const field = Model.fields[k]; + if (!field) { + throw new Error(`Cannot create/update record with data unrelated to a field. (model: "${Model.modelName}", non-field attempted update: "${k}")`); + } + field.set(record, v); + } + } + +} + +return ModelManager; + +}); diff --git a/addons/mail/static/src/models/activity/activity.js b/addons/mail/static/src/models/activity/activity.js new file mode 100644 index 00000000000..12c40bd8bc5 --- /dev/null +++ b/addons/mail/static/src/models/activity/activity.js @@ -0,0 +1,289 @@ +odoo.define('mail/static/src/models/activity/activity/js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Activity extends dependencies['mail.model'] { + + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Delete the record from database and locally. + */ + async deleteServerRecord() { + await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'unlink', + args: [[this.id]], + })); + this.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('activity_category' in data) { + data2.category = data.activity_category; + } + if ('can_write' in data) { + data2.canWrite = data.can_write; + } + if ('create_data' in data) { + data2.dateCreate = data.create_date; + } + if ('date_deadline' in data) { + data2.dateDeadline = data.date_deadline; + } + if ('force_next' in data) { + data2.force_next = data.force_next; + } + if ('icon' in data) { + data2.icon = data.icon; + } + if ('id' in data) { + data2.id = data.id; + } + if ('note' in data) { + data2.note = data.note; + } + if ('res_id' in data) { + data2.res_id = data.res_id; + } + if ('res_model' in data) { + data2.res_model = data.res_model; + } + if ('state' in data) { + data2.state = data.state; + } + if ('summary' in data) { + data2.summary = data.summary; + } + + // relation + if ('activity_type_id' in data) { + if (!data.activity_type_id) { + data2.type = [['unlink-all']]; + } else { + data2.type = [ + ['insert', { + displayName: data.activity_type_id[1], + id: data.activity_type_id[0], + }], + ]; + } + } + if ('create_uid' in data) { + if (!data.create_uid) { + data2.creator = [['unlink-all']]; + } else { + data2.creator = [ + ['insert', { + id: data.create_uid[0], + partnerDisplayName: data.create_uid[1], + }], + ]; + } + } + if ('mail_template_ids' in data) { + data2.mailTemplates = [['insert', data.mail_template_ids]]; + } + if ('user_id' in data) { + if (!data.user_id) { + data2.assignee = [['unlink-all']]; + } else { + data2.assignee = [ + ['insert', { + id: data.user_id[0], + partnerDisplayName: data.user_id[1], + }], + ]; + } + } + + return data2; + } + + /** + * Opens (legacy) form view dialog to edit current activity and updates + * the activity when dialog is closed. + */ + edit() { + const action = { + type: 'ir.actions.act_window', + name: this.env._t("Schedule Activity"), + res_model: 'mail.activity', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: this.res_id, + default_res_model: this.res_model, + }, + res_id: this.id, + }; + this.env.bus.trigger('do-action', { + action, + options: { on_close: () => this.fetchAndUpdate() }, + }); + } + + async fetchAndUpdate() { + const [data] = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [this.id], + })); + this.update(this.constructor.convertData(data)); + if (this.chatter) { + this.chatter.refresh(); + } + } + + /** + * @param {Object} param0 + * @param {mail.attachment[]} [param0.attachments=[]] + * @param {string|boolean} [param0.feedback=false] + */ + async markAsDone({ attachments = [], feedback = false }) { + const attachmentIds = attachments.map(attachment => attachment.id); + await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'action_feedback', + args: [[this.id]], + kwargs: { + attachment_ids: attachmentIds, + feedback, + }, + context: this.chatter ? this.chatter.context : {}, + })); + if (this.chatter) { + this.chatter.refresh(); + } + this.delete(); + } + + /** + * @param {Object} param0 + * @param {string} param0.feedback + * @returns {Object} + */ + async markAsDoneAndScheduleNext({ feedback }) { + const action = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'action_feedback_schedule_next', + args: [[this.id]], + kwargs: { feedback }, + })); + const chatter = this.chatter; + if (chatter) { + this.chatter.refresh(); + } + this.delete(); + this.env.bus.trigger('do-action', { + action, + options: { + on_close: () => { + if (chatter) { + chatter.refreshActivities(); + } + }, + }, + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerAssignee() { + if (!this.assigneePartner || !this.messagingCurrentPartner) { + return false; + } + return this.assigneePartner === this.messagingCurrentPartner; + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + } + + Activity.fields = { + assignee: many2one('mail.user'), + assigneePartner: many2one('mail.partner', { + related: 'assignee.partner', + }), + attachments: many2many('mail.attachment', { + inverse: 'activities', + }), + canWrite: attr({ + default: false, + }), + category: attr(), + chatter: many2one('mail.chatter', { + inverse: 'activities', + }), + creator: many2one('mail.user'), + dateCreate: attr(), + dateDeadline: attr(), + force_next: attr({ + default: false, + }), + icon: attr(), + id: attr(), + isCurrentPartnerAssignee: attr({ + compute: '_computeIsCurrentPartnerAssignee', + default: false, + dependencies: [ + 'assigneePartner', + 'messagingCurrentPartner', + ], + }), + mailTemplates: many2many('mail.mail_template', { + inverse: 'activities', + }), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + note: attr(), + res_id: attr(), + res_model: attr(), + state: attr(), + summary: attr(), + type: many2one('mail.activity_type', { + inverse: 'activities', + }), + }; + + Activity.modelName = 'mail.activity'; + + return Activity; +} + +registerNewModel('mail.activity', factory); + +}); diff --git a/addons/mail/static/src/models/activity_type/activity_type.js b/addons/mail/static/src/models/activity_type/activity_type.js new file mode 100644 index 00000000000..7af2ae74095 --- /dev/null +++ b/addons/mail/static/src/models/activity_type/activity_type.js @@ -0,0 +1,26 @@ +odoo.define('mail/static/src/models/activity_type/activity_type.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ActivityType extends dependencies['mail.model'] {} + + ActivityType.fields = { + activities: one2many('mail.activity', { + inverse: 'type', + }), + displayName: attr(), + id: attr(), + }; + + ActivityType.modelName = 'mail.activity_type'; + + return ActivityType; +} + +registerNewModel('mail.activity_type', factory); + +}); diff --git a/addons/mail/static/src/models/attachment/attachment.js b/addons/mail/static/src/models/attachment/attachment.js new file mode 100644 index 00000000000..0454890401c --- /dev/null +++ b/addons/mail/static/src/models/attachment/attachment.js @@ -0,0 +1,342 @@ +odoo.define('mail/static/src/models/attachment/attachment.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + let nextTemporaryId = -1; + function getAttachmentNextTemporaryId() { + const id = nextTemporaryId; + nextTemporaryId -= 1; + return id; + } + class Attachment extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('filename' in data) { + data2.filename = data.filename; + } + if ('id' in data) { + data2.id = data.id; + } + if ('mimetype' in data) { + data2.mimetype = data.mimetype; + } + if ('name' in data) { + data2.name = data.name; + } + + // relation + if ('res_id' in data && 'res_model' in data) { + data2.originThread = [['insert', { + id: data.res_id, + model: data.res_model, + }]]; + } + + return data2; + } + + /** + * View provided attachment(s), with given attachment initially. Prompts + * the attachment viewer. + * + * @param {Object} param0 + * @param {mail.attachment} [param0.attachment] + * @param {mail.attachment>[]} param0.attachments + * @returns {string|undefined} unique id of open dialog, if open + */ + static view({ attachment, attachments }) { + if (!attachments || attachments.length === 0) { + return; + } + if (!attachment) { + attachment = attachments[0]; + } + if (!attachments.includes(attachment)) { + return; + } + this.env.messaging.dialogManager.open('mail.attachment_viewer', { + attachment: [['replace', attachment]], + attachments: [['replace', attachments]], + }); + } + + /** + * Remove this attachment globally. + */ + async remove() { + await this.async(() => this.env.services.rpc({ + model: 'ir.attachment', + method: 'unlink', + args: [this.id], + }, { shadow: true })); + this.delete(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.composer[]} + */ + _computeComposers() { + if (this.isTemporary) { + return []; + } + const relatedTemporaryAttachment = this.env.models['mail.attachment'] + .find(attachment => + attachment.filename === this.filename && + attachment.isTemporary + ); + if (relatedTemporaryAttachment) { + const composers = relatedTemporaryAttachment.composers; + relatedTemporaryAttachment.delete(); + return [['replace', composers]]; + } + return []; + } + + /** + * @private + * @returns {string} + */ + _computeDefaultSource() { + if (this.fileType === 'image') { + return `/web/image/${this.id}?unique=1&signature=${this.checksum}&model=ir.attachment`; + } + if (this.fileType === 'application/pdf') { + return `/web/static/lib/pdfjs/web/viewer.html?file=/web/content/${this.id}?model%3Dir.attachment`; + } + if (this.fileType && this.fileType.includes('text')) { + return `/web/content/${this.id}?model%3Dir.attachment`; + } + if (this.fileType === 'youtu') { + const urlArr = this.url.split('/'); + let token = urlArr[urlArr.length - 1]; + if (token.includes('watch')) { + token = token.split('v=')[1]; + const amp = token.indexOf('&'); + if (amp !== -1) { + token = token.substring(0, amp); + } + } + return `https://www.youtube.com/embed/${token}`; + } + if (this.fileType === 'video') { + return `/web/image/${this.id}?model=ir.attachment`; + } + return undefined; + } + + /** + * @private + * @returns {string} + */ + _computeDisplayName() { + return this.name || this.filename; + } + + /** + * @private + * @returns {string|undefined} + */ + _computeExtension() { + return this.filename && this.filename.split('.').pop(); + } + + /** + * @private + * @returns {string|undefined} + */ + _computeFileType() { + if (this.type === 'url' && !this.url) { + return undefined; + } else if (!this.mimetype) { + return undefined; + } + const match = this.type === 'url' + ? this.url.match('(youtu|.png|.jpg|.gif)') + : this.mimetype.match('(image|video|application/pdf|text)'); + if (!match) { + return undefined; + } + if (match[1].match('(.png|.jpg|.gif)')) { + return 'image'; + } + return match[1]; + } + + /** + * @private + * @returns {integer} + */ + _computeId() { + if (this.isTemporary && (this.id === undefined || this.id > 0)) { + return getAttachmentNextTemporaryId(); + } + return this.id; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsLinkedToComposer() { + return this.composers.length > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsTextFile() { + if (!this.fileType) { + return false; + } + return this.fileType.includes('text'); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsViewable() { + return ( + this.mediaType === 'image' || + this.mediaType === 'video' || + this.mimetype === 'application/pdf' || + this.isTextFile + ); + } + + /** + * @private + * @returns {string} + */ + _computeMediaType() { + return this.mimetype && this.mimetype.split('/').shift(); + } + + /** + * @override + */ + _createRecordLocalId(data) { + const { id, isTemporary = false } = data; + const Attachment = this.env.models['mail.attachment']; + if (isTemporary) { + return `${Attachment.modelName}_${nextTemporaryId}`; + } + return `${Attachment.modelName}_${id}`; + } + + } + + Attachment.fields = { + activities: many2many('mail.activity', { + inverse: 'attachments', + }), + attachmentViewer: many2many('mail.attachment_viewer', { + inverse: 'attachments', + }), + checkSum: attr(), + composers: many2many('mail.composer', { + compute: '_computeComposers', + inverse: 'attachments', + }), + defaultSource: attr({ + compute: '_computeDefaultSource', + dependencies: [ + 'checkSum', + 'fileType', + 'id', + 'url', + ], + }), + displayName: attr({ + compute: '_computeDisplayName', + dependencies: [ + 'filename', + 'name', + ], + }), + extension: attr({ + compute: '_computeExtension', + dependencies: ['filename'], + }), + filename: attr(), + fileType: attr({ + compute: '_computeFileType', + dependencies: [ + 'mimetype', + 'type', + 'url', + ], + }), + id: attr({ + compute: '_computeId', + dependencies: ['isTemporary'], + }), + isLinkedToComposer: attr({ + compute: '_computeIsLinkedToComposer', + dependencies: ['composers'], + }), + isTemporary: attr({ + default: false, + }), + isTextFile: attr({ + compute: '_computeIsTextFile', + dependencies: ['fileType'], + }), + isViewable: attr({ + compute: '_computeIsViewable', + dependencies: [ + 'mediaType', + 'isTextFile', + 'mimetype', + ], + }), + mediaType: attr({ + compute: '_computeMediaType', + dependencies: ['mimetype'], + }), + messages: many2many('mail.message', { + inverse: 'attachments', + }), + mimetype: attr({ + default: '', + }), + name: attr(), + originThread: many2one('mail.thread', { + inverse: 'originThreadAttachments', + }), + size: attr(), + threads: many2many('mail.thread', { + inverse: 'attachments', + }), + type: attr(), + url: attr(), + }; + + Attachment.modelName = 'mail.attachment'; + + return Attachment; +} + +registerNewModel('mail.attachment', factory); + +}); diff --git a/addons/mail/static/src/models/attachment/attachment_tests.js b/addons/mail/static/src/models/attachment/attachment_tests.js new file mode 100644 index 00000000000..b2761f0d99d --- /dev/null +++ b/addons/mail/static/src/models/attachment/attachment_tests.js @@ -0,0 +1,156 @@ +odoo.define('mail/static/src/models/attachment/attachment_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('attachment', {}, function () { +QUnit.module('attachment_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + this.env = undefined; + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + }, +}); + +QUnit.test('create (txt)', async function (assert) { + assert.expect(9); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(this.env.models['mail.attachment'].find(attachment => attachment.id === 750), attachment); + assert.strictEqual(attachment.filename, "test.txt"); + assert.strictEqual(attachment.id, 750); + assert.notOk(attachment.isTemporary); + assert.strictEqual(attachment.mimetype, 'text/plain'); + assert.strictEqual(attachment.name, "test.txt"); +}); + +QUnit.test('displayName', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment.displayName, "test.txt"); +}); + +QUnit.test('extension', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment.extension, 'txt'); +}); + +QUnit.test('fileType', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => + attachment.id === 750) + ); + assert.strictEqual(attachment.fileType, 'text'); +}); + +QUnit.test('isTextFile', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.ok(attachment.isTextFile); +}); + +QUnit.test('isViewable', async function (assert) { + assert.expect(5); + + await this.start(); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + + const attachment = this.env.models['mail.attachment'].create({ + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }); + assert.ok(attachment); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.strictEqual(attachment, this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.ok(attachment.isViewable); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js new file mode 100644 index 00000000000..7f9da0c7a8f --- /dev/null +++ b/addons/mail/static/src/models/attachment_viewer/attachment_viewer.js @@ -0,0 +1,44 @@ +odoo.define('mail/static/src/models/attachment_viewer/attachment_viewer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class AttachmentViewer extends dependencies['mail.model'] {} + + AttachmentViewer.fields = { + /** + * Angle of the image. Changes when the user rotates it. + */ + angle: attr({ + default: 0, + }), + attachment: many2one('mail.attachment'), + attachments: many2many('mail.attachment', { + inverse: 'attachmentViewer', + }), + /** + * Determine whether the image is loading or not. Useful to diplay + * a spinner when loading image initially. + */ + isImageLoading: attr({ + default: false, + }), + /** + * Scale size of the image. Changes when user zooms in/out. + */ + scale: attr({ + default: 1, + }), + }; + + AttachmentViewer.modelName = 'mail.attachment_viewer'; + + return AttachmentViewer; +} + +registerNewModel('mail.attachment_viewer', factory); + +}); diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js new file mode 100644 index 00000000000..e08fc9029b2 --- /dev/null +++ b/addons/mail/static/src/models/chat_window/chat_window.js @@ -0,0 +1,322 @@ +odoo.define('mail/static/src/models/chat_window/chat_window.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ChatWindow extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close this chat window. + */ + close() { + const thread = this.thread; + this.delete(); + if (thread) { + thread.update({ pendingFoldState: 'closed' }); + } + } + + expand() { + if (this.thread) { + this.thread.openExpanded(); + } + } + + /** + * Programmatically auto-focus an existing chat window. + */ + focus() { + this.update({ isDoFocus: true }); + } + + focusNextVisibleUnfoldedChatWindow() { + const nextVisibleUnfoldedChatWindow = this._getNextVisibleUnfoldedChatWindow(); + if (nextVisibleUnfoldedChatWindow) { + nextVisibleUnfoldedChatWindow.focus(); + } + } + + focusPreviousVisibleUnfoldedChatWindow() { + const previousVisibleUnfoldedChatWindow = + this._getNextVisibleUnfoldedChatWindow({ reverse: true }); + if (previousVisibleUnfoldedChatWindow) { + previousVisibleUnfoldedChatWindow.focus(); + } + } + + fold() { + if (this.thread) { + this.thread.update({ pendingFoldState: 'folded' }); + } else { + this.update({ _isFolded: true }); + } + } + + /** + * Assume that this chat window was hidden before-hand. + */ + makeVisible() { + const lastVisible = this.manager.lastVisible; + this.manager.swap(this, lastVisible); + } + + /** + * Shift this chat window to the left on screen. + */ + shiftLeft() { + this.manager.shiftLeft(this); + } + + /** + * Shift this chat window to the right on screen. + */ + shiftRight() { + this.manager.shiftRight(this); + } + + unfold() { + if (this.thread) { + this.thread.update({ pendingFoldState: 'open' }); + } else { + this.update({ _isFolded: false }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {boolean} + */ + _computeHasShiftLeft() { + if (!this.manager) { + return false; + } + const allVisible = this.manager.allOrderedVisible; + const index = allVisible.findIndex(visible => visible === this); + if (index === -1) { + return false; + } + return index < allVisible.length - 1; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasShiftRight() { + if (!this.manager) { + return false; + } + const index = this.manager.allOrderedVisible.findIndex(visible => visible === this); + if (index === -1) { + return false; + } + return index > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsFolded() { + const thread = this.thread; + if (thread) { + return thread.foldState === 'folded'; + } + return this._isFolded; + } + + /** + * @private + * @returns {string} + */ + _computeName() { + if (this.thread) { + return this.thread.displayName; + } + return this.env._t("New message"); + } + + /** + * @private + * @returns {integer} + */ + _computeVisibleOffset() { + if (!this.manager) { + return 0; + } + const visible = this.manager.visual.visible; + const index = visible.findIndex(visible => visible.chatWindowLocalId === this.localId); + if (index === -1) { + return 0; + } + return visible[index].offset; + } + + /** + * Cycles to the next possible visible and unfolded chat window starting + * from the `currentChatWindow`, following the natural order based on the + * current text direction, and with the possibility to `reverse` based on + * the given parameter. + * + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.reverse=false] + * @returns {mail.chat_window|undefined} + */ + _getNextVisibleUnfoldedChatWindow({ reverse = false } = {}) { + const orderedVisible = this.manager.allOrderedVisible; + /** + * Return index of next visible chat window of a given visible chat + * window index. The direction of "next" chat window depends on + * `reverse` option. + * + * @param {integer} index + * @returns {integer} + */ + const _getNextIndex = index => { + const directionOffset = reverse ? -1 : 1; + let nextIndex = index + directionOffset; + if (nextIndex > orderedVisible.length - 1) { + nextIndex = 0; + } + if (nextIndex < 0) { + nextIndex = orderedVisible.length - 1; + } + return nextIndex; + }; + + const currentIndex = orderedVisible.findIndex(visible => visible === this); + let nextIndex = _getNextIndex(currentIndex); + let nextToFocus = orderedVisible[nextIndex]; + while (nextToFocus.isFolded) { + nextIndex = _getNextIndex(nextIndex); + nextToFocus = orderedVisible[nextIndex]; + } + return nextToFocus; + } + + /** + * @override + */ + _updateAfter(previous) { + // thread + if (previous.thread && this.thread !== previous.thread) { + this.update({ threadInitialScrollTop: undefined }); + } + } + + /** + * @override + */ + _updateBefore() { + return { + thread: this.thread, + }; + } + + } + + ChatWindow.fields = { + /** + * Determine whether the chat window is folded or not, when not + * linked to a thread. + * Note: this value only make sense for chat window not linked + * to a thread. State of chat window of a thread is entirely + * based on thread.foldState. @see isFolded . + */ + _isFolded: attr({ + default: false, + }), + hasShiftLeft: attr({ + compute: '_computeHasShiftLeft', + dependencies: ['managerAllOrderedVisible'], + default: false, + }), + hasShiftRight: attr({ + compute: '_computeHasShiftRight', + dependencies: ['managerAllOrderedVisible'], + default: false, + }), + /** + * Determine whether the chat window should be programmatically + * focused by observed component of chat window. Those components + * are responsible to unmark this record afterwards, otherwise + * any re-render will programmatically set focus again! + */ + isDoFocus: attr({ + default: false, + }), + /** + * Determine whether the chat window is focused or not. Useful for + * visual clue. + */ + isFocused: attr({ + default: false, + }), + isFolded: attr({ + compute: '_computeIsFolded', + dependencies: [ + 'thread', + 'threadFoldState', + '_isFolded', + ], + default: false, + }), + manager: many2one('mail.chat_window_manager', { + inverse: 'chatWindows', + }), + managerAllOrderedVisible: one2many('mail.chat_window', { + related: 'manager.allOrderedVisible', + }), + managerVisual: attr({ + related: 'manager.visual', + }), + name: attr({ + compute: '_computeName', + dependencies: [ + 'thread', + 'threadDisplayName', + ], + }), + /** + * If set, this is the scroll top position of the thread of this + * chat window to put initially on mount. + */ + threadInitialScrollTop: attr(), + thread: one2one('mail.thread', { + related: 'threadViewer.thread', + }), + threadDisplayName: attr({ + related: 'thread.displayName', + }), + threadFoldState: attr({ + related: 'thread.foldState', + }), + threadViewer: one2one('mail.thread_viewer', { + inverse: 'chatWindow', + }), + visibleOffset: attr({ + compute: '_computeVisibleOffset', + dependencies: ['managerVisual'], + }), + }; + + ChatWindow.modelName = 'mail.chat_window'; + + return ChatWindow; +} + +registerNewModel('mail.chat_window', factory); + +}); diff --git a/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js new file mode 100644 index 00000000000..3b054f83a30 --- /dev/null +++ b/addons/mail/static/src/models/chat_window_manager/chat_window_manager.js @@ -0,0 +1,461 @@ +odoo.define('mail/static/src/models/chat_window_manager/chat_window_manager.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + const BASE_VISUAL = { + /** + * Amount of visible slots available for chat windows. + */ + availableVisibleSlots: 0, + /** + * Data related to the hidden menu. + */ + hidden: { + /** + * List of hidden docked chat windows. Useful to compute counter. + * Chat windows are ordered by their `chatWindows` order. + */ + chatWindowLocalIds: [], + /** + * Whether hidden menu is visible or not + */ + isVisible: false, + /** + * Offset of hidden menu starting point from the starting point + * of chat window manager. Makes only sense if it is visible. + */ + offset: 0, + }, + /** + * Data related to visible chat windows. Index determine order of + * docked chat windows. + * + * Value: + * + * { + * chatWindowLocalId, + * offset, + * } + * + * Offset is offset of starting point of docked chat window from + * starting point of dock chat window manager. Docked chat windows + * are ordered by their `chatWindows` order + */ + visible: [], + }; + + + class ChatWindowManager extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close all chat windows. + * + */ + closeAll() { + const chatWindows = this.allOrdered; + for (const chatWindow of chatWindows) { + chatWindow.close(); + } + } + + closeHiddenMenu() { + this.update({ isHiddenMenuOpen: false }); + } + + openHiddenMenu() { + this.update({ isHiddenMenuOpen: true }); + } + + openNewMessage() { + let newMessageChatWindow = this.newMessageChatWindow; + if (!newMessageChatWindow) { + newMessageChatWindow = this.env.models['mail.chat_window'].create({ + manager: [['link', this]], + }); + } + newMessageChatWindow.makeVisible(); + newMessageChatWindow.unfold(); + newMessageChatWindow.focus(); + } + + /** + * @param {mail.thread} thread + * @param {Object} [param1={}] + * @param {string} [param1.mode] + */ + openThread(thread, { mode } = {}) { + let chatWindow = this.env.models['mail.chat_window'].find(chatWindow => + chatWindow.thread === thread + ); + if (!chatWindow) { + chatWindow = this.env.models['mail.chat_window'].create({ + manager: [['link', this]], + threadViewer: [['create', { + thread: [['link', thread]], + }]], + }); + } + if (mode === 'last_visible' && !chatWindow.isVisible) { + chatWindow.makeVisible(); + chatWindow.unfold(); + chatWindow.focus(); + } + if (mode === 'from_new_message') { + if (!this.newMessageChatWindow) { + throw new Error('Cannot open thread in chat window in mode "from_new_message" without any new message chat window'); + } + this.swap(chatWindow, this.newMessageChatWindow); + this.newMessageChatWindow.close(); + chatWindow.makeVisible(); + chatWindow.unfold(); + chatWindow.focus(); + } + } + + /** + * Shift provided chat window to the left on screen. + * + * @param {mail.chat_window} chatWindow + */ + shiftLeft(chatWindow) { + const chatWindows = this.allOrdered; + const index = chatWindows.findIndex(cw => cw === chatWindow); + if (index === chatWindows.length - 1) { + // already left-most + return; + } + const otherChatWindow = chatWindows[index + 1]; + const _newOrdered = [...this._ordered]; + _newOrdered[index] = otherChatWindow.localId; + _newOrdered[index + 1] = chatWindow.localId; + this.update({ _ordered: _newOrdered }); + chatWindow.focus(); + } + + /** + * Shift provided chat window to the right on screen. + * + * @param {mail.chat_window} chatWindow + */ + shiftRight(chatWindow) { + const chatWindows = this.allOrdered; + const index = chatWindows.findIndex(cw => cw === chatWindow); + if (index === 0) { + // already right-most + return; + } + const otherChatWindow = chatWindows[index - 1]; + const _newOrdered = [...this._ordered]; + _newOrdered[index] = otherChatWindow.localId; + _newOrdered[index - 1] = chatWindow.localId; + this.update({ _ordered: _newOrdered }); + chatWindow.focus(); + } + + /** + * @param {mail.chat_window} chatWindow1 + * @param {mail.chat_window} chatWindow2 + */ + swap(chatWindow1, chatWindow2) { + const ordered = this.allOrdered; + const index1 = ordered.findIndex(chatWindow => chatWindow === chatWindow1); + const index2 = ordered.findIndex(chatWindow => chatWindow === chatWindow2); + if (index1 === -1 || index2 === -1) { + return; + } + const _newOrdered = [...this._ordered]; + _newOrdered[index1] = chatWindow2.localId; + _newOrdered[index2] = chatWindow1.localId; + this.update({ _ordered: _newOrdered }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string[]} + */ + _compute_ordered() { + // remove unlinked chatWindows + const _ordered = this._ordered.filter(chatWindowLocalId => + this.chatWindows.includes(this.env.models['mail.chat_window'].get(chatWindowLocalId)) + ); + // add linked chatWindows + for (const chatWindow of this.chatWindows) { + if (!_ordered.includes(chatWindow.localId)) { + _ordered.push(chatWindow.localId); + } + } + return _ordered; + } + + /** + * // FIXME: dependent on implementation that uses arbitrary order in relations!! + * + * @private + * @returns {mail.chat_window} + */ + _computeAllOrdered() { + return [['replace', this._ordered.map(chatWindowLocalId => + this.env.models['mail.chat_window'].get(chatWindowLocalId) + )]]; + } + + /** + * @private + * @returns {mail.chat_window[]} + */ + _computeAllOrderedHidden() { + return [['replace', this.visual.hidden.chatWindowLocalIds.map(chatWindowLocalId => + this.env.models['mail.chat_window'].get(chatWindowLocalId) + )]]; + } + + /** + * @private + * @returns {mail.chat_window[]} + */ + _computeAllOrderedVisible() { + return [['replace', this.visual.visible.map(({ chatWindowLocalId }) => + this.env.models['mail.chat_window'].get(chatWindowLocalId) + )]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasHiddenChatWindows() { + return this.allOrderedHidden.length > 0; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasVisibleChatWindows() { + return this.allOrderedVisible.length > 0; + } + + /** + * @private + * @returns {mail.chat_window|undefined} + */ + _computeLastVisible() { + const { length: l, [l - 1]: lastVisible } = this.allOrderedVisible; + if (!lastVisible) { + return [['unlink-all']]; + } + return [['replace', lastVisible]]; + } + + /** + * @private + * @returns {mail.chat_window|undefined} + */ + _computeNewMessageChatWindow() { + const chatWindow = this.allOrdered.find(chatWindow => !chatWindow.thread); + if (!chatWindow) { + return [['unlink']]; + } + return [['replace', chatWindow]]; + } + + /** + * @private + * @returns {integer} + */ + _computeUnreadHiddenConversationAmount() { + const allHiddenWithThread = this.allOrderedHidden.filter( + chatWindow => chatWindow.thread + ); + let amount = 0; + for (const chatWindow of allHiddenWithThread) { + if (chatWindow.thread.message_unread_counter > 0) { + amount++; + } + } + return amount; + } + + /** + * @private + * @returns {Object} + */ + _computeVisual() { + let visual = JSON.parse(JSON.stringify(BASE_VISUAL)); + if (!this.env.messaging) { + return visual; + } + const device = this.env.messaging.device; + const discuss = this.env.messaging.discuss; + const BETWEEN_GAP_WIDTH = 5; + const CHAT_WINDOW_WIDTH = 325; + const END_GAP_WIDTH = device.isMobile ? 0 : 10; + const GLOBAL_WINDOW_WIDTH = device.globalWindowInnerWidth; + const HIDDEN_MENU_WIDTH = 200; // max width, including width of dropup list items + const START_GAP_WIDTH = device.isMobile ? 0 : 10; + const chatWindows = this.allOrdered; + if (!device.isMobile && discuss.isOpen) { + return visual; + } + if (!chatWindows.length) { + return visual; + } + const relativeGlobalWindowWidth = GLOBAL_WINDOW_WIDTH - START_GAP_WIDTH - END_GAP_WIDTH; + let maxAmountWithoutHidden = Math.floor( + relativeGlobalWindowWidth / (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH)); + let maxAmountWithHidden = Math.floor( + (relativeGlobalWindowWidth - HIDDEN_MENU_WIDTH - BETWEEN_GAP_WIDTH) / + (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH)); + if (device.isMobile) { + maxAmountWithoutHidden = 1; + maxAmountWithHidden = 1; + } + if (chatWindows.length <= maxAmountWithoutHidden) { + // all visible + for (let i = 0; i < chatWindows.length; i++) { + const chatWindowLocalId = chatWindows[i].localId; + const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH); + visual.visible.push({ chatWindowLocalId, offset }); + } + visual.availableVisibleSlots = maxAmountWithoutHidden; + } else if (maxAmountWithHidden > 0) { + // some visible, some hidden + for (let i = 0; i < maxAmountWithHidden; i++) { + const chatWindowLocalId = chatWindows[i].localId; + const offset = START_GAP_WIDTH + i * (CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH); + visual.visible.push({ chatWindowLocalId, offset }); + } + if (chatWindows.length > maxAmountWithHidden) { + visual.hidden.isVisible = !device.isMobile; + visual.hidden.offset = visual.visible[maxAmountWithHidden - 1].offset + + CHAT_WINDOW_WIDTH + BETWEEN_GAP_WIDTH; + } + for (let j = maxAmountWithHidden; j < chatWindows.length; j++) { + visual.hidden.chatWindowLocalIds.push(chatWindows[j].localId); + } + visual.availableVisibleSlots = maxAmountWithHidden; + } else { + // all hidden + visual.hidden.isVisible = !device.isMobile; + visual.hidden.offset = START_GAP_WIDTH; + visual.hidden.chatWindowLocalIds.concat(chatWindows.map(chatWindow => chatWindow.localId)); + console.warn('cannot display any visible chat windows (screen is too small)'); + visual.availableVisibleSlots = 0; + } + return visual; + } + + } + + ChatWindowManager.fields = { + /** + * List of ordered chat windows (list of local ids) + */ + _ordered: attr({ + compute: '_compute_ordered', + default: [], + dependencies: [ + 'chatWindows', + ], + }), + // FIXME: dependent on implementation that uses arbitrary order in relations!! + allOrdered: one2many('mail.chat_window', { + compute: '_computeAllOrdered', + dependencies: [ + '_ordered', + ], + }), + allOrderedThread: one2many('mail.thread', { + related: 'allOrdered.thread', + }), + allOrderedHidden: one2many('mail.chat_window', { + compute: '_computeAllOrderedHidden', + dependencies: ['visual'], + }), + allOrderedHiddenThread: one2many('mail.thread', { + related: 'allOrderedHidden.thread', + }), + allOrderedHiddenThreadMessageUnreadCounter: attr({ + related: 'allOrderedHiddenThread.message_unread_counter', + }), + allOrderedVisible: one2many('mail.chat_window', { + compute: '_computeAllOrderedVisible', + dependencies: ['visual'], + }), + chatWindows: one2many('mail.chat_window', { + inverse: 'manager', + isCausal: true, + }), + device: one2one('mail.device', { + related: 'messaging.device', + }), + deviceGlobalWindowInnerWidth: attr({ + related: 'device.globalWindowInnerWidth', + }), + deviceIsMobile: attr({ + related: 'device.isMobile', + }), + discuss: one2one('mail.discuss', { + related: 'messaging.discuss', + }), + discussIsOpen: attr({ + related: 'discuss.isOpen', + }), + hasHiddenChatWindows: attr({ + compute: '_computeHasHiddenChatWindows', + dependencies: ['allOrderedHidden'], + }), + hasVisibleChatWindows: attr({ + compute: '_computeHasVisibleChatWindows', + dependencies: ['allOrderedVisible'], + }), + isHiddenMenuOpen: attr({ + default: false, + }), + lastVisible: many2one('mail.chat_window', { + compute: '_computeLastVisible', + dependencies: ['allOrderedVisible'], + }), + messaging: one2one('mail.messaging', { + inverse: 'chatWindowManager', + }), + newMessageChatWindow: one2one('mail.chat_window', { + compute: '_computeNewMessageChatWindow', + dependencies: ['allOrderedThread'], + }), + unreadHiddenConversationAmount: attr({ + compute: '_computeUnreadHiddenConversationAmount', + dependencies: ['allOrderedHiddenThreadMessageUnreadCounter'], + }), + visual: attr({ + compute: '_computeVisual', + default: BASE_VISUAL, + dependencies: [ + 'allOrdered', + 'deviceGlobalWindowInnerWidth', + 'deviceIsMobile', + 'discussIsOpen', + ], + }), + }; + + ChatWindowManager.modelName = 'mail.chat_window_manager'; + + return ChatWindowManager; +} + +registerNewModel('mail.chat_window_manager', factory); + +}); diff --git a/addons/mail/static/src/models/chatter/chatter.js b/addons/mail/static/src/models/chatter/chatter.js new file mode 100644 index 00000000000..fc50dc4d3ef --- /dev/null +++ b/addons/mail/static/src/models/chatter/chatter.js @@ -0,0 +1,326 @@ +odoo.define('mail/static/src/models/chatter/chatter.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + const getThreadNextTemporaryId = (function () { + let tmpId = 0; + return () => { + tmpId -= 1; + return tmpId; + }; + })(); + + const getMessageNextTemporaryId = (function () { + let tmpId = 0; + return () => { + tmpId -= 1; + return tmpId; + }; + })(); + + class Chatter extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + this._stopAttachmentsLoading(); + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + async refresh() { + const thread = this.thread; + if (!thread || thread.isTemporary) { + return; + } + thread.loadNewMessages(); + if (!this._isPreparingAttachmentsLoading && !this.isShowingAttachmentsLoading) { + this._prepareAttachmentsLoading(); + } + await thread.fetchAttachments(); + this._stopAttachmentsLoading(); + } + + async refreshActivities() { + // A bit "extreme", may be improved + const [{ activity_ids: newActivityIds }] = await this.async(() => this.env.services.rpc({ + model: this.thread.model, + method: 'read', + args: [this.thread.id, ['activity_ids']] + })); + const activitiesData = await this.async(() => this.env.services.rpc({ + model: 'mail.activity', + method: 'activity_format', + args: [newActivityIds] + })); + const activities = []; + for (const activityData of activitiesData) { + const activity = this.env.models['mail.activity'].insert( + this.env.models['mail.activity'].convertData(activityData) + ); + activities.push(activity); + } + this.update({ activities: [['replace', activities]] }); + } + + showLogNote() { + this.update({ + isComposerLog: true, + isComposerVisible: true, + }); + } + + showSendMessage() { + this.update({ + isComposerLog: false, + isComposerVisible: true, + }); + } + + toggleActivityBoxVisibility() { + this.update({ isActivityBoxVisible: !this.isActivityBoxVisible }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.activity[]} + */ + _computeFutureActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'planned')]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsDisabled() { + return !this.threadId; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeOverdueActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'overdue')]]; + } + + /** + * @private + * @returns {mail.activity[]} + */ + _computeTodayActivities() { + return [['replace', this.activities.filter(activity => activity.state === 'today')]]; + } + + /** + * @private + */ + _prepareAttachmentsLoading() { + this._isPreparingAttachmentsLoading = true; + this._attachmentsLoaderTimeout = this.env.browser.setTimeout(() => { + this.update({isShowingAttachmentsLoading: true}); + this._isPreparingAttachmentsLoading = false; + }, this.env.loadingBaseDelayDuration); + } + + /** + * @private + */ + _stopAttachmentsLoading() { + this.env.browser.clearTimeout(this._attachmentsLoaderTimeout); + this._attachmentsLoaderTimeout = null; + this.update({isShowingAttachmentsLoading: false}); + this._isPreparingAttachmentsLoading = false; + } + + /** + * @override + */ + _updateAfter(previous) { + // thread + if ( + this.threadModel !== previous.threadModel || + this.threadId !== previous.threadId + ) { + // change of thread + this._updateRelationThread(); + if (previous.thread && previous.thread.isTemporary) { + // AKU FIXME: make dedicated models for "temporary" threads, + // so that it auto-handles causality of messages for deletion + // automatically + const oldMainThreadCache = previous.thread.mainCache; + const message = oldMainThreadCache.messages[0]; + message.delete(); + previous.thread.delete(); + } + } + + if (previous.activityIds.join(',') !== this.activityIds.join(',')) { + this.refreshActivities(); + } + if ( + previous.followerIds.join(',') !== this.followerIds.join(',') && + !this.thread.isTemporary + ) { + this.thread.refreshFollowers(); + } + if ( + previous.thread !== this.thread || + (this.thread && this.messageIds.join(',') !== previous.messageIds.join(',')) + ) { + this.refresh(); + } + } + + /** + * @override + */ + _updateBefore() { + return { + activityIds: this.activityIds, + followerIds: this.followerIds, + messageIds: this.messageIds, + threadModel: this.threadModel, + threadId: this.threadId, + thread: this.thread, + }; + } + + /** + * @private + */ + _updateRelationThread() { + if (!this.threadId) { + if (this.thread && this.thread.isTemporary) { + return; + } + const nextId = getThreadNextTemporaryId(); + const thread = this.env.models['mail.thread'].create({ + areAttachmentsLoaded: true, + id: nextId, + isTemporary: true, + model: this.threadModel, + }); + const currentPartner = this.env.messaging.currentPartner; + const message = this.env.models['mail.message'].create({ + author: [['link', currentPartner]], + body: this.env._t("Creating a new record..."), + id: getMessageNextTemporaryId(), + isTemporary: true, + }); + this.threadViewer.update({ thread: [['link', thread]] }); + for (const cache of thread.caches) { + cache.update({ messages: [['link', message]] }); + } + } else { + // thread id and model + const thread = this.env.models['mail.thread'].insert({ + id: this.threadId, + model: this.threadModel, + }); + this.threadViewer.update({ thread: [['link', thread]] }); + } + } + + } + + Chatter.fields = { + activities: one2many('mail.activity', { + inverse: 'chatter', + }), + activityIds: attr({ + default: [], + }), + activitiesState: attr({ + related: 'activities.state', + }), + context: attr({ + default: {}, + }), + followerIds: attr({ + default: [], + }), + futureActivities: one2many('mail.activity', { + compute: '_computeFutureActivities', + dependencies: ['activitiesState'], + }), + hasActivities: attr({ + default: true, + }), + hasExternalBorder: attr({ + default: true, + }), + hasFollowers: attr({ + default: true, + }), + hasThread: attr({ + default: true, + }), + hasTopbarCloseButton: attr({ + default: false, + }), + isActivityBoxVisible: attr({ + default: true, + }), + isAttachmentBoxVisible: attr({ + default: false, + }), + isComposerLog: attr({ + default: true, + }), + isComposerVisible: attr({ + default: false, + }), + isDisabled: attr({ + compute: '_computeIsDisabled', + default: false, + dependencies: ['threadId'], + }), + isShowingAttachmentsLoading: attr({ + default: false, + }), + messageIds: attr({ + default: [], + }), + overdueActivities: one2many('mail.activity', { + compute: '_computeOverdueActivities', + dependencies: ['activitiesState'], + }), + thread: one2one('mail.thread', { + related: 'threadViewer.thread', + }), + threadAttachmentCount: attr({ + default: 0, + }), + threadId: attr(), + threadModel: attr(), + threadViewer: one2one('mail.thread_viewer', { + autocreate: true, + }), + todayActivities: one2many('mail.activity', { + compute: '_computeTodayActivities', + dependencies: ['activitiesState'], + }), + }; + + Chatter.modelName = 'mail.chatter'; + + return Chatter; +} + +registerNewModel('mail.chatter', factory); + +}); diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js new file mode 100644 index 00000000000..343e0d372c0 --- /dev/null +++ b/addons/mail/static/src/models/composer/composer.js @@ -0,0 +1,593 @@ +odoo.define('mail/static/src/models/composer/composer.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js'); + +const { + addLink, + escapeAndCompactTextContent, + parseAndTransform, +} = require('mail.utils'); + +function factory(dependencies) { + + class Composer extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + closeMentionSuggestions() { + this.update({ + activeSuggestedPartner: [['unlink']], + extraSuggestedPartners: [['unlink-all']], + mainSuggestedPartners: [['unlink-all']], + }); + } + + discard() { + const discuss = this.env.messaging.discuss; + const thread = this.thread; + if ( + !discuss.isOpen || + discuss.thread !== thread || + !discuss.isReplyingToMessage + ) { + return; + } + discuss.clearReplyingToMessage(); + } + + focus() { + this.update({ isDoFocus: true }); + } + + /** + * Inserts text content in text input based on selection. + * + * @param {string} content + */ + insertIntoTextInput(content) { + const partA = this.textInputContent.slice(0, this.textInputCursorStart); + const partB = this.textInputContent.slice( + this.textInputCursorEnd, + this.textInputContent.length + ); + this.update({ + textInputContent: partA + content + partB, + textInputCursorStart: this.textInputCursorStart + content.length, + textInputCursorEnd: this.textInputCursorStart + content.length, + }); + } + + /** + * @param {mail.partner} partner + */ + insertMentionedPartner(partner) { + const cursorPosition = this.textInputCursorStart; + const textLeft = this.textInputContent.substring( + 0, + this.textInputContent.substring(0, cursorPosition).lastIndexOf('@') + 1 + ); + const textRight = this.textInputContent.substring( + cursorPosition, + this.textInputContent.length + ); + const partnerName = partner.name.replace(/ /g, '\u00a0'); + this.update({ + mentionedPartners: [['link', partner]], + textInputContent: textLeft + partnerName + ' ' + textRight, + textInputCursorEnd: textLeft.length + partnerName.length + 1, + textInputCursorStart: textLeft.length + partnerName.length + 1, + }); + } + + /** + * Post a message in provided composer's thread with given data. + * + * @param {Object} data + * @param {string[]} data.attachments + * @param {*[]} data.canned_response_ids + * @param {integer[]} data.channel_ids + * @param {boolean} [data.isLog=false] + * @param {string} data.subject + * @param {integer} [data.subtype_id] + * @param {string} [data.subtype_xmlid='mail.mt_comment'] + * @param {Object} [options] + * @param {integer} options.res_id + * @param {string} options.res_model + */ + async postMessage(data, options) { + const thread = this.thread; + this.thread.unregisterCurrentPartnerIsTyping({ immediateNotify: true }); + if (thread.model === 'mail.box') { + const { res_id, res_model } = options; + const otherThread = this.env.models['mail.thread'].find(thread => + thread.id === res_id && + thread.model === res_model + ); + return otherThread.composer.postMessage(Object.assign({}, data)); + } + const { + canned_response_ids, + channel_ids = [], + context, + isLog = false, + subject, + subtype_id, + // subtype_xmlid='mail.mt_comment', + } = data; + const escapedAndCompactContent = escapeAndCompactTextContent(this.textInputContent); + let body = escapedAndCompactContent.replace(/ /g, ' ').trim(); + // This message will be received from the mail composer as html content + // subtype but the urls will not be linkified. If the mail composer + // takes the responsibility to linkify the urls we end up with double + // linkification a bit everywhere. Ideally we want to keep the content + // as text internally and only make html enrichment at display time but + // the current design makes this quite hard to do. + body = this._generateMentionsLinks(body); + body = parseAndTransform(body, addLink); + body = this._generateEmojisOnHtml(body); + let postData = { + attachment_ids: this.attachments.map(attachment => attachment.id), + body, + partner_ids: this.mentionedPartners.map(partner => partner.id), + message_type: 'comment', + }; + let messageId; + if (thread.model === 'mail.channel') { + const command = this._getCommandFromText(body); + Object.assign(postData, { + command, + subtype_xmlid: 'mail.mt_comment' + }); + messageId = await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: command ? 'execute_command' : 'message_post', + args: [thread.id], + kwargs: postData + })); + } else { + Object.assign(postData, { + channel_ids: channel_ids.map(channelId => [4, channelId, false]), + canned_response_ids + }); + if (subject) { + postData.subject = subject; + } + Object.assign(postData, { + context, + subtype_id, + subtype_xmlid: isLog ? 'mail.mt_note' : 'mail.mt_comment', + }); + messageId = await this.async(() => this.env.services.rpc({ + model: thread.model, + method: 'message_post', + args: [thread.id], + kwargs: postData + })); + const [messageData] = await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'message_format', + args: [[messageId]] + })); + this.env.models['mail.message'].insert(Object.assign( + {}, + this.env.models['mail.message'].convertData(messageData), + { + originThread: [['insert', { + id: thread.id, + model: thread.model, + }]], + }) + ); + thread.loadNewMessages(); + } + for (const threadViewer of this.thread.viewers) { + threadViewer.addComponentHint('current-partner-just-posted-message', messageId); + } + this._reset(); + } + + /** + * Called when current partner is inserting some input in composer. + * Useful to notify current partner is currently typing something in the + * composer of this thread to all other members. + */ + handleCurrentPartnerIsTyping() { + if (!this.thread) { + return; + } + if (this.thread.typingMembers.includes(this.env.messaging.currentPartner)) { + this.thread.refreshCurrentPartnerIsTyping(); + } else { + this.thread.registerCurrentPartnerIsTyping(); + } + } + + setFirstSuggestedPartnerActive() { + if (!this.allSuggestedPartners[0]) { + return; + } + this.update({ + activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]], + }); + } + + setLastSuggestedPartnerActive() { + if (this.allSuggestedPartners.length === 0) { + return; + } + this.update({ + activeSuggestedPartner: [[ + 'link', + this.allSuggestedPartners[this.allSuggestedPartners.length - 1] + ]], + }); + } + + setNextSuggestedPartnerActive() { + if (this.allSuggestedPartners.length === 0) { + return; + } + const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex( + suggestedPartner => suggestedPartner === this.activeSuggestedPartner + ); + if (activeSuggestedPartnerIndex !== this.allSuggestedPartners.length - 1) { + this.update({ + activeSuggestedPartner: [[ + 'link', + this.allSuggestedPartners[activeSuggestedPartnerIndex + 1] + ]], + }); + } else { + this.update({ + activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]], + }); + } + } + + setPreviousSuggestedPartnerActive() { + if (this.allSuggestedPartners.length === 0) { + return; + } + const activeSuggestedPartnerIndex = this.allSuggestedPartners.findIndex( + suggestedPartner => suggestedPartner === this.activeSuggestedPartner + ); + if (activeSuggestedPartnerIndex === -1) { + this.update({ + activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]] + }); + } else if (activeSuggestedPartnerIndex !== 0) { + this.update({ + activeSuggestedPartner: [[ + 'link', + this.allSuggestedPartners[activeSuggestedPartnerIndex - 1] + ]], + }); + } else { + this.update({ + activeSuggestedPartner: [[ + 'link', + this.allSuggestedPartners[this.allSuggestedPartners.length - 1] + ]], + }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.partner[]} + */ + _computeAllSuggestedPartners() { + return [['replace', this.mainSuggestedPartners.concat(this.extraSuggestedPartners)]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeCanPostMessage() { + return this.textInputContent || this.attachments.length !== 0; + } + + /** + * Ensure extraSuggestedPartners does not contain any partner already + * present in mainSuggestedPartners. This is necessary for the + * consistency of suggestion list because allSuggestedPartners is the + * union of both but without duplicates (because it is a relational + * field). + * + * @private + * @returns {mail.partner[]} + */ + _computeExtraSuggestedPartners() { + return [['unlink', this.mainSuggestedPartners]]; + } + + /** + * @private + * @return {boolean} + */ + _computeHasSuggestedPartners() { + return this.allSuggestedPartners.length > 0; + } + + /** + * Detects if mentioned partners are still in the composer text input content + * and removes them if not. + * + * @private + * @returns {mail.partner[]} + */ + _computeMentionedPartners() { + const inputMentions = this.textInputContent.match( + new RegExp("@[^ ]+(?= | |$)", 'g') + ) || []; + const unmentionedPartners = []; + for (const partner of this.mentionedPartners) { + let inputMention = inputMentions.find(item => { + return item === ("@" + partner.name).replace(/ /g, '\u00a0'); + }); + if (!inputMention) { + unmentionedPartners.push(partner); + } + } + return [['unlink', unmentionedPartners]]; + } + + /** + * Detects if mentions suggestions should be displayed when user is typing + * and searches partners based on user's research. + * + * @private + */ + _detectDelimiter() { + const mentionKeyword = this._validateMentionKeyword('@', false); + if (mentionKeyword !== false) { + this._getSuggestedPartners(mentionKeyword); + } else { + this.closeMentionSuggestions(); + } + } + + /** + * @private + * @param {string} htmlString + * @returns {string} + */ + _generateEmojisOnHtml(htmlString) { + for (const emoji of emojis) { + for (const source of emoji.sources) { + const escapedSource = String(source).replace( + /([.*+?=^!:${}()|[\]/\\])/g, + '\\$1'); + const regexp = new RegExp( + '(\\s|^)(' + escapedSource + ')(?=\\s|$)', + 'g'); + htmlString = htmlString.replace(regexp, '$1' + emoji.unicode); + } + } + return htmlString; + } + + /** + * + * Generates the html link related to the mentioned partner + * + * @private + * @param {string} body + * @returns {string} + */ + _generateMentionsLinks(body) { + if (this.mentionedPartners.length === 0) { + return body; + } + const inputMentions = body.match(new RegExp("@"+'[^ ]+(?= | |$)', 'g')); + const substrings = []; + let startIndex = 0; + for (const i in inputMentions) { + const match = inputMentions[i]; + const matchName = owl.utils.escape(match.substring(1).replace(new RegExp('\u00a0', 'g'), ' ')); + const endIndex = body.indexOf(match, startIndex) + match.length; + const partner = this.mentionedPartners.find(partner => + partner.name === matchName + ); + let mentionLink = "@" + matchName; + if (partner) { + const baseHREF = this.env.session.url('/web'); + const href = `href='${baseHREF}#model=res.partner&id=${partner.id}'`; + const attClass = `class='o_mail_redirect'`; + const dataOeId = `data-oe-id='${partner.id}'`; + const dataOeModel = `data-oe-model='res.partner'`; + const target = `target='_blank'`; + mentionLink = `<a ${href} ${attClass} ${dataOeId} ${dataOeModel} ${target} >@${matchName}</a>`; + } + substrings.push(body.substring(startIndex, body.indexOf(match, startIndex))); + substrings.push(mentionLink); + startIndex = endIndex; + } + substrings.push(body.substring(startIndex, body.length)); + return substrings.join(''); + } + + /** + * @private + * @param {string} content html content + * @returns {string|undefined} command, if any in the content + */ + _getCommandFromText(content) { + if (content.startsWith('/')) { + return content.substring(1).split(/\s/)[0]; + } + return undefined; + } + + /** + * @private + * @param {string} mentionKeyword + */ + async _getSuggestedPartners(mentionKeyword) { + const mentions = await this.async(() => this.env.services.rpc( + { + model: 'res.partner', + method: 'get_mention_suggestions', + kwargs: { + limit: 8, + search: mentionKeyword, + }, + }, + { shadow: true } + )); + + const mainSuggestedPartners = mentions[0]; + const extraSuggestedPartners = mentions[1]; + this.update({ + extraSuggestedPartners: [[ + 'insert-and-replace', + extraSuggestedPartners.map(data => + this.env.models['mail.partner'].convertData(data) + ) + ]], + mainSuggestedPartners: [[ + 'insert-and-replace', + mainSuggestedPartners.map(data => + this.env.models['mail.partner'].convertData(data)) + ]], + }); + + if (this.allSuggestedPartners[0]) { + this.update({ + activeSuggestedPartner: [['link', this.allSuggestedPartners[0]]], + }); + } else { + this.update({ + activeSuggestedPartner: [['unlink']], + }); + } + } + + /** + * @private + */ + _reset() { + this.closeMentionSuggestions(); + this.update({ + attachments: [['unlink-all']], + mentionedPartners: [['unlink-all']], + textInputContent: '', + textInputCursorStart: 0, + textInputCursorEnd: 0, + }); + } + + /** + * Validates user's current typing as a correct mention keyword + * in order to trigger mentions suggestions display. + * Returns the mention keyword without the delimiter if it has been validated + * and false if not + * + * @private + * @param {string} delimiter + * @param {boolean} beginningOnly + * @returns {string|boolean} + */ + _validateMentionKeyword(delimiter, beginningOnly) { + const leftString = this.textInputContent.substring(0, this.textInputCursorStart); + + // use position before delimiter because there should be whitespaces + // or line feed/carriage return before the delimiter + const beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; + if (beginningOnly && beforeDelimiterPosition > 0) { + return false; + } + let searchStr = this.textInputContent.substring( + beforeDelimiterPosition, + this.textInputCursorStart + ); + // regex string start with delimiter or whitespace then delimiter + const pattern = "^"+delimiter+"|^\\s"+delimiter; + const regexStart = new RegExp(pattern, 'g'); + // trim any left whitespaces or the left line feed/ carriage return + // at the beginning of the string + searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); + if (regexStart.test(searchStr) && searchStr.length) { + searchStr = searchStr.replace(pattern, ''); + return !searchStr.includes(' ') && !/[\r\n]/.test(searchStr) + ? searchStr.replace(delimiter, '') + : false; + } + return false; + } + } + + Composer.fields = { + activeSuggestedPartner: many2one('mail.partner'), + allSuggestedPartners: many2many('mail.partner', { + compute: '_computeAllSuggestedPartners', + dependencies: [ + 'extraSuggestedPartners', + 'mainSuggestedPartners', + ], + }), + attachments: many2many('mail.attachment', { + inverse: 'composers', + }), + canPostMessage: attr({ + compute: '_computeCanPostMessage', + dependencies: [ + 'attachments', + 'textInputContent', + ], + default: false, + }), + extraSuggestedPartners: many2many('mail.partner', { + compute: '_computeExtraSuggestedPartners', + dependencies: [ + 'extraSuggestedPartners', + 'mainSuggestedPartners', + ], + }), + hasSuggestedPartners: attr({ + compute: '_computeHasSuggestedPartners', + dependencies: [ + 'allSuggestedPartners', + ], + default: false, + }), + isDoFocus: attr({ + default: false, + }), + mainSuggestedPartners: many2many('mail.partner'), + mentionedPartners: many2many('mail.partner', { + compute: '_computeMentionedPartners', + dependencies: ['textInputContent'], + }), + textInputContent: attr({ + default: "", + }), + textInputCursorStart: attr({ + default: 0, + }), + textInputCursorEnd: attr({ + default: 0, + }), + thread: one2one('mail.thread', { + inverse: 'composer', + }), + }; + + Composer.modelName = 'mail.composer'; + + return Composer; +} + +registerNewModel('mail.composer', factory); + +}); diff --git a/addons/mail/static/src/models/country/country.js b/addons/mail/static/src/models/country/country.js new file mode 100644 index 00000000000..8fe6b282a31 --- /dev/null +++ b/addons/mail/static/src/models/country/country.js @@ -0,0 +1,23 @@ +odoo.define('mail/static/src/models/country/country.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Country extends dependencies['mail.model'] {} + + Country.fields = { + id: attr(), + name: attr(), + }; + + Country.modelName = 'mail.country'; + + return Country; +} + +registerNewModel('mail.country', factory); + +}); diff --git a/addons/mail/static/src/models/device/device.js b/addons/mail/static/src/models/device/device.js new file mode 100644 index 00000000000..d3aba92aac4 --- /dev/null +++ b/addons/mail/static/src/models/device/device.js @@ -0,0 +1,71 @@ +odoo.define('mail/static/src/models/device/device.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Device extends dependencies['mail.model'] { + + /** + * @override + */ + static create() { + const record = super.create(); + record._refresh(); + record._onResize = _.debounce(() => record._refresh(), 100); + return record; + } + + /** + * @override + */ + delete() { + window.removeEventListener('resize', this._onResize); + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Called when messaging is started. + */ + start() { + // TODO FIXME Not using this.env.browser because it's proxified, and + // addEventListener does not work on proxified window. task-2234596 + window.addEventListener('resize', this._onResize); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + */ + _refresh() { + this.update({ + globalWindowInnerHeight: this.env.browser.innerHeight, + globalWindowInnerWidth: this.env.browser.innerWidth, + isMobile: this.env.device.isMobile, + }); + } + } + + Device.fields = { + globalWindowInnerHeight: attr(), + globalWindowInnerWidth: attr(), + isMobile: attr(), + }; + + Device.modelName = 'mail.device'; + + return Device; +} + +registerNewModel('mail.device', factory); + +}); diff --git a/addons/mail/static/src/models/dialog/dialog.js b/addons/mail/static/src/models/dialog/dialog.js new file mode 100644 index 00000000000..d4b0bcddfb5 --- /dev/null +++ b/addons/mail/static/src/models/dialog/dialog.js @@ -0,0 +1,52 @@ +odoo.define('mail/static/src/models/dialog/dialog.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Dialog extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + if (this.manager) { + this.manager.unregister(this); + } + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + close() { + this.delete(); + } + + } + + Dialog.fields = { + manager: many2one('mail.dialog_manager', { + inverse: 'dialogs', + }), + /** + * Content of dialog that is directly linked to a record that models + * a UI component, such as AttachmentViewer. These records must be + * created from @see `mail.dialog_manager:open()`. + */ + record: one2one('mail.model', { + isCausal: true, + }), + }; + + Dialog.modelName = 'mail.dialog'; + + return Dialog; +} + +registerNewModel('mail.dialog', factory); + +}); diff --git a/addons/mail/static/src/models/dialog_manager/dialog_manager.js b/addons/mail/static/src/models/dialog_manager/dialog_manager.js new file mode 100644 index 00000000000..024172f7cdf --- /dev/null +++ b/addons/mail/static/src/models/dialog_manager/dialog_manager.js @@ -0,0 +1,99 @@ +odoo.define('mail/static/src/models/dialog_manager/dialog_manager.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class DialogManager extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {mail.dialog} dialog + */ + close(dialog) { + this.unregister(dialog); + dialog.delete(); + } + + /** + * @param {string} modelName + * @param {Object} [recordData] + */ + open(modelName, recordData) { + const dialog = this.env.models['mail.dialog'].create({ + manager: [['link', this]], + }); + if (!modelName) { + throw new Error("Dialog should have a link to a model"); + } + const Model = this.env.models[modelName]; + if (!Model) { + throw new Error(`No model exists with name ${modelName}`); + } + const record = Model.create(recordData); + dialog.update({ record: [['link', record]] }); + this.update({ _ordered: this._ordered.concat([dialog.localId]) }); + return dialog; + } + + /** + * @param {mail.dialog} dialog + */ + unregister(dialog) { + if (!this.allOrdered.includes(dialog)) { + return; + } + this.update({ + _ordered: this._ordered.filter( + dialogLocalId => dialogLocalId !== dialog.localId + ), + dialogs: [['unlink', dialog]], + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * FIXME: dependent on implementation that uses arbitrary order in relations!! + * + * @private + * @returns {mail.dialog} + */ + _computeAllOrdered() { + return [['replace', this._ordered.map(dialogLocalId => + this.env.models['mail.dialog'].get(dialogLocalId) + )]]; + } + } + + DialogManager.fields = { + _ordered: attr({ default: [] }), + // FIXME: dependent on implementation that uses arbitrary order in relations!! + allOrdered: one2many('mail.dialog', { + compute: '_computeAllOrdered', + dependencies: [ + '_ordered', + 'dialogs', + ], + }), + dialogs: one2many('mail.dialog', { + inverse: 'manager', + isCausal: true, + }), + }; + + DialogManager.modelName = 'mail.dialog_manager'; + + return DialogManager; +} + +registerNewModel('mail.dialog_manager', factory); + +}); diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js new file mode 100644 index 00000000000..c5d6c3ecf91 --- /dev/null +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -0,0 +1,402 @@ +odoo.define('mail/static/src/models/discuss.discuss.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Discuss extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {mail.thread} thread + */ + cancelThreadRenaming(thread) { + this.update({ renamingThreads: [['unlink', thread]] }); + } + + clearIsAddingItem() { + this.update({ + addingChannelValue: "", + isAddingChannel: false, + isAddingChat: false, + }); + } + + clearReplyingToMessage() { + this.update({ replyingToMessage: [['unlink-all']] }); + } + + /** + * Close the discuss app. Should reset its internal state. + */ + close() { + this.update({ isOpen: false }); + } + + /** + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + handleAddChannelAutocompleteSelect(ev, ui) { + if (ui.item.special) { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + name: this.addingChannelValue, + public: ui.item.special, + type: 'channel', + }); + } else { + this.env.models['mail.thread'].joinChannel(ui.item.id, { autoselect: true }); + } + this.clearIsAddingItem(); + } + + /** + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + async handleAddChannelAutocompleteSource(req, res) { + const value = req.term; + const escapedValue = owl.utils.escape(value); + this.update({ addingChannelValue: value }); + const result = await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_search_to_join', + args: [value], + })); + const items = result.map(data => { + let escapedName = owl.utils.escape(data.name); + return Object.assign(data, { + label: escapedName, + value: escapedName + }); + }); + // XDU FIXME could use a component but be careful with owl's + // renderToString https://github.com/odoo/owl/issues/708 + items.push({ + label: _.str.sprintf( + `<strong>${this.env._t('Create %s')}</strong>`, + `<em><span class="fa fa-hashtag"/>${escapedValue}</em>`, + ), + escapedValue, + special: 'public' + }, { + label: _.str.sprintf( + `<strong>${this.env._t('Create %s')}</strong>`, + `<em><span class="fa fa-lock"/>${escapedValue}</em>`, + ), + escapedValue, + special: 'private' + }); + res(items); + } + + /** + * @param {Event} ev + * @param {Object} ui + * @param {Object} ui.item + * @param {integer} ui.item.id + */ + handleAddChatAutocompleteSelect(ev, ui) { + const partnerId = ui.item.id; + const partner = this.env.models['mail.partner'].find(partner => + partner.id === partnerId + ); + const chat = partner.correspondentThreads.find(thread => thread.channel_type === 'chat'); + if (chat) { + this.threadViewer.update({ thread: [['link', chat]] }); + } else { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + partnerId, + type: 'chat', + }); + } + this.clearIsAddingItem(); + } + + /** + * @param {Object} req + * @param {string} req.term + * @param {function} res + */ + handleAddChatAutocompleteSource(req, res) { + const value = owl.utils.escape(req.term); + this.env.models['mail.partner'].imSearch({ + callback: partners => { + const suggestions = partners.map(partner => { + return { + id: partner.id, + value: partner.nameOrDisplayName, + label: partner.nameOrDisplayName, + }; + }); + res(_.sortBy(suggestions, 'label')); + }, + keyword: value, + limit: 10, + }); + } + + /** + * Open thread from init active id. `initActiveId` is used to refer to + * a thread that we may not have full data yet, such as when messaging + * is not yet initialized. + */ + openInitThread() { + const [model, id] = this.initActiveId.split('_'); + const thread = this.env.models['mail.thread'].find(thread => + thread.id === (model !== 'mail.box' ? Number(id) : id) && + thread.model === model + ); + if (!thread) { + return; + } + this.threadViewer.update({ + stringifiedDomain: '[]', + thread: [['link', thread]], + }); + thread.open({ resetDiscussDomain: true }); + } + + /** + * @param {mail.thread} thread + * @param {string} newName + */ + async renameThread(thread, newName) { + await this.async(() => thread.rename(newName)); + this.update({ renamingThreads: [['unlink', thread]] }); + } + + /** + * @param {mail.thread} thread + */ + setThreadRenaming(thread) { + this.update({ renamingThreads: [['link', thread ]] }); + } + + /** + * @param {mail.thread} thread + * @returns {string} + */ + threadToActiveId(thread) { + return `${thread.model}_${thread.id}`; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string|undefined} + */ + _computeActiveId() { + if (!this.thread) { + return undefined; + } + return this.threadToActiveId(this.thread); + } + + /** + * @private + * @returns {string} + */ + _computeAddingChannelValue() { + if (!this.isOpen) { + return ""; + } + return this.addingChannelValue; + } + + /** + * @private + * @returns {string} + */ + _computeInitActiveId() { + if (!this.isOpen) { + return this.defaultInitActiveId; + } + if (this.thread) { + return this.threadToActiveId(this.thread); + } + return this.initActiveId; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsAddingChannel() { + if (!this.isOpen) { + return false; + } + return this.isAddingChannel; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsAddingChat() { + if (!this.isOpen) { + return false; + } + return this.isAddingChat; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsReplyingToMessage() { + return !!this.replyingToMessage; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeReplyingToMessage() { + if (!this.isOpen) { + return [['unlink-all']]; + } + return []; + } + + } + + Discuss.fields = { + activeId: attr({ + compute: '_computeActiveId', + dependencies: [ + 'thread', + 'threadId', + 'threadModel', + ], + }), + /** + * Active mobile navbar tab, either 'mailbox', 'chat', or 'channel'. + */ + activeMobileNavbarTabId: attr({ + default: 'mailbox', + }), + /** + * Value that is used to create a channel from the sidebar. + */ + addingChannelValue: attr({ + compute: '_computeAddingChannelValue', + default: "", + dependencies: ['isOpen'], + }), + defaultInitActiveId: attr({ + default: 'mail.box_inbox', + }), + /** + * Determine if the moderation discard dialog is displayed. + */ + hasModerationDiscardDialog: attr({ + default: false, + }), + /** + * Determine if the moderation reject dialog is displayed. + */ + hasModerationRejectDialog: attr({ + default: false, + }), + /** + * Formatted init thread on opening discuss for the first time, + * when no active thread is defined. Useful to set a thread to + * open without knowing its local id in advance. + * format: <threadModel>_<threadId> + */ + initActiveId: attr({ + compute: '_computeInitActiveId', + default: 'mail.box_inbox', + dependencies: [ + 'isOpen', + 'thread', + 'threadId', + 'threadModel', + ], + }), + /** + * Determine whether current user is currently adding a channel from + * the sidebar. + */ + isAddingChannel: attr({ + compute: '_computeIsAddingChannel', + default: false, + dependencies: ['isOpen'], + }), + /** + * Determine whether current user is currently adding a chat from + * the sidebar. + */ + isAddingChat: attr({ + compute: '_computeIsAddingChat', + default: false, + dependencies: ['isOpen'], + }), + /** + * Whether the discuss app is open or not. Useful to determine + * whether the discuss or chat window logic should be applied. + */ + isOpen: attr({ + default: false, + }), + isReplyingToMessage: attr({ + compute: '_computeIsReplyingToMessage', + default: false, + dependencies: ['replyingToMessage'], + }), + /** + * The menu_id of discuss app, received on mail/init_messaging and + * used to open discuss from elsewhere. + */ + menu_id: attr({ + default: null, + }), + renamingThreads: one2many('mail.thread'), + replyingToMessage: one2one('mail.message', { + compute: '_computeReplyingToMessage', + dependencies: ['isOpen'], + }), + /** + * Quick search input value in the discuss sidebar (desktop). Useful + * to filter channels and chats based on this input content. + */ + sidebarQuickSearchValue: attr({ + default: "", + }), + thread: many2one('mail.thread', { + related: 'threadViewer.thread', + }), + threadId: attr({ + related: 'thread.id', + }), + threadModel: attr({ + related: 'thread.model', + }), + threadViewer: one2one('mail.thread_viewer', { + autocreate: true, + isCausal: true, + }), + }; + + Discuss.modelName = 'mail.discuss'; + + return Discuss; +} + +registerNewModel('mail.discuss', factory); + +}); diff --git a/addons/mail/static/src/models/follower/follower.js b/addons/mail/static/src/models/follower/follower.js new file mode 100644 index 00000000000..78667ed755c --- /dev/null +++ b/addons/mail/static/src/models/follower/follower.js @@ -0,0 +1,265 @@ +odoo.define('mail/static/src/models/follower.follower.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Follower extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @returns {Object} + */ + static convertData(data) { + const data2 = {}; + if ('channel_id' in data) { + if (!data.channel_id) { + data2.channel = [['unlink-all']]; + } else { + const channelData = { + id: data.channel_id, + model: 'mail.channel', + name: data.name, + }; + data2.channel = [['insert', channelData]]; + } + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_active' in data) { + data2.isActive = data.is_active; + } + if ('is_editable' in data) { + data2.isEditable = data.is_editable; + } + if ('partner_id' in data) { + if (!data.partner_id) { + data2.partner = [['unlink-all']]; + } else { + const partnerData = { + email: data.email, + id: data.partner_id, + name: data.name, + }; + data2.partner = [['insert', partnerData]]; + } + } + return data2; + } + + /** + * Close subtypes dialog + */ + closeSubtypes() { + this.env.messaging.dialogManager.close(this._subtypesListDialog); + this._subtypesListDialog = undefined; + } + + /** + * Remove this follower from its related thread. + */ + async remove() { + const args = [[this.followedThread.id]]; + if (this.partner) { + args.push([this.partner.id]); + } else { + args.push([this.channel.id]); + } + await this.async(() => this.env.services.rpc({ + model: this.followedThread.model, + method: 'message_unsubscribe', + args + })); + this.delete(); + } + + /** + * @param {mail.follower_subtype} subtype + */ + selectSubtype(subtype) { + if (!this.selectedSubtypes.includes(subtype)) { + this.update({ selectedSubtypes: [['link', subtype]] }); + } + } + + /** + * Show (editable) list of subtypes of this follower. + */ + async showSubtypes() { + const subtypesData = await this.async(() => this.env.services.rpc({ + route: '/mail/read_subscription_data', + params: { follower_id: this.id }, + })); + this.update({ subtypes: [['unlink-all']] }); + for (const data of subtypesData) { + const subtype = this.env.models['mail.follower_subtype'].insert( + this.env.models['mail.follower_subtype'].convertData(data) + ); + this.update({ subtypes: [['link', subtype]] }); + if (data.followed) { + this.update({ selectedSubtypes: [['link', subtype]] }); + } else { + this.update({ selectedSubtypes: [['unlink', subtype]] }); + } + } + this._subtypesListDialog = this.env.messaging.dialogManager.open('mail.follower_subtype_list', { + follower: [['replace', this]], + }); + } + + /** + * @param {mail.follower_subtype} subtype + */ + unselectSubtype(subtype) { + if (this.selectedSubtypes.includes(subtype)) { + this.update({ selectedSubtypes: [['unlink', subtype]] }); + } + } + + /** + * Update server-side subscription of subtypes of this follower. + */ + async updateSubtypes() { + if (this.selectedSubtypes.length === 0) { + this.remove(); + } else { + const kwargs = { + subtype_ids: this.selectedSubtypes.map(subtype => subtype.id), + }; + if (this.partner) { + kwargs.partner_ids = [this.partner.id]; + } else { + kwargs.channel_ids = [this.channel.id]; + } + await this.async(() => this.env.services.rpc({ + model: this.followedThread.model, + method: 'message_subscribe', + args: [[this.followedThread.id]], + kwargs, + })); + } + this.closeSubtypes(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string} + */ + _computeName() { + if (this.channel) { + return this.channel.name; + } + if (this.partner) { + return this.partner.name; + } + return ''; + } + + /** + * @private + * @returns {integer} + */ + _computeResId() { + if (this.partner) { + return this.partner.id; + } + if (this.channel) { + return this.channel.id; + } + return 0; + } + + /** + * @private + * @returns {string} + */ + _computeResModel() { + if (this.partner) { + return this.partner.model; + } + if (this.channel) { + return this.channel.model; + } + return ''; + } + + } + + Follower.fields = { + resId: attr({ + compute: '_computeResId', + default: 0, + dependencies: [ + 'channelId', + 'partnerId', + ], + }), + channel: many2one('mail.thread'), + channelId: attr({ + related: 'channel.id', + }), + channelModel: attr({ + related: 'channel.model', + }), + channelName: attr({ + related: 'channel.name', + }), + followedThread: many2one('mail.thread', { + inverse: 'followers', + }), + id: attr(), + isActive: attr({ + default: true, + }), + isEditable: attr({ + default: false, + }), + name: attr({ + compute: '_computeName', + dependencies: [ + 'channelName', + 'partnerName', + ], + }), + partner: many2one('mail.partner'), + partnerId: attr({ + related: 'partner.id', + }), + partnerModel: attr({ + related: 'partner.model', + }), + partnerName: attr({ + related: 'partner.name', + }), + resModel: attr({ + compute: '_computeResModel', + default: '', + dependencies: [ + 'channelModel', + 'partnerModel', + ], + }), + selectedSubtypes: many2many('mail.follower_subtype'), + subtypes: many2many('mail.follower_subtype'), + }; + + Follower.modelName = 'mail.follower'; + + return Follower; +} + +registerNewModel('mail.follower', factory); + +}); diff --git a/addons/mail/static/src/models/follower_subtype/follower_subtype.js b/addons/mail/static/src/models/follower_subtype/follower_subtype.js new file mode 100644 index 00000000000..37e65129c61 --- /dev/null +++ b/addons/mail/static/src/models/follower_subtype/follower_subtype.js @@ -0,0 +1,71 @@ +odoo.define('mail/static/src/models/follower_subtype/follower_subtype.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class FollowerSubtype extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @returns {Object} + */ + static convertData(data) { + const data2 = {}; + if ('default' in data) { + data2.isDefault = data.default; + } + if ('id' in data) { + data2.id = data.id; + } + if ('internal' in data) { + data2.isInternal = data.internal; + } + if ('name' in data) { + data2.name = data.name; + } + if ('parent_model' in data) { + data2.parentModel = data.parent_model; + } + if ('res_model' in data) { + data2.resModel = data.res_model; + } + if ('sequence' in data) { + data2.sequence = data.sequence; + } + return data2; + } + + } + + FollowerSubtype.fields = { + id: attr(), + isDefault: attr({ + default: false, + }), + isInternal: attr({ + default: false, + }), + name: attr(), + // AKU FIXME: use relation instead + parentModel: attr(), + // AKU FIXME: use relation instead + resModel: attr(), + sequence: attr(), + }; + + FollowerSubtype.modelName = 'mail.follower_subtype'; + + return FollowerSubtype; +} + +registerNewModel('mail.follower_subtype', factory); + +}); diff --git a/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js new file mode 100644 index 00000000000..9d67cedb97d --- /dev/null +++ b/addons/mail/static/src/models/follower_subtype_list/follower_subtype_list.js @@ -0,0 +1,22 @@ +odoo.define('mail/static/src/models/follower_subtype_list/follower_subtype_list.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class FollowerSubtypeList extends dependencies['mail.model'] {} + + FollowerSubtypeList.fields = { + follower: many2one('mail.follower'), + }; + + FollowerSubtypeList.modelName = 'mail.follower_subtype_list'; + + return FollowerSubtypeList; +} + +registerNewModel('mail.follower_subtype_list', factory); + +}); diff --git a/addons/mail/static/src/models/locale/locale.js b/addons/mail/static/src/models/locale/locale.js new file mode 100644 index 00000000000..4007dbc796c --- /dev/null +++ b/addons/mail/static/src/models/locale/locale.js @@ -0,0 +1,38 @@ +odoo.define('mail/static/src/models/locale/locale.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Locale extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {string} + */ + _computeTextDirection() { + return this.env._t.database.parameters.direction; + } + + } + + Locale.fields = { + textDirection: attr({ + compute: '_computeTextDirection', + }), + }; + + Locale.modelName = 'mail.locale'; + + return Locale; +} + +registerNewModel('mail.locale', factory); + +}); diff --git a/addons/mail/static/src/models/mail_template/mail_template.js b/addons/mail/static/src/models/mail_template/mail_template.js new file mode 100644 index 00000000000..69d2b4c18b1 --- /dev/null +++ b/addons/mail/static/src/models/mail_template/mail_template.js @@ -0,0 +1,76 @@ +odoo.define('mail/static/src/models/mail_template/mail_template.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MailTemplate extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @param {mail.activity} activity + */ + preview(activity) { + const action = { + name: this.env._t("Compose Email"), + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + views: [[false, 'form']], + target: 'new', + context: { + default_res_id: activity.res_id, + default_model: activity.res_model, + default_use_template: true, + default_template_id: this.id, + force_email: true, + }, + }; + this.env.bus.trigger('do-action', { + action, + options: { + on_close: () => { + if (activity.chatter) { + activity.chatter.refresh(); + } + }, + }, + }); + } + + /** + * @param {mail.activity} activity + */ + async send(activity) { + await this.async(() => this.env.services.rpc({ + model: activity.res_model, + method: 'activity_send_mail', + args: [[activity.res_id], this.id], + })); + if (activity.chatter) { + activity.chatter.refresh(); + } + } + + } + + MailTemplate.fields = { + activities: many2many('mail.activity', { + inverse: 'mailTemplates', + }), + id: attr(), + name: attr(), + }; + + MailTemplate.modelName = 'mail.mail_template'; + + return MailTemplate; +} + +registerNewModel('mail.mail_template', factory); + +}); diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js new file mode 100644 index 00000000000..f09d063b473 --- /dev/null +++ b/addons/mail/static/src/models/message/message.js @@ -0,0 +1,587 @@ +odoo.define('mail/static/src/models/message/message.js', function (require) { +'use strict'; + +const emojis = require('mail.emojis'); +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); +const { addLink, parseAndTransform } = require('mail.utils'); + +const { str_to_datetime } = require('web.time'); + +function factory(dependencies) { + + class Message extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + static checkAll(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + threadCache.update({ checkedMessages: [['link', threadCache.messages]] }); + } + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = { + threadCaches: [], + }; + if ('body' in data) { + data2.body = data.body; + } + if ('date' in data && data.date) { + data2.date = moment(str_to_datetime(data.date)); + } + if ('email_from' in data) { + data2.email_from = data.email_from; + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_discussion' in data) { + data2.is_discussion = data.is_discussion; + } + if ('is_note' in data) { + data2.is_note = data.is_note; + } + if ('is_notification' in data) { + data2.is_notification = data.is_notification; + } + if ('message_type' in data) { + data2.message_type = data.message_type; + } + if ('moderation_status' in data) { + data2.moderation_status = data.moderation_status; + const moderationMailbox = this.env.messaging.moderation; + if (moderationMailbox && data.moderation_status !== 'pending') { + data2.threadCaches.push(['unlink', moderationMailbox.mainCache]); + } + } + if ('subject' in data) { + data2.subject = data.subject; + } + if ('subtype_description' in data) { + data2.subtype_description = data.subtype_description; + } + if ('subtype_id' in data) { + data2.subtype_id = data.subtype_id; + } + if ('tracking_value_ids' in data) { + data2.tracking_value_ids = data.tracking_value_ids; + } + + // relations + if ('attachment_ids' in data) { + if (!data.attachment_ids) { + data2.attachments = [['unlink-all']]; + } else { + data2.attachments = [ + ['insert-and-replace', data.attachment_ids.map(attachmentData => + this.env.models['mail.attachment'].convertData(attachmentData) + )], + ]; + } + } + if ('author_id' in data) { + if (!data.author_id) { + data2.author = [['unlink-all']]; + } else if (data.author_id[0] !== 0) { + // partner id 0 is a hack of message_format to refer to an + // author non-related to a partner. display_name equals + // email_from, so this is omitted due to being redundant. + data2.author = [ + ['insert', { + display_name: data.author_id[1], + id: data.author_id[0], + }], + ]; + } + } + if ('channel_ids' in data && data.channel_ids) { + // AKU FIXME: side-effect of calling convert... + const channelList = []; + for (const channelId of data.channel_ids) { + const channel = this.env.models['mail.thread'].insert({ + id: channelId, + model: 'mail.channel', + }); + channelList.push(channel); + } + data2.threadCaches.push(['replace', channelList.map(channel => channel.mainCache)]); + } + if ('history_partner_ids' in data) { + const history = this.env.messaging.history; + if (data.history_partner_ids.includes(this.env.messaging.currentPartner.id)) { + data2.threadCaches.push(['link', history.mainCache]); + } else { + data2.threadCaches.push(['unlink', history.mainCache]); + } + } + if ('model' in data && 'res_id' in data && data.model && data.res_id) { + const originThreadData = { + id: data.res_id, + model: data.model, + }; + if ('record_name' in data && data.record_name) { + originThreadData.name = data.record_name; + } + if ('res_model_name' in data && data.res_model_name) { + originThreadData.model_name = data.res_model_name; + } + if ('module_icon' in data) { + originThreadData.moduleIcon = data.module_icon; + } + data2.originThread = [['insert', originThreadData]]; + } + if ('needaction_partner_ids' in data) { + const inbox = this.env.messaging.inbox; + if (data.needaction_partner_ids.includes(this.env.messaging.currentPartner.id)) { + data2.threadCaches.push(['link', inbox.mainCache]); + } else { + data2.threadCaches.push(['unlink', inbox.mainCache]); + } + } + if ('notifications' in data) { + data2.notifications = [['insert', data.notifications.map(notificationData => + this.env.models['mail.notification'].convertData(notificationData) + )]]; + } + if ('starred_partner_ids' in data) { + const starred = this.env.messaging.starred; + if (data.starred_partner_ids.includes(this.env.messaging.currentPartner.id)) { + data2.threadCaches.push(['link', starred.mainCache]); + } else { + data2.threadCaches.push(['unlink', starred.mainCache]); + } + } + + return data2; + } + + /** + * Mark all messages of current user with given domain as read. + * + * @static + * @param {Array[]} domain + */ + static async markAllAsRead(domain) { + await this.env.services.rpc({ + model: 'mail.message', + method: 'mark_all_as_read', + kwargs: { domain }, + }); + } + + /** + * Mark provided messages as read. Messages that have been marked as + * read are acknowledged by server with response as longpolling + * notification of following format: + * + * [[dbname, 'res.partner', partnerId], { type: 'mark_as_read' }] + * + * @see mail.messaging_notification_handler:_handleNotificationPartnerMarkAsRead() + * + * @static + * @param {mail.message[]} messages + */ + static async markAsRead(messages) { + await this.env.services.rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [messages.map(message => message.id)] + }); + } + + /** + * Applies the moderation `decision` on the provided messages. + * + * @static + * @param {mail.message[]} messages + * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject' + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + */ + static async moderate(messages, decision, kwargs) { + const messageIds = messages.map(message => message.id); + await this.env.services.rpc({ + model: 'mail.message', + method: 'moderate', + args: [messageIds, decision], + kwargs: kwargs, + }); + } + + /** + * @static + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + static uncheckAll(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + threadCache.update({ checkedMessages: [['unlink', threadCache.messages]] }); + } + + /** + * Unstar all starred messages of current user. + */ + static async unstarAll() { + await this.env.services.rpc({ + model: 'mail.message', + method: 'unstar_all', + }); + } + + /** + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + * @returns {boolean} + */ + isChecked(thread, threadStringifiedDomain) { + // aku todo + const relatedCheckedThreadCache = this.checkedThreadCaches.find( + threadCache => ( + threadCache.thread === thread && + threadCache.stringifiedDomain === threadStringifiedDomain + ) + ); + return !!relatedCheckedThreadCache; + } + + /** + * Mark this message as read, so that it no longer appears in current + * partner Inbox. + */ + async markAsRead() { + await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [[this.id]] + })); + } + + /** + * Applies the moderation `decision` on this message. + * + * @param {string} decision: 'accept', 'allow', ban', 'discard', or 'reject' + * @param {Object|undefined} [kwargs] optional data to pass on + * message moderation. This is provided when rejecting the messages + * for which title and comment give reason(s) for reject. + * @param {string} [kwargs.title] + * @param {string} [kwargs.comment] + */ + async moderate(decision, kwargs) { + await this.async(() => this.constructor.moderate([this], decision, kwargs)); + } + + /** + * Opens the view that allows to resend the message in case of failure. + */ + openResendAction() { + this.env.bus.trigger('do-action', { + action: 'mail.mail_resend_message_action', + options: { + additional_context: { + mail_message_to_resend: this.id, + }, + }, + }); + } + + /** + * Action to initiate reply to given message. + */ + replyTo() { + const discuss = this.env.messaging.discuss; + if (!discuss.isOpen) { + return; + } + if (discuss.replyingToMessage === this) { + discuss.clearReplyingToMessage(); + } else { + discuss.update({ replyingToMessage: [['link', this]] }); + } + } + + /** + * Toggle check state of this message in the context of the provided + * thread and its stringifiedDomain. + * + * @param {mail.thread} thread + * @param {string} threadStringifiedDomain + */ + toggleCheck(thread, threadStringifiedDomain) { + const threadCache = thread.cache(threadStringifiedDomain); + if (threadCache.checkedMessages.includes(this)) { + threadCache.update({ checkedMessages: [['unlink', this]] }); + } else { + threadCache.update({ checkedMessages: [['link', this]] }); + } + } + + /** + * Toggle the starred status of the provided message. + */ + async toggleStar() { + await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'toggle_message_starred', + args: [[this.id]] + })); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.thread[]} + */ + _computeAllThreads() { + const threads = this.threadCaches.map(cache => cache.thread); + let allThreads = threads; + if (this.originThread) { + allThreads = allThreads.concat([this.originThread]); + } + return [['replace', [...new Set(allThreads)]]]; + } + + /** + * @returns {boolean} + */ + _computeFailureNotifications() { + return [['replace', this.notifications.filter(notifications => + ['exception', 'bounce'].includes(notifications.notification_status) + )]]; + } + + /** + * @private + * @returns {boolean} + */ + _computeHasCheckbox() { + return this.isModeratedByCurrentPartner; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerAuthor() { + return ( + this.author && + this.messagingCurrentPartner && + this.messagingCurrentPartner === this.author + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + return ( + this.moderation_status === 'pending_moderation' && + this.originThread && + this.originThread.isModeratedByCurrentPartner + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsNeedaction() { + const inbox = this.env.messaging.inbox; + if (!inbox) { + return false; + } + return this.allThreads.includes(inbox); + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {string} + */ + _computePrettyBody() { + let prettyBody; + for (const emoji of emojis) { + const { unicode } = emoji; + const regexp = new RegExp( + `(?:^|\\s|<[a-z]*>)(${unicode})(?=\\s|$|</[a-z]*>)`, + "g" + ); + const originalBody = this.body; + prettyBody = this.body.replace( + regexp, + ` <span class="o_mail_emoji">${unicode}</span> ` + ); + // Idiot-proof limit. If the user had the amazing idea of + // copy-pasting thousands of emojis, the image rendering can lead + // to memory overflow errors on some browsers (e.g. Chrome). Set an + // arbitrary limit to 200 from which we simply don't replace them + // (anyway, they are already replaced by the unicode counterpart). + if (_.str.count(prettyBody, "o_mail_emoji") > 200) { + prettyBody = originalBody; + } + } + // add anchor tags to urls + return parseAndTransform(prettyBody, addLink); + } + + /** + * @override + */ + _createRecordLocalId(data) { + const Message = this.env.models['mail.message']; + return `${Message.modelName}_${data.id}`; + } + + } + + Message.fields = { + allThreads: many2many('mail.thread', { + compute: '_computeAllThreads', + dependencies: [ + 'originThread', + 'threadCachesThread', + ], + }), + attachments: many2many('mail.attachment', { + inverse: 'messages', + }), + author: many2one('mail.partner', { + inverse: 'messagesAsAuthor', + }), + body: attr({ + default: "", + }), + checkedThreadCaches: many2many('mail.thread_cache', { + inverse: 'checkedMessages', + }), + date: attr({ + default: moment(), + }), + email_from: attr(), + failureNotifications: one2many('mail.notification', { + compute: '_computeFailureNotifications', + dependencies: ['notificationsStatus'], + }), + hasCheckbox: attr({ + compute: '_computeHasCheckbox', + default: false, + dependencies: ['isModeratedByCurrentPartner'], + }), + id: attr(), + isCurrentPartnerAuthor: attr({ + compute: '_computeIsCurrentPartnerAuthor', + default: false, + dependencies: [ + 'author', + 'messagingCurrentPartner', + ], + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + default: false, + dependencies: [ + 'moderation_status', + 'originThread', + 'originThreadIsModeratedByCurrentPartner', + ], + }), + isNeedaction: attr({ + compute: '_computeIsNeedaction', + dependencies: [ + 'allThreads', + 'messagingInbox', + ], + default: false, + }), + isTemporary: attr({ + default: false, + }), + isTransient: attr({ + default: false, + }), + is_discussion: attr({ + default: false, + }), + is_note: attr({ + default: false, + }), + is_notification: attr({ + default: false, + }), + message_type: attr(), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + messagingInbox: many2one('mail.thread', { + related: 'messaging.inbox', + }), + moderation_status: attr(), + notifications: one2many('mail.notification', { + inverse: 'message', + isCausal: true, + }), + notificationsStatus: attr({ + default: [], + related: 'notifications.notification_status', + }), + originThread: many2one('mail.thread', { + inverse: 'originThreadMessages', + }), + originThreadIsModeratedByCurrentPartner: attr({ + default: false, + related: 'originThread.isModeratedByCurrentPartner', + }), + prettyBody: attr({ + compute: '_computePrettyBody', + dependencies: ['body'], + }), + subject: attr(), + subtype_description: attr(), + subtype_id: attr(), + threadCaches: many2many('mail.thread_cache', { + inverse: 'messages', + }), + threadCachesThread: many2many('mail.thread', { + related: 'threadCaches.thread', + }), + tracking_value_ids: attr({ + default: [], + }), + }; + + Message.modelName = 'mail.message'; + + return Message; +} + +registerNewModel('mail.message', factory); + +}); diff --git a/addons/mail/static/src/models/message/message_tests.js b/addons/mail/static/src/models/message/message_tests.js new file mode 100644 index 00000000000..fefb495a3ba --- /dev/null +++ b/addons/mail/static/src/models/message/message_tests.js @@ -0,0 +1,132 @@ +odoo.define('mail/static/src/models/message/message_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +const { str_to_datetime } = require('web.time'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('message', {}, function () { +QUnit.module('message_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + this.env = undefined; + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + }, +}); + +QUnit.test('create', async function (assert) { + assert.expect(31); + + await this.start(); + assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 5)); + assert.notOk(this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )); + assert.notOk(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.notOk(this.env.models['mail.message'].find(message => message.id === 4000)); + + const thread = this.env.models['mail.thread'].create({ + id: 100, + model: 'mail.channel', + name: "General", + }); + const message = this.env.models['mail.message'].create({ + attachments: [['insert-and-replace', { + filename: "test.txt", + id: 750, + mimetype: 'text/plain', + name: "test.txt", + }]], + author: [['insert', { id: 5, display_name: "Demo" }]], + body: "<p>Test</p>", + date: moment(str_to_datetime("2019-05-05 10:00:00")), + id: 4000, + originThread: [['link', thread]], + threadCaches: [['link', [ + thread.mainCache, + this.env.messaging.inbox.mainCache, + this.env.messaging.starred.mainCache, + ]]], + }); + + assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 5)); + assert.ok(this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )); + assert.ok(this.env.models['mail.attachment'].find(attachment => attachment.id === 750)); + assert.ok(this.env.models['mail.message'].find(message => message.id === 4000)); + + assert.ok(message); + assert.strictEqual(this.env.models['mail.message'].find(message => message.id === 4000), message); + assert.strictEqual(message.body, "<p>Test</p>"); + assert.ok(message.date instanceof moment); + assert.strictEqual( + moment(message.date).utc().format('YYYY-MM-DD hh:mm:ss'), + "2019-05-05 10:00:00" + ); + assert.strictEqual(message.id, 4000); + assert.strictEqual(message.originThread, this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )); + assert.ok( + message.allThreads.includes(this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )) + ); + // from partnerId being in needaction_partner_ids + assert.ok(message.allThreads.includes(this.env.messaging.inbox)); + // from partnerId being in starred_partner_ids + assert.ok(message.allThreads.includes(this.env.messaging.starred)); + const attachment = this.env.models['mail.attachment'].find(attachment => attachment.id === 750); + assert.ok(attachment); + assert.strictEqual(attachment.filename, "test.txt"); + assert.strictEqual(attachment.id, 750); + assert.notOk(attachment.isTemporary); + assert.strictEqual(attachment.mimetype, 'text/plain'); + assert.strictEqual(attachment.name, "test.txt"); + const channel = this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + ); + assert.ok(channel); + assert.strictEqual(channel.model, 'mail.channel'); + assert.strictEqual(channel.id, 100); + assert.strictEqual(channel.name, "General"); + const partner = this.env.models['mail.partner'].find(partner => partner.id === 5); + assert.ok(partner); + assert.strictEqual(partner.display_name, "Demo"); + assert.strictEqual(partner.id, 5); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js new file mode 100644 index 00000000000..9ad56eb8da0 --- /dev/null +++ b/addons/mail/static/src/models/message_seen_indicator/message_seen_indicator.js @@ -0,0 +1,286 @@ +odoo.define('mail/static/src/models/message_seen_indicator/message_seen_indicator.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MessageSeenIndicator extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.message|integer} message + * @param {mail.thread|integer} thread + * @returns {string|undefined} + */ + static computeId(message, thread) { + if (message && thread) { + const messageId = Number.isInteger(message) ? message : message.id; + const threadId = Number.isInteger(thread) ? thread : thread.id; + return [messageId, threadId].join('-'); + } + return undefined; + } + + /** + * @static + * @param {mail.thread} [channel] the concerned thread + */ + static recomputeFetchedValues(channel = undefined) { + const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined; + const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction); + for (const indicator of indicators) { + indicator.update({ + hasEveryoneFetched: indicator._computeHasEveryoneFetched(), + hasSomeoneFetched: indicator._computeHasSomeoneFetched(), + partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(), + }); + } + } + + /** + * @static + * @param {mail.thread} [channel] the concerned thread + */ + static recomputeSeenValues(channel = undefined) { + const indicatorFindFunction = channel ? localIndicator => localIndicator.thread === channel : undefined; + const indicators = this.env.models['mail.message_seen_indicator'].all(indicatorFindFunction); + for (const indicator of indicators) { + indicator.update({ + hasEveryoneSeen: indicator._computeHasEveryoneSeen(), + hasSomeoneFetched: indicator._computeHasSomeoneFetched(), + hasSomeoneSeen: indicator._computeHasSomeoneSeen(), + isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: + indicator._computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone(), + partnersThatHaveFetched: indicator._computePartnersThatHaveFetched(), + partnersThatHaveSeen: indicator._computePartnersThatHaveSeen(), + }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeFetchedValues + * @see computeSeenValues + */ + _computeHasEveryoneFetched() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosDidNotFetch = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + ( + !partnerSeenInfo.lastFetchedMessage || + partnerSeenInfo.lastFetchedMessage.id < this.message.id + ) + ); + return otherPartnerSeenInfosDidNotFetch.length === 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeSeenValues + */ + _computeHasEveryoneSeen() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosDidNotSee = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + ( + !partnerSeenInfo.lastSeenMessage || + partnerSeenInfo.lastSeenMessage.id < this.message.id + ) + ); + return otherPartnerSeenInfosDidNotSee.length === 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeFetchedValues + * @see computeSeenValues + */ + _computeHasSomeoneFetched() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosFetched = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastFetchedMessage && + partnerSeenInfo.lastFetchedMessage.id >= this.message.id + ); + return otherPartnerSeenInfosFetched.length > 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeSeenValues + */ + _computeHasSomeoneSeen() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return false; + } + const otherPartnerSeenInfosSeen = + this.thread.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastSeenMessage && + partnerSeenInfo.lastSeenMessage.id >= this.message.id + ); + return otherPartnerSeenInfosSeen.length > 0; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {boolean} + * @see computeSeenValues + */ + _computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone() { + if ( + !this.message || + !this.thread || + !this.thread.lastCurrentPartnerMessageSeenByEveryone + ) { + return false; + } + return this.message.id < this.thread.lastCurrentPartnerMessageSeenByEveryone.id; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {mail.partner[]} + * @see computeFetchedValues + * @see computeSeenValues + */ + _computePartnersThatHaveFetched() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return [['unlink-all']]; + } + const otherPartnersThatHaveFetched = this.thread.partnerSeenInfos + .filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastFetchedMessage && + partnerSeenInfo.lastFetchedMessage.id >= this.message.id + ) + .map(partnerSeenInfo => partnerSeenInfo.partner); + if (otherPartnersThatHaveFetched.length === 0) { + return [['unlink-all']]; + } + return [['replace', otherPartnersThatHaveFetched]]; + } + + /** + * Manually called as not always called when necessary + * + * @private + * @returns {mail.partner[]} + * @see computeSeenValues + */ + _computePartnersThatHaveSeen() { + if (!this.message || !this.thread || !this.thread.partnerSeenInfos) { + return [['unlink-all']]; + } + const otherPartnersThatHaveSeen = this.thread.partnerSeenInfos + .filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.message.author && + partnerSeenInfo.lastSeenMessage && + partnerSeenInfo.lastSeenMessage.id >= this.message.id) + .map(partnerSeenInfo => partnerSeenInfo.partner); + if (otherPartnersThatHaveSeen.length === 0) { + return [['unlink-all']]; + } + return [['replace', otherPartnersThatHaveSeen]]; + } + } + + MessageSeenIndicator.modelName = 'mail.message_seen_indicator'; + + MessageSeenIndicator.fields = { + hasEveryoneFetched: attr({ + compute: '_computeHasEveryoneFetched', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + hasEveryoneSeen: attr({ + compute: '_computeHasEveryoneSeen', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + hasSomeoneFetched: attr({ + compute: '_computeHasSomeoneFetched', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + hasSomeoneSeen: attr({ + compute: '_computeHasSomeoneSeen', + default: false, + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + id: attr(), + isMessagePreviousToLastCurrentPartnerMessageSeenByEveryone: attr({ + compute: '_computeIsMessagePreviousToLastCurrentPartnerMessageSeenByEveryone', + default: false, + dependencies: [ + 'messageId', + 'threadLastCurrentPartnerMessageSeenByEveryone', + ], + }), + message: many2one('mail.message'), + messageAuthor: many2one('mail.partner', { + related: 'message.author', + }), + messageId: attr({ + related: 'message.id', + }), + partnersThatHaveFetched: many2many('mail.partner', { + compute: '_computePartnersThatHaveFetched', + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + partnersThatHaveSeen: many2many('mail.partner', { + compute: '_computePartnersThatHaveSeen', + dependencies: ['messageAuthor', 'messageId', 'threadPartnerSeenInfos'], + }), + thread: many2one('mail.thread', { + inverse: 'messageSeenIndicators' + }), + threadPartnerSeenInfos: one2many('mail.thread_partner_seen_info', { + related: 'thread.partnerSeenInfos', + }), + threadLastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', { + related: 'thread.lastCurrentPartnerMessageSeenByEveryone', + }), + }; + + return MessageSeenIndicator; +} + +registerNewModel('mail.message_seen_indicator', factory); + +}); diff --git a/addons/mail/static/src/models/messaging/messaging.js b/addons/mail/static/src/models/messaging/messaging.js new file mode 100644 index 00000000000..4cd4c22a82f --- /dev/null +++ b/addons/mail/static/src/models/messaging/messaging.js @@ -0,0 +1,216 @@ +odoo.define('mail/static/src/models/messaging/messaging.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Messaging extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + this.env.services['bus_service'].off('window_focus', null, this._handleGlobalWindowFocus); + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Open the form view of the record with provided id and model. + * + * @param {Object} param0 + * @param {integer} param0.id + * @param {string} param0.model + */ + openDocument({ id, model }) { + this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: model, + views: [[false, 'form']], + res_id: id, + }, + }); + this.messagingMenu.close(); + } + + /** + * Handles redirection to a model and id. Try to handle it in the context + * of messaging (e.g. open chat if this is a user), otherwise fallback to + * opening form view of record. + * + * @param {Object} param0 + * @param {integer} param0.id + * @param {string} param0.model + * FIXME needs to be tested and maybe refactored (see task-2244279) + */ + async redirect({ id, model }) { + if (model === 'mail.channel') { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === id && + thread.model === 'mail.channel' + ); + if (!channel || !channel.isPinned) { + this.env.models['mail.thread'].joinChannel(id, { autoselect: true }); + return; + } + channel.open(); + } else if (model === 'res.partner') { + if (id === this.currentPartner.id) { + this.openDocument({ + model: 'res.partner', + id, + }); + return; + } + const partner = this.env.models['mail.partner'].insert({ id }); + if (!partner.user) { + await this.async(() => partner.checkIsUser()); + } + if (!partner.user) { + // partner is not a user, open document instead + this.openDocument({ + model: 'res.partner', + id: partner.id, + }); + return; + } + const chat = partner.correspondentThreads.find(thread => + thread.channel_type === 'chat' + ); + if (!chat) { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + partnerId: id, + type: 'chat', + }); + return; + } + chat.open(); + } else { + this.openDocument({ + model: 'res.partner', + id, + }); + } + } + + /** + * Start messaging and related records. + */ + async start() { + this._handleGlobalWindowFocus = this._handleGlobalWindowFocus.bind(this); + this.env.services['bus_service'].on('window_focus', null, this._handleGlobalWindowFocus); + await this.async(() => this.initializer.start()); + this.notificationHandler.start(); + this.update({ isInitialized: true }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + */ + _handleGlobalWindowFocus() { + this.update({ outOfFocusUnreadMessageCounter: 0 }); + this.env.bus.trigger('set_title_part', { + part: '_chat', + }); + } + + } + + Messaging.fields = { + attachmentViewer: one2one('mail.attachment_viewer', { + autocreate: true, + isCausal: true, + }), + cannedResponses: attr({ + default: {}, + }), + chatWindowManager: one2one('mail.chat_window_manager', { + autocreate: true, + inverse: 'messaging', + isCausal: true, + }), + commands: attr({ + default: {}, + }), + currentPartner: one2one('mail.partner'), + currentUser: one2one('mail.user'), + device: one2one('mail.device', { + autocreate: true, + isCausal: true, + }), + dialogManager: one2one('mail.dialog_manager', { + autocreate: true, + isCausal: true, + }), + discuss: one2one('mail.discuss', { + autocreate: true, + isCausal: true, + }), + /** + * Mailbox History. + */ + history: one2one('mail.thread'), + /** + * Mailbox Inbox. + */ + inbox: one2one('mail.thread'), + initializer: one2one('mail.messaging_initializer', { + autocreate: true, + inverse: 'messaging', + isCausal: true, + }), + isInitialized: attr({ + default: false, + }), + locale: one2one('mail.locale', { + autocreate: true, + isCausal: true, + }), + messagingMenu: one2one('mail.messaging_menu', { + autocreate: true, + inverse: 'messaging', + isCausal: true, + }), + /** + * Mailbox Moderation. + */ + moderation: one2one('mail.thread'), + notificationGroupManager: one2one('mail.notification_group_manager', { + autocreate: true, + isCausal: true, + }), + notificationHandler: one2one('mail.messaging_notification_handler', { + autocreate: true, + inverse: 'messaging', + isCausal: true, + }), + outOfFocusUnreadMessageCounter: attr({ + default: 0, + }), + partnerRoot: many2one('mail.partner'), + publicPartner: many2one('mail.partner'), + /** + * Mailbox Starred. + */ + starred: one2one('mail.thread'), + }; + + Messaging.modelName = 'mail.messaging'; + + return Messaging; +} + +registerNewModel('mail.messaging', factory); + +}); diff --git a/addons/mail/static/src/models/messaging/messaging_tests.js b/addons/mail/static/src/models/messaging/messaging_tests.js new file mode 100644 index 00000000000..11a7b1c5779 --- /dev/null +++ b/addons/mail/static/src/models/messaging/messaging_tests.js @@ -0,0 +1,66 @@ +odoo.define('mail/static/src/models/messaging/messaging_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('messaging', {}, function () { +QUnit.module('messaging_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + const { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + this.env = undefined; + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + }, +}); + +QUnit.test('currentPartner initialized from session', async function (assert) { + assert.expect(5); + + await this.start(); + + assert.ok(this.env.messaging.currentPartner); + assert.strictEqual( + this.env.messaging.currentPartner.id, + this.env.session.partner_id + ); + assert.strictEqual( + this.env.messaging.currentPartner.name, + this.env.session.name + ); + assert.strictEqual( + this.env.messaging.currentPartner.display_name, + this.env.session.partner_display_name + ); + assert.strictEqual( + this.env.messaging.currentPartner.user.id, + this.env.session.uid + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js new file mode 100644 index 00000000000..3cee3777d3a --- /dev/null +++ b/addons/mail/static/src/models/messaging_initializer/messaging_initializer.js @@ -0,0 +1,292 @@ +odoo.define('mail/static/src/models/messaging_initializer/messaging_initializer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MessagingInitializer extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Fetch messaging data initially to populate the store specifically for + * the current user. This includes pinned channels for instance. + */ + async start() { + await this.async(() => this.env.session.is_bound); + + this.messaging.update({ + history: [['create', { + id: 'history', + isPinned: true, + model: 'mail.box', + name: this.env._t("History"), + }]], + inbox: [['create', { + id: 'inbox', + isPinned: true, + model: 'mail.box', + name: this.env._t("Inbox"), + }]], + starred: [['create', { + id: 'starred', + isPinned: true, + model: 'mail.box', + name: this.env._t("Starred"), + }]], + }); + + const device = this.messaging.device; + device.start(); + const context = Object.assign({ + isMobile: device.isMobile, + }, this.env.session.user_context); + const discuss = this.messaging.discuss; + const data = await this.async(() => this.env.services.rpc({ + route: '/mail/init_messaging', + params: { context: context } + })); + this._init(data); + if (discuss.isOpen) { + discuss.openInitThread(); + } + if (this.env.autofetchPartnerImStatus) { + this.env.models['mail.partner'].startLoopFetchImStatus(); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @param {Object} param0 + * @param {Object} param0.channel_slots + * @param {Array} [param0.commands=[]] + * @param {boolean} [param0.is_moderator=false] + * @param {Object} [param0.mail_failures={}] + * @param {Object[]} [param0.mention_partner_suggestions=[]] + * @param {Object[]} [param0.moderation_channel_ids=[]] + * @param {integer} [param0.moderation_counter=0] + * @param {integer} [param0.needaction_inbox_counter=0] + * @param {Array} param0.partner_root + * @param {Object[]} [param0.shortcodes=[]] + * @param {integer} [param0.starred_counter=0] + */ + _init({ + channel_slots, + commands = [], + is_moderator = false, + mail_failures = {}, + mention_partner_suggestions = [], + menu_id, + moderation_channel_ids = [], + moderation_counter = 0, + needaction_inbox_counter = 0, + partner_root, + public_partner, + shortcodes = [], + starred_counter = 0 + }) { + const discuss = this.messaging.discuss; + this._initPartners({ + moderation_channel_ids, + partner_root, + public_partner, + }); + this._initChannels(channel_slots); + this._initCommands(commands); + this._initMailboxes({ + is_moderator, + moderation_counter, + needaction_inbox_counter, + starred_counter, + }); + this._initMailFailures(mail_failures); + this._initCannedResponses(shortcodes); + this._initMentionPartnerSuggestions(mention_partner_suggestions); + discuss.update({ menu_id }); + } + + /** + * @private + * @param {Object[]} shortcodes + */ + _initCannedResponses(shortcodes) { + const messaging = this.messaging; + const cannedResponses = shortcodes + .map(s => { + const { id, source, substitution } = s; + return { id, source, substitution }; + }) + .reduce((obj, cr) => { + obj[cr.id] = cr; + return obj; + }, {}); + messaging.update({ cannedResponses }); + } + + /** + * @private + * @param {Object} [param0={}] + * @param {Object[]} [param0.channel_channel=[]] + * @param {Object[]} [param0.channel_direct_message=[]] + * @param {Object[]} [param0.channel_private_group=[]] + */ + _initChannels({ + channel_channel = [], + channel_direct_message = [], + channel_private_group = [], + } = {}) { + for (const data of channel_channel.concat(channel_direct_message, channel_private_group)) { + this.env.models['mail.thread'].insert(Object.assign( + {}, + this.env.models['mail.thread'].convertData(data), + { isPinned: true } + )); + } + } + + /** + * @private + * @param {Object[]} commandsData + */ + _initCommands(commandsData) { + const messaging = this.messaging; + const commands = commandsData + .map(command => { + return Object.assign({ + id: command.name, + }, command); + }) + .reduce((obj, command) => { + obj[command.id] = command; + return obj; + }, {}); + messaging.update({ commands }); + } + + /** + * @private + * @param {Object} param0 + * @param {boolean} param0.is_moderator + * @param {integer} param0.moderation_counter + * @param {integer} param0.needaction_inbox_counter + * @param {integer} param0.starred_counter + */ + _initMailboxes({ + is_moderator, + moderation_counter, + needaction_inbox_counter, + starred_counter, + }) { + this.env.messaging.inbox.update({ counter: needaction_inbox_counter }); + this.env.messaging.starred.update({ counter: starred_counter }); + if (is_moderator) { + this.messaging.update({ + moderation: [['create', { + counter: moderation_counter, + id: 'moderation', + isPinned: true, + model: 'mail.box', + name: this.env._t("Moderation"), + }]], + }); + } + } + + /** + * @private + * @param {Object} mailFailuresData + */ + _initMailFailures(mailFailuresData) { + for (const messageData of mailFailuresData) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + // implicit: failures are sent by the server at initialization + // only if the current partner is author of the message + if (!message.author && this.messaging.currentPartner) { + message.update({ author: [['link', this.messaging.currentPartner]] }); + } + } + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object[]} mentionPartnerSuggestionsData + */ + _initMentionPartnerSuggestions(mentionPartnerSuggestionsData) { + for (const suggestions of mentionPartnerSuggestionsData) { + for (const suggestion of suggestions) { + const { email, id, name } = suggestion; + this.env.models['mail.partner'].insert({ email, id, name }); + } + } + } + + /** + * @private + * @param {Array} param0 partner root name get + * @param {integer} param0[0] partner root id + * @param {string} param0[1] partner root display_name + */ + _initPartners({ + moderation_channel_ids = [], + partner_root: { + active: partnerRootIsActive, + display_name: partnerRootDisplayName, + id: partnerRootId, + }, + public_partner: { + active: publicPartnerIsActive, + display_name: publicPartnerDisplayName, + id: publicPartnerId, + }, + }) { + this.messaging.update({ + currentPartner: [['insert', { + display_name: this.env.session.partner_display_name, + id: this.env.session.partner_id, + moderatedChannelIds: moderation_channel_ids, + name: this.env.session.name, + user: [['insert', { id: this.env.session.uid }]], + }]], + currentUser: [['insert', { id: this.env.session.uid }]], + partnerRoot: [['insert', { + active: partnerRootIsActive, + display_name: partnerRootDisplayName, + id: partnerRootId, + }]], + publicPartner: [['insert', { + active: publicPartnerIsActive, + display_name: publicPartnerDisplayName, + id: publicPartnerId, + }]], + }); + } + + } + + MessagingInitializer.fields = { + messaging: one2one('mail.messaging', { + inverse: 'initializer', + }), + }; + + MessagingInitializer.modelName = 'mail.messaging_initializer'; + + return MessagingInitializer; +} + +registerNewModel('mail.messaging_initializer', factory); + +}); diff --git a/addons/mail/static/src/models/messaging_menu/messaging_menu.js b/addons/mail/static/src/models/messaging_menu/messaging_menu.js new file mode 100644 index 00000000000..c8e52733ff7 --- /dev/null +++ b/addons/mail/static/src/models/messaging_menu/messaging_menu.js @@ -0,0 +1,131 @@ +odoo.define('mail/static/src/models/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class MessagingMenu extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Close the messaging menu. Should reset its internal state. + */ + close() { + this.update({ + activeTabId: 'all', + isMobileNewMessageToggled: false, + isOpen: false, + }); + } + + /** + * Toggle the visibility of the messaging menu "new message" input in + * mobile. + */ + toggleMobileNewMessage() { + this.update({ isMobileNewMessageToggled: !this.isMobileNewMessageToggled }); + } + + /** + * Toggle whether the messaging menu is open or not. + */ + async toggleOpen() { + if (!this.isOpen) { + const inbox = this.env.messaging.inbox; + if (!inbox.mainCache.isLoaded && !inbox.mainCache.isLoading) { + // populate some needaction messages on threads. + // FIXME: await necessary due to bug in tests without it + // see task-id 2275999 + await this.async(() => inbox.mainCache.loadMessages()); + } + } + this.update({ isOpen: !this.isOpen }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {integer} + */ + _updateCounter() { + if (!this.env.messaging) { + return 0; + } + const inboxMailbox = this.env.messaging.inbox; + const unreadChannels = this.env.models['mail.thread'].all(thread => + thread.message_unread_counter > 0 && + thread.model === 'mail.channel' + ); + let counter = unreadChannels.length; + if (inboxMailbox) { + counter += inboxMailbox.counter; + } + if (!this.messaging) { + // compute after delete + return counter; + } + if (this.messaging.notificationGroupManager) { + counter += this.messaging.notificationGroupManager.groups.reduce( + (total, group) => total + group.notifications.length, + 0 + ); + } + return counter; + } + + /** + * @override + */ + _updateAfter(previous) { + const counter = this._updateCounter(); + if (this.counter !== counter) { + this.update({ counter }); + } + } + + } + + MessagingMenu.fields = { + /** + * Tab selected in the messaging menu. + * Either 'all', 'chat' or 'channel'. + */ + activeTabId: attr({ + default: 'all', + }), + counter: attr({ + default: 0, + }), + /** + * Determine whether the mobile new message input is visible or not. + */ + isMobileNewMessageToggled: attr({ + default: false, + }), + /** + * Determine whether the messaging menu dropdown is open or not. + */ + isOpen: attr({ + default: false, + }), + messaging: one2one('mail.messaging', { + inverse: 'messagingMenu', + }), + }; + + MessagingMenu.modelName = 'mail.messaging_menu'; + + return MessagingMenu; +} + +registerNewModel('mail.messaging_menu', factory); + +}); diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js new file mode 100644 index 00000000000..e779b5a20ff --- /dev/null +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -0,0 +1,744 @@ +odoo.define('mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2one } = require('mail/static/src/model/model_field.js'); + +const PREVIEW_MSG_MAX_SIZE = 350; // optimal for native English speakers + +function factory(dependencies) { + + class MessagingNotificationHandler extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + this.env.services['bus_service'].off('notification'); + this.env.services['bus_service'].stopPolling(); + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Fetch messaging data initially to populate the store specifically for + * the current users. This includes pinned channels for instance. + */ + start() { + this.env.services.bus_service.onNotification(null, notifs => this._handleNotifications(notifs)); + this.env.services.bus_service.startPolling(); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @param {Object[]} notifications + * @returns {Object[]} + */ + _filterNotificationsOnUnsubscribe(notifications) { + const unsubscribedNotif = notifications.find(notif => + notif[1].info === 'unsubscribe'); + if (unsubscribedNotif) { + notifications = notifications.filter(notif => + notif[0][1] !== 'mail.channel' || + notif[0][2] !== unsubscribedNotif[1].id + ); + } + return notifications; + } + + /** + * @private + * @param {Object[]} notifications + * @param {Array} notifications[i][0] meta-data of the notification. + * @param {string} notifications[i][0][0] name of database this + * notification comes from. + * @param {string} notifications[i][0][1] type of notification. + * @param {integer} notifications[i][0][2] usually id of related type + * of notification. For instance, with `mail.channel`, this is the id + * of the channel. + * @param {Object} notifications[i][1] payload of the notification + */ + async _handleNotifications(notifications) { + const filteredNotifications = this._filterNotificationsOnUnsubscribe(notifications); + const proms = filteredNotifications.map(notification => { + const [[, model, id], data] = notification; + switch (model) { + case 'ir.needaction': + return this._handleNotificationNeedaction(data); + case 'mail.channel': + return this._handleNotificationChannel(Object.assign({ + channelId: id, + }, data)); + case 'res.partner': + return this._handleNotificationPartner(Object.assign({}, data)); + default: + console.warn(`mail.messaging_notification_handler: Unhandled notification "${model}"`); + return; + } + }); + await this.async(() => Promise.all(proms)); + } + + /** + * @private + * @param {Object} data + * @param {integer} data.channelId + * @param {string} [data.info] + * @param {boolean} [data.is_typing] + * @param {integer} [data.last_message_id] + * @param {integer} [data.partner_id] + */ + async _handleNotificationChannel(data) { + const { + channelId, + id, + info, + is_typing, + last_message_id, + partner_id, + } = data; + switch (info) { + case 'channel_fetched': + return this._handleNotificationChannelFetched({ + id, + channelId, + last_message_id, + partner_id, + }); + case 'channel_seen': + return this._handleNotificationChannelSeen({ + id, + channelId, + last_message_id, + partner_id, + }); + case 'typing_status': + return this._handleNotificationChannelTypingStatus({ + channelId, + is_typing, + partner_id, + }); + default: + return this._handleNotificationChannelMessage(data); + } + } + + /** + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {integer} param0.id + * @param {integer} param0.last_message_id + * @param {integer} param0.partner_id + */ + async _handleNotificationChannelFetched({ + id, + channelId, + last_message_id, + partner_id, + }) { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && thread.model === 'mail.channel' + ); + if (!channel) { + // for example seen from another browser, the current one has no + // knowledge of the channel + return; + } + channel.update({ + partnerSeenInfos: [['insert', + { + lastFetchedMessage: [['insert', {id: last_message_id}]], + id, + partner: [['insert', {id: partner_id}]], + } + ]], + messageSeenIndicators: [['insert', + { + id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id), + message: [['insert', {id: last_message_id}]], + } + ]], + }); + // FIXME force the computing of message values (cf task-2261221) + this.env.models['mail.message_seen_indicator'].recomputeFetchedValues(channel); + } + + /** + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {...Object} param0.data + * @param {Array} [param0.data.author_id] + * @param {integer} param0.data.author_id[0] + * @param {integer[]} param0.data.channel_ids + */ + async _handleNotificationChannelMessage(param0) { + const channelId = param0.channelId; + const convertedData = this.env.models['mail.message'].convertData(param0); + + const oldMessage = this.env.models['mail.message'].find(message => + message.id === convertedData.id + ); + // locally save old value, as insert would overwrite them + const oldMessageModerationStatus = ( + oldMessage && oldMessage.moderation_status + ); + const oldMessageWasModeratedByCurrentPartner = ( + oldMessage && oldMessage.isModeratedByCurrentPartner + ); + + const message = this.env.models['mail.message'].insert(convertedData); + + // join the corresponding channel if necessary + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && + thread.model === 'mail.channel' + ); + if (!channel || !channel.isPinned) { + this.env.models['mail.thread'].joinChannel(channelId); + } + + // If the message was already known: nothing else should be done, + // except if it was pending moderation by the current partner, then + // decrement the moderation counter. + if (oldMessage) { + if ( + oldMessageModerationStatus === 'pending_moderation' && + message.moderation_status !== 'pending_moderation' && + oldMessageWasModeratedByCurrentPartner + ) { + const moderation = this.env.messaging.moderation; + moderation.update({ counter: moderation.counter - 1 }); + } + return; + } + + // If the current partner is author, do nothing else. + if (message.author === this.env.messaging.currentPartner) { + return; + } + + // If the channel wasn't known, joining the channel already updated + // the counter. + if (!channel) { + return; + } + + // Message from mailing channel should not make a notification in + // Odoo for users with notification "Handled by Email". + // Channel has been marked as read server-side in this case, so + // it should not display a notification by incrementing the + // unread counter. + if ( + channel && + channel.mass_mailing && + this.env.session.notification_type === 'email' + ) { + return; + } + + // In all other cases: update counter and notify if out of focus. + const isOdooFocused = this.env.services['bus_service'].isOdooFocused(); + if (!isOdooFocused) { + this._notifyNewChannelMessageWhileOutOfFocus({ channel, message }); + } + if (channel.model === 'mail.channel') { + channel.markAsFetched(); + } + channel.update({ message_unread_counter: channel.message_unread_counter + 1 }); + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * Called when a channel has been seen, and the server responses with the + * last message seen. Useful in order to track last message seen. + * + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {integer} param0.id + * @param {integer} param0.last_message_id + * @param {integer} param0.partner_id + */ + async _handleNotificationChannelSeen({ + channelId, + id, + last_message_id, + partner_id, + }) { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && thread.model === 'mail.channel' + ); + if (!channel) { + // for example seen from another browser, the current one has no + // knowledge of the channel + return; + } + const lastMessage = this.env.models['mail.message'].insert({id: last_message_id}); + const updateData = { + partnerSeenInfos: [['insert', + { + id, + lastFetchedMessage: [['link', lastMessage]], + lastSeenMessage: [['link', lastMessage]], + partner: [['insert', {id: partner_id}]], + }], + ], + messageSeenIndicators: [['insert', + { + id: this.env.models['mail.message_seen_indicator'].computeId(last_message_id, channel.id), + message: [['link', lastMessage]], + } + ]], + }; + if (this.env.messaging.currentPartner.id === partner_id) { + Object.assign(updateData, { + message_unread_counter: 0, + seen_message_id: last_message_id, + }); + } + channel.update(updateData); + // FIXME force the computing of thread values (cf task-2261221) + this.env.models['mail.thread'].computeLastCurrentPartnerMessageSeenByEveryone(channel); + // FIXME force the computing of message values (cf task-2261221) + this.env.models['mail.message_seen_indicator'].recomputeSeenValues(channel); + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer} param0.channelId + * @param {boolean} param0.is_typing + * @param {integer} param0.partner_id + */ + _handleNotificationChannelTypingStatus({ + channelId, + is_typing, + partner_id, + }) { + const channel = this.env.models['mail.thread'].insert({ + id: channelId, + model: 'mail.channel', + }); + const partner = this.env.models['mail.partner'].insert({ id: partner_id }); + if (partner === this.env.messaging.currentPartner) { + // Ignore management of current partner is typing notification. + return; + } + if (is_typing) { + if (channel.typingMembers.includes(partner)) { + channel.refreshOtherMemberTypingMember(partner); + } else { + channel.registerOtherMemberTypingMember(partner); + } + } else { + if (!channel.typingMembers.includes(partner)) { + // Ignore no longer typing notifications of members that + // are not registered as typing something. + return; + } + channel.unregisterOtherMemberTypingMember(partner); + } + } + + /** + * @private + * @param {Object} data + */ + _handleNotificationNeedaction(data) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data) + ); + const inboxMailbox = this.env.messaging.inbox; + inboxMailbox.update({ counter: inboxMailbox.counter + 1 }); + for (const thread of message.allThreads) { + if ( + thread.channel_type === 'channel' && + message.allThreads.includes(inboxMailbox) + ) { + thread.update({ message_needaction_counter: thread.message_needaction_counter + 1 }); + } + const mainThreadCache = thread.mainCache; + mainThreadCache.update({ messages: [['link', message]] }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} data + * @param {string} [data.info] + * @param {string} [data.type] + */ + async _handleNotificationPartner(data) { + const { + info, + type, + } = data; + if (type === 'activity_updated') { + this.env.bus.trigger('activity_updated', data); + } else if (type === 'author') { + return this._handleNotificationPartnerAuthor(data); + } else if (type === 'deletion') { + return this._handleNotificationPartnerDeletion(data); + } else if (type === 'message_notification_update') { + return this._handleNotificationPartnerMessageNotificationUpdate(data.elements); + } else if (type === 'mark_as_read') { + return this._handleNotificationPartnerMarkAsRead(data); + } else if (type === 'moderator') { + return this._handleNotificationPartnerModerator(data); + } else if (type === 'simple_notification') { + const escapedTitle = owl.utils.escape(data.title); + const escapedMessage = owl.utils.escape(data.message); + this.env.services['notification'].notify({ + message: escapedMessage, + sticky: data.sticky, + title: escapedTitle, + type: data.warning ? 'warning' : 'danger', + }); + } else if (type === 'toggle_star') { + return this._handleNotificationPartnerToggleStar(data); + } else if (info === 'transient_message') { + return this._handleNotificationPartnerTransientMessage(data); + } else if (info === 'unsubscribe') { + return this._handleNotificationPartnerUnsubscribe(data.id); + } else if (type === 'user_connection') { + return this._handleNotificationPartnerUserConnection(data); + } else { + return this._handleNotificationPartnerChannel(data); + } + } + + /** + * @private + * @param {Object} data + * @param {Object} data.message + */ + _handleNotificationPartnerAuthor(data) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data.message) + ); + } + + /** + * @private + * @param {Object} data + * @param {string} data.channel_type + * @param {integer} data.id + * @param {string} [data.info] + * @param {boolean} data.is_minimized + * @param {string} data.name + * @param {string} data.state + * @param {string} data.uuid + */ + _handleNotificationPartnerChannel(data) { + const convertedData = this.env.models['mail.thread'].convertData(data); + + let channel = this.env.models['mail.thread'].find(thread => + thread.id === convertedData.id && + thread.model === 'mail.channel' + ); + const wasCurrentPartnerMember = ( + channel && + channel.members.includes(this.env.messaging.currentPartner) + ); + + channel = this.env.models['mail.thread'].insert(Object.assign( + { isPinned: true }, + convertedData, + )); + + if ( + channel.channel_type === 'channel' && + data.info !== 'creation' && + !wasCurrentPartnerMember + ) { + this.env.services['notification'].notify({ + message: _.str.sprintf( + this.env._t("You have been invited to: %s"), + owl.utils.escape(channel.name) + ), + title: this.env._t("Invitation"), + type: 'warning', + }); + } + // a new thread with unread messages could have been added + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} param0.messag_ids + */ + _handleNotificationPartnerDeletion({ message_ids }) { + const moderationMailbox = this.env.messaging.moderation; + for (const id of message_ids) { + const message = this.env.models['mail.message'].find(message => message.id === id); + if (message) { + if ( + message.moderation_status === 'pending_moderation' && + message.originThread.isModeratedByCurrentPartner + ) { + moderationMailbox.update({ counter: moderationMailbox.counter - 1 }); + } + message.delete(); + } + } + // deleting message might have deleted notifications, force recompute + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} data + */ + _handleNotificationPartnerMessageNotificationUpdate(data) { + for (const messageData of data) { + const message = this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + // implicit: failures are sent by the server as notification + // only if the current partner is author of the message + if (!message.author && this.messaging.currentPartner) { + message.update({ author: [['link', this.messaging.currentPartner]] }); + } + } + this.messaging.notificationGroupManager.computeGroups(); + // manually force recompute of counter (after computing the groups) + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} [param0.channel_ids + * @param {integer[]} [param0.message_ids=[]] + */ + _handleNotificationPartnerMarkAsRead({ channel_ids, message_ids = [] }) { + const inboxMailbox = this.env.messaging.inbox; + const historyMailbox = this.env.messaging.history; + const mainHistoryThreadCache = historyMailbox.mainCache; + + // 1. move messages from inbox to history + for (const cache of inboxMailbox.caches) { + const messages = message_ids + .map(id => this.env.models['mail.message'].find(message => message.id === id)) + .filter(message => !!message); + cache.update({ messages: [['unlink', messages]] }); + // TODO FIXME cannot update other caches of history since the + // messages in them depend on the search domain + mainHistoryThreadCache.update({ messages: [['link', messages]] }); + } + + // 2. remove "needaction" from channels + let channels; + if (channel_ids) { + channels = channel_ids + .map(id => this.env.models['mail.thread'].find(thread => + thread.id === id && + thread.model === 'mail.channel' + )) + .filter(thread => !!thread); + } else { + // flux specific: channel_ids unset means "mark all as read" + channels = this.env.models['mail.thread'].all(thread => + thread.model === 'mail.channel' + ); + } + for (const channel of channels) { + channel.update({ message_needaction_counter: 0 }); + } + inboxMailbox.update({ counter: inboxMailbox.counter - message_ids.length }); + + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {Object} param0.message + */ + _handleNotificationPartnerModerator({ message: data }) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(data) + ); + const moderationMailbox = this.env.messaging.moderation; + if (moderationMailbox) { + moderationMailbox.update({ counter: moderationMailbox.counter + 1 }); + } + // manually force recompute of counter + this.messaging.messagingMenu.update(); + } + + /** + * @private + * @param {Object} param0 + * @param {integer[]} param0.message_ids + * @param {boolean} param0.starred + */ + _handleNotificationPartnerToggleStar({ message_ids = [], starred }) { + const starredMailbox = this.env.messaging.starred; + for (const messageId of message_ids) { + const message = this.env.models['mail.message'].find(message => + message.id === messageId + ); + if (!message) { + continue; + } + if (starred) { + message.update({ threadCaches: [['link', starredMailbox.mainCache]] }); + starredMailbox.update({ counter: starredMailbox.counter + 1 }); + } else { + for (const cache of starredMailbox.caches) { + cache.update({ messages: [['unlink', message]] }); + } + starredMailbox.update({ counter: starredMailbox.counter - 1 }); + } + } + } + + /** + * On receiving a transient message, i.e. a message which does not come + * from a member of the channel. Usually a log message, such as one + * generated from a command with ('/'). + * + * @private + * @param {Object} data + */ + _handleNotificationPartnerTransientMessage(data) { + const convertedData = this.env.models['mail.message'].convertData(data); + const messageIds = this.env.models['mail.message'].all().map(message => message.id); + const partnerRoot = this.env.messaging.partnerRoot; + this.env.models['mail.message'].create(Object.assign(convertedData, { + author: [['link', partnerRoot]], + id: (messageIds ? Math.max(...messageIds) : 0) + 0.01, + isTransient: true, + })); + } + + /** + * @private + * @param {integer} channelId + */ + _handleNotificationPartnerUnsubscribe(channelId) { + const channel = this.env.models['mail.thread'].find(thread => + thread.id === channelId && + thread.model === 'mail.channel' + ); + if (!channel) { + return; + } + let message; + if (channel.correspondent) { + const correspondent = channel.correspondent; + message = _.str.sprintf( + this.env._t("You unpinned your conversation with <b>%s</b>."), + owl.utils.escape(correspondent.name) + ); + } else { + message = _.str.sprintf( + this.env._t("You unsubscribed from <b>%s</b>."), + owl.utils.escape(channel.name) + ); + } + this.env.services['notification'].notify({ + message, + title: this.env._t("Unsubscribed"), + type: 'warning', + }); + channel.update({ isPinned: false }); + } + + /** + * @private + * @param {Object} param0 + * @param {string} param0.message + * @param {integer} param0.partner_id + * @param {string} param0.title + */ + _handleNotificationPartnerUserConnection({ message, partner_id, title }) { + this.env.services['bus_service'].sendNotification(title, message); + const partner = this.env.models['mail.partner'].insert({ + id: partner_id, + }); + partner.openChat(); + } + + /** + * @private + * @param {Object} param0 + * @param {mail.thread} param0.channel + * @param {mail.message} param0.message + */ + _notifyNewChannelMessageWhileOutOfFocus({ channel, message }) { + const author = message.author; + const messaging = this.env.messaging; + let notificationTitle; + if (!author) { + notificationTitle = this.env._t("New message"); + } else { + const authorName = author.nameOrDisplayName; + if (channel.channel_type === 'channel') { + // hack: notification template does not support OWL components, + // so we simply use their template to make HTML as if it comes + // from component + const channelIcon = this.env.qweb.renderToString('mail.ThreadIcon', { + env: this.env, + thread: channel, + }); + const channelName = owl.utils.escape(channel.displayName); + const channelNameWithIcon = channelIcon + channelName; + notificationTitle = _.str.sprintf( + this.env._t("%s from %s"), + owl.utils.escape(authorName), + channelNameWithIcon + ); + } else { + notificationTitle = owl.utils.escape(authorName); + } + } + const notificationContent = message.prettyBody.substr(0, PREVIEW_MSG_MAX_SIZE); + this.env.services['bus_service'].sendNotification(notificationTitle, notificationContent); + messaging.update({ outOfFocusUnreadMessageCounter: messaging.outOfFocusUnreadMessageCounter + 1 }); + const titlePattern = messaging.outOfFocusUnreadMessageCounter === 1 + ? this.env._t("%d Message") + : this.env._t("%d Messages"); + this.env.bus.trigger('set_title_part', { + part: '_chat', + title: _.str.sprintf(titlePattern, messaging.outOfFocusUnreadMessageCounter), + }); + } + + } + + MessagingNotificationHandler.fields = { + messaging: one2one('mail.messaging', { + inverse: 'notificationHandler', + }), + }; + + MessagingNotificationHandler.modelName = 'mail.messaging_notification_handler'; + + return MessagingNotificationHandler; +} + +registerNewModel('mail.messaging_notification_handler', factory); + +}); diff --git a/addons/mail/static/src/models/model/model.js b/addons/mail/static/src/models/model/model.js new file mode 100644 index 00000000000..15647acaf49 --- /dev/null +++ b/addons/mail/static/src/models/model/model.js @@ -0,0 +1,245 @@ +odoo.define('mail/static/src/models/Model', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { RecordDeletedError } = require('mail/static/src/model/model_errors.js'); + +/** + * This function generates a class that represent a model. Instances of such + * model (or inherited models) represent logical objects used in whole + * application. They could represent server record (e.g. Thread, Message) or + * UI elements (e.g. MessagingMenu, ChatWindow). These instances are called + * "records", while the classes are called "models". + */ +function factory() { + + class Model { + + /** + * @param {Object} [param0={}] + * @param {boolean} [param0.valid=false] if set, this constructor is + * called by static method `create()`. This should always be the case. + * @throws {Error} in case constructor is called in an invalid way, i.e. + * by instantiating the record manually with `new` instead of from + * static method `create()`. + */ + constructor({ valid = false } = {}) { + if (!valid) { + throw new Error("Record must always be instantiated from static method 'create()'"); + } + } + + /** + * Called when the record is being created, but not yet processed + * its create value on the fields. This method is handy to define purely + * technical property on this record, like handling of timers. This + * method acts like the constructor, but has a very important difference: + * the `this` is the proxified record, so evaluation of field values + * on get/set work correctly. + */ + init() {} + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns all records of this model that match provided criteria. + * + * @static + * @param {function} [filterFunc] + * @returns {mail.model[]} + */ + static all(filterFunc) { + return this.env.modelManager.all(this, filterFunc); + } + + /** + * This method is used to create new records of this model + * with provided data. This is the only way to create them: + * instantiation must never been done with keyword `new` outside of this + * function, otherwise the record will not be registered. + * + * @static + * @param {Object} [data] data object with initial data, including relations. + * @returns {mail.model} newly created record + */ + static create(data) { + return this.env.modelManager.create(this, data); + } + + /** + * Get the record that has provided criteria, if it exists. + * + * @static + * @param {function} findFunc + * @returns {mail.model|undefined} + */ + static find(findFunc) { + return this.env.modelManager.find(this, findFunc); + } + + /** + * This method returns the record of this model that matches provided + * local id. Useful to convert a local id to a record. Note that even + * if there's a record in the system having provided local id, if the + * resulting record is not an instance of this model, this getter + * assumes the record does not exist. + * + * @static + * @param {string|mail.model|undefined} recordOrLocalId + * @returns {mail.model|undefined} + */ + static get(recordOrLocalId) { + return this.env.modelManager.get(this, recordOrLocalId); + } + + /** + * This method creates a record or updates one, depending + * on provided data. + * + * @static + * @param {Object} data + * @returns {mail.model} created or updated record. + */ + static insert(data) { + return this.env.modelManager.insert(this, data); + } + + /** + * Perform an async function and wait until it is done. If the record + * is deleted, it raises a RecordDeletedError. + * + * @param {function} func an async function + * @throws {RecordDeletedError} in case the current record is not alive + * at the end of async function call, whether it's resolved or + * rejected. + * @throws {any} forwards any error in case the current record is still + * alive at the end of rejected async function call. + * @returns {any} result of resolved async function. + */ + async async(func) { + return new Promise((resolve, reject) => { + Promise.resolve(func()).then(result => { + if (this.constructor.get(this)) { + resolve(result); + } else { + reject(new RecordDeletedError(this.localId)); + } + }).catch(error => { + if (this.constructor.get(this)) { + reject(error); + } else { + reject(new RecordDeletedError(this.localId)); + } + }); + }); + } + + /** + * This method deletes this record. + */ + delete() { + this.env.modelManager.delete(this); + } + + /** + * Update this record with provided data. + * + * @param {Object} [data={}] + */ + update(data = {}) { + this.env.modelManager.update(this, data); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @static + * @private + * @param {Object} data + * @param {any} data.id + * @return {function} + */ + static _findFunctionFromData(data) { + return record => record.id === data.id; + } + + /** + * This method generates a local id for this record that is + * being created at the moment. + * + * This function helps customizing the local id to ease mapping a local + * id to its record for the developer that reads the local id. For + * instance, the local id of a thread cache could combine the thread + * and stringified domain in its local id, which is much easier to + * track relations and records in the system instead of arbitrary + * number to differenciate them. + * + * @private + * @param {Object} data + * @returns {string} + */ + _createRecordLocalId(data) { + return _.uniqueId(`${this.constructor.modelName}_`); + } + + /** + * This function is called when this record has been explicitly updated + * with `.update()` or static method `.create()`, at the end of an + * record update cycle. This is a backward-compatible behaviour that + * is deprecated: you should use computed fields instead. + * + * @deprecated + * @abstract + * @private + * @param {Object} previous contains data that have been stored by + * `_updateBefore()`. Useful to make extra update decisions based on + * previous data. + */ + _updateAfter(previous) {} + + /** + * This function is called just at the beginning of an explicit update + * on this function, with `.update()` or static method `.create()`. This + * is useful to remember previous values of fields in `_updateAfter`. + * This is a backward-compatible behaviour that is deprecated: you + * should use computed fields instead. + * + * @deprecated + * @abstract + * @private + * @param {Object} data + * @returns {Object} + */ + _updateBefore() { + return {}; + } + + } + + /** + * Models should define fields in static prop or getter `fields`. + * It contains an object with name of field as key and value are objects + * that define the field. There are some helpers to ease the making of these + * objects, @see `mail/static/src/model/model_field.js` + * + * Note: fields of super-class are automatically inherited, therefore a + * sub-class should (re-)define fields without copying ancestors' fields. + */ + Model.fields = {}; + + /** + * Name of the model. Important to refer to appropriate model class + * like in relational fields. Name of model classes must be unique. + */ + Model.modelName = 'mail.model'; + + return Model; +} + +registerNewModel('mail.model', factory); + +}); diff --git a/addons/mail/static/src/models/notification/notification.js b/addons/mail/static/src/models/notification/notification.js new file mode 100644 index 00000000000..95e8a013876 --- /dev/null +++ b/addons/mail/static/src/models/notification/notification.js @@ -0,0 +1,69 @@ +odoo.define('mail/static/src/models/notification/notification.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class Notification extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('failure_type' in data) { + data2.failure_type = data.failure_type; + } + if ('id' in data) { + data2.id = data.id; + } + if ('notification_status' in data) { + data2.notification_status = data.notification_status; + } + if ('notification_type' in data) { + data2.notification_type = data.notification_type; + } + if ('partner_id' in data) { + if (!data.partner_id) { + data2.partner = [['unlink-all']]; + } else { + data2.partner = [ + ['insert', { + display_name: data.partner_id[1], + id: data.partner_id[0], + }], + ]; + } + } + return data2; + } + + } + + Notification.fields = { + failure_type: attr(), + id: attr(), + message: many2one('mail.message', { + inverse: 'notifications', + }), + notification_status: attr(), + notification_type: attr(), + partner: many2one('mail.partner'), + }; + + Notification.modelName = 'mail.notification'; + + return Notification; +} + +registerNewModel('mail.notification', factory); + +}); diff --git a/addons/mail/static/src/models/notification_group/notification_group.js b/addons/mail/static/src/models/notification_group/notification_group.js new file mode 100644 index 00000000000..9fdbefc4979 --- /dev/null +++ b/addons/mail/static/src/models/notification_group/notification_group.js @@ -0,0 +1,115 @@ +odoo.define('mail/static/src/models/notification_group/notification_group.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class NotificationGroup extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Opens the view that allows to cancel all notifications of the group. + */ + openCancelAction() { + const device = this.env.messaging.device; + if (this.notification_type === 'email') { + this.env.bus.trigger('do-action', { + action: 'mail.mail_resend_cancel_action', + options: { + additional_context: { + default_model: this.res_model, + unread_counter: this.notifications.length, + }, + }, + }); + } + if (!device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + + /** + * Opens the view that displays either the single record of the group or + * all the records in the group. + */ + openDocuments() { + if (this.res_id) { + if (this.res_model === 'mail.channel') { + const channel = this.env.models['mail.thread'].insert({ + id: this.res_id, + model: this.res_model, + }); + channel.open(); + } else { + this.env.messaging.openDocument({ + id: this.res_id, + model: this.res_model, + }); + } + } else { + this._openDocuments(); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + _createRecordLocalId(data) { + const NotificationGroup = this.env.models['mail.notification_group']; + return `${NotificationGroup.modelName}_${data.id}`; + } + + /** + * Opens the view that displays all the records of the group. + * + * @private + */ + _openDocuments() { + const device = this.env.messaging.device; + if (this.notification_type === 'email') { + this.env.bus.trigger('do-action', { + action: { + name: this.env._t("Mail Failures"), + type: 'ir.actions.act_window', + view_mode: 'kanban,list,form', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + target: 'current', + res_model: this.res_model, + domain: [['message_has_error', '=', true]], + }, + }); + } + if (!device.isMobile) { + this.env.messaging.messagingMenu.close(); + } + } + + } + + NotificationGroup.fields = { + date: attr(), + id: attr(), + notification_type: attr(), + notifications: one2many('mail.notification'), + res_id: attr(), + res_model: attr(), + res_model_name: attr(), + }; + + NotificationGroup.modelName = 'mail.notification_group'; + + return NotificationGroup; +} + +registerNewModel('mail.notification_group', factory); + +}); diff --git a/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js new file mode 100644 index 00000000000..be7bef8352a --- /dev/null +++ b/addons/mail/static/src/models/notification_group_manager/notification_group_manager.js @@ -0,0 +1,76 @@ +odoo.define('mail/static/src/models/notification_group_manager/notification_group_manager.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { one2many } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class NotificationGroupManager extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + computeGroups() { + for (const group of this.groups) { + group.delete(); + } + const groups = []; + this.env.messaging.currentPartner.failureNotifications.forEach(notification => { + const thread = notification.message.originThread; + // Notifications are grouped by model and notification_type. + // Except for channel where they are also grouped by id because + // we want to open the actual channel in discuss or chat window + // and not its kanban/list/form view. + const channelId = thread.model === 'mail.channel' ? thread.id : null; + const id = `${thread.model}/${channelId}/${notification.notification_type}`; + const group = this.env.models['mail.notification_group'].insert({ + id, + notification_type: notification.notification_type, + res_model: thread.model, + res_model_name: thread.model_name, + }); + group.update({ notifications: [['link', notification]] }); + // keep res_id only if all notifications are for the same record + // set null if multiple records are present in the group + let res_id = group.res_id; + if (group.res_id === undefined) { + res_id = thread.id; + } else if (group.res_id !== thread.id) { + res_id = null; + } + // keep only the most recent date from all notification messages + let date = group.date; + if (!date) { + date = notification.message.date; + } else { + date = moment.max(group.date, notification.message.date); + } + group.update({ + date, + res_id, + }); + // avoid linking the same group twice when adding a notification + // to an existing group + if (!groups.includes(group)) { + groups.push(group); + } + }); + this.update({ groups: [['link', groups]] }); + } + + } + + NotificationGroupManager.fields = { + groups: one2many('mail.notification_group'), + }; + + NotificationGroupManager.modelName = 'mail.notification_group_manager'; + + return NotificationGroupManager; +} + +registerNewModel('mail.notification_group_manager', factory); + +}); diff --git a/addons/mail/static/src/models/partner/partner.js b/addons/mail/static/src/models/partner/partner.js new file mode 100644 index 00000000000..3bf38a60b95 --- /dev/null +++ b/addons/mail/static/src/models/partner/partner.js @@ -0,0 +1,273 @@ +odoo.define('mail/static/src/models/partner/partner.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); + +const utils = require('web.utils'); + +function factory(dependencies) { + + let nextPublicId = -1; + + class Partner extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @private + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('country' in data) { + if (!data.country) { + data2.country = [['unlink-all']]; + } else { + data2.country = [['insert', { + id: data.country[0], + name: data.country[1], + }]]; + } + } + if ('display_name' in data) { + data2.display_name = data.display_name; + } + if ('email' in data) { + data2.email = data.email; + } + if ('id' in data) { + data2.id = data.id; + } + if ('im_status' in data) { + data2.im_status = data.im_status; + } + if ('name' in data) { + data2.name = data.name; + } + + // relation + if ('user_id' in data) { + if (!data.user_id) { + data2.user = [['unlink-all']]; + } else { + data2.user = [ + ['insert', { + id: data.user_id[0], + partnerDisplayName: data.user_id[1], + }], + ]; + } + } + + return data2; + } + + static getNextPublicId() { + const id = nextPublicId; + nextPublicId -= 1; + return id; + } + + /** + * Search for partners matching `keyword`. + * + * @static + * @param {Object} param0 + * @param {function} param0.callback + * @param {string} param0.keyword + * @param {integer} [param0.limit=10] + */ + static async imSearch({ callback, keyword, limit = 10 }) { + // prefetched partners + let partners = []; + const searchRegexp = new RegExp( + _.str.escapeRegExp(utils.unaccent(keyword)), + 'i' + ); + const currentPartner = this.env.messaging.currentPartner; + for (const partner of this.all(partner => partner.active)) { + if (partners.length < limit) { + if ( + partner !== currentPartner && + searchRegexp.test(partner.name) + ) { + partners.push(partner); + } + } + } + if (!partners.length) { + const partnersData = await this.env.services.rpc( + { + model: 'res.partner', + method: 'im_search', + args: [keyword, limit] + }, + { shadow: true } + ); + for (const data of partnersData) { + const partner = this.insert(data); + partners.push(partner); + } + } + callback(partners); + } + + /** + * @static + */ + static async startLoopFetchImStatus() { + await this._fetchImStatus(); + this._loopFetchImStatus(); + } + + async checkIsUser() { + const userIds = await this.async(() => this.env.services.rpc({ + model: 'res.users', + method: 'search', + args: [[['partner_id', '=', this.id]]], + })); + if (userIds.length) { + this.update({ user: [['insert', { id: userIds[0] }]] }); + } + } + + /** + * Opens an existing or new chat. + */ + openChat() { + const chat = this.correspondentThreads.find(thread => thread.channel_type === 'chat'); + if (chat) { + chat.open(); + } else { + this.env.models['mail.thread'].createChannel({ + autoselect: true, + partnerId: this.id, + type: 'chat', + }); + } + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @static + * @private + */ + static async _fetchImStatus() { + let toFetchPartnersLocalIds = []; + let partnerIdToLocalId = {}; + const toFetchPartners = this.all(partner => partner.im_status !== null); + for (const partner of toFetchPartners) { + toFetchPartnersLocalIds.push(partner.localId); + partnerIdToLocalId[partner.id] = partner.localId; + } + if (!toFetchPartnersLocalIds.length) { + return; + } + const dataList = await this.env.services.rpc({ + route: '/longpolling/im_status', + params: { + partner_ids: toFetchPartnersLocalIds.map(partnerLocalId => + this.get(partnerLocalId).id + ), + }, + }, { shadow: true }); + for (const { id, im_status } of dataList) { + this.insert({ id, im_status }); + delete partnerIdToLocalId[id]; + } + // partners with no im_status => set null + for (const noImStatusPartnerLocalId of Object.values(partnerIdToLocalId)) { + const partner = this.get(noImStatusPartnerLocalId); + if (partner) { + partner.update({ im_status: null }); + } + } + } + + /** + * @static + * @private + */ + static _loopFetchImStatus() { + setTimeout(async () => { + await this._fetchImStatus(); + this._loopFetchImStatus(); + }, 50 * 1000); + } + + /** + * @private + * @returns {string} + */ + _computeNameOrDisplayName() { + return this.name || this.display_name; + } + + /** + * @override + */ + _createRecordLocalId(data) { + const Partner = this.env.models['mail.partner']; + return `${Partner.modelName}_${data.id}`; + } + + } + + Partner.fields = { + active: attr({ + default: true, + }), + correspondentThreads: one2many('mail.thread', { + inverse: 'correspondent', + }), + country: many2one('mail.country'), + display_name: attr({ + default: "", + }), + email: attr(), + failureNotifications: one2many('mail.notification', { + related: 'messagesAsAuthor.failureNotifications', + }), + id: attr(), + im_status: attr(), + memberThreads: many2many('mail.thread', { + inverse: 'members', + }), + messagesAsAuthor: one2many('mail.message', { + inverse: 'author', + }), + model: attr({ + default: 'res.partner', + }), + moderatedChannelIds: attr({ + default: [], + }), + name: attr(), + nameOrDisplayName: attr({ + compute: '_computeNameOrDisplayName', + dependencies: [ + 'display_name', + 'name', + ], + }), + user: one2one('mail.user', { + inverse: 'partner', + }), + }; + + Partner.modelName = 'mail.partner'; + + return Partner; +} + +registerNewModel('mail.partner', factory); + +}); diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js new file mode 100644 index 00000000000..16f52ab861f --- /dev/null +++ b/addons/mail/static/src/models/thread/thread.js @@ -0,0 +1,1370 @@ +odoo.define('mail/static/src/models/thread/thread.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2many, one2one } = require('mail/static/src/model/model_field.js'); +const throttle = require('mail/static/src/utils/throttle/throttle.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); + +function factory(dependencies) { + + class Thread extends dependencies['mail.model'] { + + /** + * @override + */ + init(...args) { + super.init(...args); + /** + * Timer of current partner that was currently typing something, but + * there is no change on the input for 5 seconds. This is used + * in order to automatically notify other members that current + * partner has stopped typing something, due to making no changes + * on the composer for some time. + */ + this._currentPartnerInactiveTypingTimer = new Timer( + this.env, + () => this.async(() => this._onCurrentPartnerInactiveTypingTimeout()), + 5 * 1000 + ); + /** + * Last 'is_typing' status of current partner that has been notified + * to other members. Useful to prevent spamming typing notifications + * to other members if it hasn't changed. An exception is the + * current partner long typing scenario where current partner has + * to re-send the same typing notification from time to time, so + * that other members do not assume he/she is no longer typing + * something from not receiving any typing notifications for a + * very long time. + * + * Supported values: true/false/undefined. + * undefined makes only sense initially and during current partner + * long typing timeout flow. + */ + this._currentPartnerLastNotifiedIsTyping = undefined; + /** + * Timer of current partner that is typing a very long text. When + * the other members do not receive any typing notification for a + * long time, they must assume that the related partner is no longer + * typing something (e.g. they have closed the browser tab). + * This is a timer to let other members know that current partner + * is still typing something, so that they should not assume he/she + * has stopped typing something. + */ + this._currentPartnerLongTypingTimer = new Timer( + this.env, + () => this.async(() => this._onCurrentPartnerLongTypingTimeout()), + 50 * 1000 + ); + /** + * Determines whether the next request to notify current partner + * typing status should always result to making RPC, regardless of + * whether last notified current partner typing status is the same. + * Most of the time we do not want to notify if value hasn't + * changed, exception being the long typing scenario of current + * partner. + */ + this._forceNotifyNextCurrentPartnerTypingStatus = false; + /** + * Registry of timers of partners currently typing in the thread, + * excluding current partner. This is useful in order to + * automatically unregister typing members when not receive any + * typing notification after a long time. Timers are internally + * indexed by partner records as key. The current partner is + * ignored in this registry of timers. + * + * @see registerOtherMemberTypingMember + * @see unregisterOtherMemberTypingMember + */ + this._otherMembersLongTypingTimers = new Map(); + + /** + * Clearable and cancellable throttled version of the + * `_notifyCurrentPartnerTypingStatus` method. + * This is useful when the current partner posts a message and + * types something else afterwards: it must notify immediately that + * he/she is typing something, instead of waiting for the throttle + * internal timer. + * + * @see _notifyCurrentPartnerTypingStatus + */ + this._throttleNotifyCurrentPartnerTypingStatus = throttle( + this.env, + ({ isTyping }) => this.async(() => this._notifyCurrentPartnerTypingStatus({ isTyping })), + 2.5 * 1000 + ); + } + + /** + * @override + */ + delete(...args) { + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + for (const timer of this._otherMembersLongTypingTimers.values()) { + timer.clear(); + } + super.delete(...args); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {mail.thread} [thread] the concerned thread + */ + static computeLastCurrentPartnerMessageSeenByEveryone(thread = undefined) { + const threads = thread ? [thread] : this.env.models['mail.thread'].all(); + threads.map(localThread => { + localThread.update({ + lastCurrentPartnerMessageSeenByEveryone: localThread._computeLastCurrentPartnerMessageSeenByEveryone(), + }); + }); + } + + /** + * @static + * @param {Object} data + * @return {Object} + */ + static convertData(data) { + const data2 = {}; + if ('channel_type' in data) { + data2.channel_type = data.channel_type; + data2.model = 'mail.channel'; + } + if ('create_uid' in data) { + data2.creator = [['insert', { id: data.create_uid }]]; + } + if ('custom_channel_name' in data) { + data2.custom_channel_name = data.custom_channel_name; + } + if ('group_based_subscription' in data) { + data2.group_based_subscription = data.group_based_subscription; + } + if ('id' in data) { + data2.id = data.id; + } + if ('is_minimized' in data && 'state' in data) { + data2.serverFoldState = data.is_minimized ? data.state : 'closed'; + } + if ('is_moderator' in data) { + data2.is_moderator = data.is_moderator; + } + if ('mass_mailing' in data) { + data2.mass_mailing = data.mass_mailing; + } + if ('moderation' in data) { + data2.moderation = data.moderation; + } + if ('message_needaction_counter' in data) { + data2.message_needaction_counter = data.message_needaction_counter; + } + if ('message_unread_counter' in data) { + data2.message_unread_counter = data.message_unread_counter; + } + if ('name' in data) { + data2.name = data.name; + } + if ('public' in data) { + data2.public = data.public; + } + if ('seen_message_id' in data) { + data2.seen_message_id = data.seen_message_id; + } + if ('uuid' in data) { + data2.uuid = data.uuid; + } + + // relation + if ('direct_partner' in data) { + if (!data.direct_partner) { + data2.correspondent = [['unlink-all']]; + } else { + data2.correspondent = [ + ['insert', this.env.models['mail.partner'].convertData(data.direct_partner[0])], + ]; + } + } + if ('members' in data) { + if (!data.members) { + data2.members = [['unlink-all']]; + } else { + data2.members = [ + ['insert-and-replace', data.members.map(memberData => + this.env.models['mail.partner'].convertData(memberData) + )], + ]; + } + } + if ('seen_partners_info' in data) { + if (!data.seen_partners_info) { + data2.partnerSeenInfos = [['unlink-all']]; + } else { + data2.partnerSeenInfos = [ + ['insert-and-replace', + data.seen_partners_info.map( + ({ fetched_message_id, id, partner_id, seen_message_id}) => { + return { + id, + lastFetchedMessage: [seen_message_id ? ['insert', {id: seen_message_id}] : ['unlink-all']], + lastSeenMessage: [fetched_message_id ? ['insert', {id: fetched_message_id}] : ['unlink-all']], + partner: [['insert', {id: partner_id}]], + }; + }) + ] + ]; + if (data.id || this.id) { + const messageIds = data.seen_partners_info.reduce((currentSet, { fetched_message_id, seen_message_id}) => { + if (fetched_message_id) { + currentSet.add(fetched_message_id); + } + if (seen_message_id) { + currentSet.add(seen_message_id); + } + return currentSet; + }, new Set()); + if (messageIds.size > 0) { + data2.messageSeenIndicators = [ + ['insert', + [...messageIds].map(messageId => { + return { + id: this.env.models['mail.message_seen_indicator'].computeId(messageId, data.id || this.id), + message: [['insert', {id: messageId}]], + }; + }) + ] + ]; + } + } + } + } + + return data2; + } + + /** + * Create a channel, which is a special kind of thread on model + * 'mail.channel' with multiple members. + * + * @static + * @param {Object} param0 + * @param {boolean} [param0.autoselect=false] if set, when channel + * has been created, it auto-open the channel. This opens in discuss + * or chat window, depending on whether discuss is open or not. + * @param {string} [param0.autoselectChatWindowMode] + * @param {string} param0.name + * @param {integer} [param0.partnerId] + * @param {string} [param0.public] + * @param {string} param0.type + */ + static async createChannel({ + autoselect = false, + autoselectChatWindowMode, + name, + partnerId, + public: publicStatus, + type, + }) { + const device = this.env.messaging.device; + // TODO FIX: potential duplicate chat task-2276490 + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: type === 'chat' ? 'channel_get' : 'channel_create', + args: type === 'chat' ? [[partnerId]] : [name, publicStatus], + kwargs: { + context: Object.assign({}, this.env.session.user_content, { + isMobile: device.isMobile, + }), + } + }); + const thread = this.insert(Object.assign( + { isPinned: true }, + this.convertData(data) + )); + if (autoselect) { + thread.open({ chatWindowMode: autoselectChatWindowMode }); + } + } + + /** + * Join a channel. This channel may not yet exists in the store. + * + * @static + * @param {integer} channelId + * @param {Object} [param1={}] + * @param {boolean} [param1.autoselect=false] + */ + static async joinChannel(channelId, { autoselect = false } = {}) { + const channel = this.find(thread => + thread.id === channelId && + thread.model === 'mail.channel' + ); + if (channel && channel.isPinned) { + return; + } + const data = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_join_and_get_info', + args: [[channelId]] + }); + const thread = this.insert(Object.assign( + { isPinned: true }, + this.convertData(data) + )); + if (autoselect) { + thread.open({ resetDiscussDomain: true }); + } + } + + /** + * Load the previews of the specified threads. Basically, it fetches the + * last messages, since they are used to display inline content of them. + * + * @static + * @param {mail.thread[]} threads + */ + static async loadPreviews(threads) { + const channelIds = threads.reduce((list, thread) => { + if (thread.model === 'mail.channel') { + return list.concat(thread.id); + } + return list; + }, []); + const messagePreviews = await this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetch_preview', + args: [channelIds], + }, { shadow: true }); + for (const preview of messagePreviews) { + const messageData = preview.last_message; + if (messageData) { + this.env.models['mail.message'].insert( + this.env.models['mail.message'].convertData(messageData) + ); + } + } + } + + /** + * @static + */ + static openNewMessage() { + const discuss = this.env.messaging.discuss; + if (discuss.isOpen) { + discuss.openNewMessage(); + } else { + this.env.messaging.chatWindowManager.openNewMessage(); + } + } + + /** + * @param {string} [stringifiedDomain='[]'] + * @returns {mail.thread_cache} + */ + cache(stringifiedDomain = '[]') { + let cache = this.caches.find(cache => cache.stringifiedDomain === stringifiedDomain); + if (!cache) { + cache = this.env.models['mail.thread_cache'].create({ + stringifiedDomain, + thread: [['link', this]], + }); + } + return cache; + } + + /** + * Fetch attachments linked to a record. Useful for populating the store + * with these attachments, which are used by attachment box in the chatter. + */ + async fetchAttachments() { + const attachmentsData = await this.async(() => this.env.services.rpc({ + model: 'ir.attachment', + method: 'search_read', + domain: [ + ['res_id', '=', this.id], + ['res_model', '=', this.model], + ], + fields: ['id', 'name', 'mimetype'], + orderBy: [{ name: 'id', asc: false }], + })); + for (const attachmentData of attachmentsData) { + this.env.models['mail.attachment'].insert(Object.assign({ + originThread: [['link', this]], + }, this.env.models['mail.attachment'].convertData(attachmentData))); + } + this.update({ areAttachmentsLoaded: true }); + } + + /** + * Add current user to provided thread's followers. + */ + async follow() { + await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'message_subscribe', + args: [[this.id]], + kwargs: { + partner_ids: [this.env.messaging.currentPartner.id], + context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187 + }, + })); + this.refreshFollowers(); + } + + /** + * Load new messages on the main cache of this thread. + */ + loadNewMessages() { + this.mainCache.loadNewMessages(); + } + + /** + * Mark the specified conversation as fetched. + */ + async markAsFetched() { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fetched', + args: [[this.id]], + }, { shadow: true })); + } + + /** + * Mark the specified conversation as read/seen. + */ + async markAsSeen() { + if (this.message_unread_counter === 0) { + return; + } + if (this.model === 'mail.channel') { + const seen_message_id = await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [[this.id]] + }, { shadow: true })); + this.update({ seen_message_id }); + } + this.update({ message_unread_counter: 0 }); + } + + /** + * Mark all needaction messages of this thread as read. + */ + async markNeedactionMessagesAsRead() { + await this.async(() => + this.env.models['mail.message'].markAsRead(this.needactionMessages) + ); + } + + /** + * Notify server the fold state of this thread. Useful for cross-tab + * and cross-device chat window state synchronization. + * + * Only makes sense if pendingFoldState is set to the desired value. + */ + async notifyFoldStateToServer() { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: { + uuid: this.uuid, + state: this.pendingFoldState, + } + }, { shadow: true })); + } + + /** + * Open provided thread, either in discuss app or as a chat window. + * + * @param {Object} param0 + * @param {string} [param0.chatWindowMode='last_visible'] + * @param {boolean} [param0.resetDiscussDomain=false] + */ + open({ chatWindowMode = 'last_visible', resetDiscussDomain = false } = {}) { + const device = this.env.messaging.device; + const discuss = this.env.messaging.discuss; + const messagingMenu = this.env.messaging.messagingMenu; + if (!['mail.box', 'mail.channel'].includes(this.model)) { + this.env.messaging.openDocument({ + id: this.id, + model: this.model, + }); + } + if ( + (!device.isMobile && discuss.isOpen) || + (device.isMobile && this.model === 'mail.box') + ) { + if (resetDiscussDomain) { + discuss.threadViewer.update({ stringifiedDomain: '[]' }); + } + discuss.threadViewer.update({ thread: [['link', this]] }); + } else { + this.env.messaging.chatWindowManager.openThread(this, { mode: chatWindowMode }); + } + if (!device.isMobile) { + messagingMenu.close(); + } + } + + /** + * Open this thread in an expanded way, that is not in a chat window. + */ + openExpanded() { + const discuss = this.env.messaging.discuss; + if (['mail.channel', 'mail.box'].includes(this.model)) { + this.env.bus.trigger('do-action', { + action: 'mail.action_new_discuss', + options: { + clear_breadcrumbs: false, + active_id: discuss.threadToActiveId(this), + on_reverse_breadcrumb: () => discuss.close(), + }, + }); + } else { + this.env.bus.trigger('do-action', { + action: { + type: 'ir.actions.act_window', + res_model: this.model, + views: [[false, 'form']], + res_id: this.id, + }, + }); + } + } + + /** + * Open a dialog to add channels as followers. + */ + promptAddChannelFollower() { + this._promptAddFollower({ mail_invite_follower_channel_only: true }); + } + + /** + * Open a dialog to add partners as followers. + */ + promptAddPartnerFollower() { + this._promptAddFollower({ mail_invite_follower_channel_only: false }); + } + + /** + * Refresh followers information from server. + */ + async refreshFollowers() { + // FIXME Do that with only one RPC (see task-2243180) + const [{ message_follower_ids: followerIds }] = await this.async(() => this.env.services.rpc({ + model: this.model, + method: 'read', + args: [this.id, ['message_follower_ids']], + })); + if (followerIds && followerIds.length > 0) { + const { followers } = await this.async(() => this.env.services.rpc({ + route: '/mail/read_followers', + params: { + follower_ids: followerIds, + context: {}, // FIXME empty context to be overridden in session.js with 'allowed_company_ids' task-2243187 + } + })); + this.update({ + followers: [['insert-and-replace', followers.map(data => + this.env.models['mail.follower'].convertData(data)) + ]], + }); + } else { + this.update({ + followers: [['unlink-all']], + }); + } + } + + /** + * Refresh the typing status of the current partner. + */ + refreshCurrentPartnerIsTyping() { + this._currentPartnerInactiveTypingTimer.reset(); + } + + /** + * Called to refresh a registered other member partner that is typing + * something. + * + * @param {mail.partner} partner + */ + refreshOtherMemberTypingMember(partner) { + this._otherMembersLongTypingTimers.get(partner).reset(); + } + + /** + * Called when current partner is inserting some input in composer. + * Useful to notify current partner is currently typing something in the + * composer of this thread to all other members. + */ + async registerCurrentPartnerIsTyping() { + // Handling of typing timers. + this._currentPartnerInactiveTypingTimer.start(); + this._currentPartnerLongTypingTimer.start(); + // Manage typing member relation. + const currentPartner = this.env.messaging.currentPartner; + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== currentPartner.localId); + newOrderedTypingMemberLocalIds.push(currentPartner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['link', currentPartner]], + }); + // Notify typing status to other members. + await this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }); + } + + /** + * Called to register a new other member partner that is typing + * something. + * + * @param {mail.partner} partner + */ + registerOtherMemberTypingMember(partner) { + const timer = new Timer( + this.env, + () => this.async(() => this._onOtherMemberLongTypingTimeout(partner)), + 60 * 1000 + ); + this._otherMembersLongTypingTimers.set(partner, timer); + timer.start(); + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== partner.localId); + newOrderedTypingMemberLocalIds.push(partner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['link', partner]], + }); + } + + /** + * Rename the given thread with provided new name. + * + * @param {string} newName + */ + async rename(newName) { + if (this.channel_type === 'chat') { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_set_custom_name', + args: [this.id], + kwargs: { + name: newName, + }, + })); + } + this.update({ custom_channel_name: newName }); + } + + /** + * Unfollow current partner from this thread. + */ + async unfollow() { + const currentPartnerFollower = this.followers.find( + follower => follower.partner === this.env.messaging.currentPartner + ); + await this.async(() => currentPartnerFollower.remove()); + } + + /** + * Called when current partner has explicitly stopped inserting some + * input in composer. Useful to notify current partner has currently + * stopped typing something in the composer of this thread to all other + * members. + * + * @param {Object} [param0={}] + * @param {boolean} [param0.immediateNotify=false] if set, is typing + * status of current partner is immediately notified and doesn't + * consume throttling at all. + */ + async unregisterCurrentPartnerIsTyping({ immediateNotify = false } = {}) { + // Handling of typing timers. + this._currentPartnerInactiveTypingTimer.clear(); + this._currentPartnerLongTypingTimer.clear(); + // Manage typing member relation. + const currentPartner = this.env.messaging.currentPartner; + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== currentPartner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['unlink', currentPartner]], + }); + // Notify typing status to other members. + if (immediateNotify) { + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + } + await this.async( + () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: false }) + ); + } + + /** + * Called to unregister an other member partner that is no longer typing + * something. + * + * @param {mail.partner} partner + */ + unregisterOtherMemberTypingMember(partner) { + this._otherMembersLongTypingTimers.get(partner).clear(); + this._otherMembersLongTypingTimers.delete(partner); + const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds + .filter(localId => localId !== partner.localId); + this.update({ + orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + typingMembers: [['unlink', partner]], + }); + } + + /** + * Unsubscribe current user from provided channel. + */ + async unsubscribe() { + if (this.channel_type === 'mail.channel') { + return this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'action_unfollow', + args: [[this.id]] + })); + } + return this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.uuid, false] + })); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + static _findFunctionFromData(data) { + return record => record.id === data.id && record.model === data.model; + } + + /** + * @private + * @returns {mail.attachment[]} + */ + _computeAllAttachments() { + const allAttachments = [...new Set(this.originThreadAttachments.concat(this.attachments))] + .sort((a1, a2) => a1.id < a2.id ? 1 : -1); + return [['replace', allAttachments]]; + } + + /** + * @private + * @returns {mail.chat_window[]} + */ + _computeChatWindows() { + const chatWindowViewers = this.viewers.filter(viewer => !!viewer.chatWindow); + return [['replace', chatWindowViewers.map(viewer => viewer.chatWindow)]]; + } + + /** + * @private + * @returns {string} + */ + _computeDisplayName() { + if (this.channel_type === 'chat' && this.correspondent) { + return this.custom_channel_name || this.correspondent.nameOrDisplayName; + } + return this.name; + } + + /** + * @private + * @returns {string} + */ + _computeFoldState() { + return this.pendingFoldState || this.serverFoldState; + } + + /** + * @private + * @returns {boolean} + */ + _computeIsCurrentPartnerFollowing() { + return this.followers.some(follower => + follower.partner && follower.partner === this.env.messaging.currentPartner + ); + } + + /** + * @private + * @returns {boolean} + */ + _computeIsModeratedByCurrentPartner() { + if (this.model !== 'mail.channel') { + return false; + } + if (!this.messaging) { + return false; + } + if (!this.messaging.currentPartner) { + return false; + } + return this.messaging.currentPartner.moderatedChannelIds.includes(this.id); + } + + /** + * @private + * @returns {mail.message} + */ + _computeLastCurrentPartnerMessageSeenByEveryone() { + if (!this.partnerSeenInfos || !this.mainCacheOrderedMessages) { + return [['unlink-all']]; + } + const otherPartnerSeenInfos = + this.partnerSeenInfos.filter(partnerSeenInfo => + partnerSeenInfo.partner !== this.messagingCurrentPartner); + if (otherPartnerSeenInfos.length === 0) { + return [['unlink-all']]; + } + + const otherPartnersLastSeenMessageIds = + otherPartnerSeenInfos.map(partnerSeenInfo => + partnerSeenInfo.lastSeenMessage ? partnerSeenInfo.lastSeenMessage.id : 0 + ); + if(otherPartnersLastSeenMessageIds.length === 0) { + return [['unlink-all']]; + } + const lastMessageSeenByAllId = Math.min( + ...otherPartnersLastSeenMessageIds + ); + const currentPartnerOrderedSeenMessages = + this.mainCacheOrderedMessages.filter(message => + message.author === this.messagingCurrentPartner && + message.id <= lastMessageSeenByAllId); + + if ( + !currentPartnerOrderedSeenMessages || + currentPartnerOrderedSeenMessages.length === 0 + ) { + return [['unlink-all']]; + } + return [['replace', currentPartnerOrderedSeenMessages.slice().pop()]]; + } + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastNeedactionMessage() { + const orderedNeedactionMessages = this.needactionMessages.sort( + (m1, m2) => m1.id < m2.id ? -1 : 1 + ); + const { + length: l, + [l - 1]: lastNeedactionMessage, + } = orderedNeedactionMessages; + return [['replace', lastNeedactionMessage]]; + } + + /** + * @private + * @returns {mail.thread_cache} + */ + _computeMainCache() { + return [['replace', this.cache('[]')]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeMessages() { + const allMessages = this.mainCacheMessages.concat(this.originThreadMessages); + return [['replace', allMessages]]; + } + + /** + * @private + * @returns {mail.messaging} + */ + _computeMessaging() { + return [['link', this.env.messaging]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeNeedactionMessages() { + return [['replace', this.messages.filter(message => message.isNeedaction)]]; + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeOrderedOtherTypingMembers() { + return [[ + 'replace', + this.orderedTypingMembers.filter( + member => member !== this.env.messaging.currentPartner + ), + ]]; + } + + /** + * @private + * @returns {mail.partner[]} + */ + _computeOrderedTypingMembers() { + return [[ + 'replace', + this.orderedTypingMemberLocalIds + .map(localId => this.env.models['mail.partner'].get(localId)) + .filter(member => !!member), + ]]; + } + + /** + * @private + * @returns {string} + */ + _computeTypingStatusText() { + if (this.orderedOtherTypingMembers.length === 0) { + return this.constructor.fields.typingStatusText.default; + } + if (this.orderedOtherTypingMembers.length === 1) { + return _.str.sprintf( + this.env._t("%s is typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName + ); + } + if (this.orderedOtherTypingMembers.length === 2) { + return _.str.sprintf( + this.env._t("%s and %s are typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName, + this.orderedOtherTypingMembers[1].nameOrDisplayName + ); + } + return _.str.sprintf( + this.env._t("%s, %s and more are typing..."), + this.orderedOtherTypingMembers[0].nameOrDisplayName, + this.orderedOtherTypingMembers[1].nameOrDisplayName + ); + } + + /** + * @override + */ + _createRecordLocalId(data) { + const { channel_type, id, isTemporary = false, model } = data; + let threadModel = model; + if (!threadModel && channel_type) { + threadModel = 'mail.channel'; + } + const Thread = this.env.models['mail.thread']; + if (isTemporary) { + return `${Thread.modelName}_${id}`; + } + return `${Thread.modelName}_${threadModel}_${id}`; + } + + /** + * @private + * @param {Object} param0 + * @param {boolean} param0.isTyping + */ + async _notifyCurrentPartnerTypingStatus({ isTyping }) { + if ( + this._forceNotifyNextCurrentPartnerTypingStatus || + isTyping !== this._currentPartnerLastNotifiedIsTyping + ) { + if (this.model === 'mail.channel') { + await this.async(() => this.env.services.rpc({ + model: 'mail.channel', + method: 'notify_typing', + args: [this.id], + kwargs: { is_typing: isTyping }, + }, { shadow: true })); + } + if (isTyping && this._currentPartnerLongTypingTimer.isRunning) { + this._currentPartnerLongTypingTimer.reset(); + } + } + this._forceNotifyNextCurrentPartnerTypingStatus = false; + this._currentPartnerLastNotifiedIsTyping = isTyping; + } + + /** + * @private + * @param {Object} [param0={}] + * @param {boolean} [param0.mail_invite_follower_channel_only=false] + */ + _promptAddFollower({ mail_invite_follower_channel_only = false } = {}) { + const action = { + type: 'ir.actions.act_window', + res_model: 'mail.wizard.invite', + view_mode: 'form', + views: [[false, 'form']], + name: this.env._t("Invite Follower"), + target: 'new', + context: { + default_res_model: this.model, + default_res_id: this.id, + mail_invite_follower_channel_only, + }, + }; + this.env.bus.trigger('do-action', { + action, + options: { + on_close: () => this.refreshFollowers(), + }, + }); + } + + /** + * @override + */ + _updateAfter(previous) { + if (this.model !== 'mail.channel') { + // fold state only makes sense on channels + return; + } + if ( + this.pendingFoldState && + previous.pendingFoldState !== this.pendingFoldState + ) { + this.notifyFoldStateToServer(); + } + if ( + this.serverFoldState === this.pendingFoldState + ) { + this.update({ pendingFoldState: undefined }); + } + + // TODO FIXME prevent to open/close a channel on mobile when you + // open/close it on desktop (task-2267593) + + // chat window + if (previous.foldState === this.foldState) { + // avoid updating chatWindows when not changing foldState + // important to avoid issues when thread is in progress of being + // opened, because the foldState is updated only at the end of + // the process + return; + } + if (this.foldState !== 'closed' && this.chatWindows.length === 0) { + // condition to avoid crash during destroy + if (this.env.messaging.chatWindowManager) { + this.env.messaging.chatWindowManager.openThread(this); + } + } + if (this.foldState === 'closed' && this.chatWindows.length > 0) { + for (const chatWindow of this.chatWindows) { + chatWindow.close(); + } + } + } + + /** + * @override + */ + _updateBefore() { + return { + foldState: this.foldState, + pendingFoldState: this.pendingFoldState, + }; + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + */ + async _onCurrentPartnerInactiveTypingTimeout() { + await this.async(() => this.unregisterCurrentPartnerIsTyping()); + } + + /** + * Called when current partner has been typing for a very long time. + * Immediately notify other members that he/she is still typing. + * + * @private + */ + async _onCurrentPartnerLongTypingTimeout() { + this._forceNotifyNextCurrentPartnerTypingStatus = true; + this._throttleNotifyCurrentPartnerTypingStatus.clear(); + await this.async( + () => this._throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }) + ); + } + + /** + * @private + * @param {mail.partner} partner + */ + async _onOtherMemberLongTypingTimeout(partner) { + if (!this.typingMembers.includes(partner)) { + this._otherMembersLongTypingTimers.delete(partner); + return; + } + this.unregisterOtherMemberTypingMember(partner); + } + } + + Thread.fields = { + allAttachments: many2many('mail.attachment', { + compute: '_computeAllAttachments', + dependencies: [ + 'attachments', + 'originThreadAttachments', + ], + }), + areAttachmentsLoaded: attr({ + default: false, + }), + attachments: many2many('mail.attachment', { + inverse: 'threads', + }), + caches: one2many('mail.thread_cache', { + inverse: 'thread', + isCausal: true, + }), + channel_type: attr(), + chatWindows: one2many('mail.chat_window', { + compute: '_computeChatWindows', + dependencies: ['viewersChatWindow'], + }), + composer: one2one('mail.composer', { + autocreate: true, + inverse: 'thread', + isCausal: true, + }), + correspondent: many2one('mail.partner', { + inverse: 'correspondentThreads', + }), + correspondentNameOrDisplayName: attr({ + related: 'correspondent.nameOrDisplayName', + }), + counter: attr({ + default: 0, + }), + creator: many2one('mail.user'), + custom_channel_name: attr(), + displayName: attr({ + compute: '_computeDisplayName', + dependencies: [ + 'channel_type', + 'correspondent', + 'correspondentNameOrDisplayName', + 'custom_channel_name', + 'name', + ], + }), + /** + * Determine the fold state of the channel on the web client. + * + * If there is a pending fold state change, it is immediately applied on + * the interface to avoid a feeling of unresponsiveness. Otherwise the + * last known fold state of the server is used. + * + * This field must be considered read only. + */ + foldState: attr({ + compute: '_computeFoldState', + dependencies: [ + 'pendingFoldState', + 'serverFoldState', + ], + }), + followersPartner: many2many('mail.partner', { + related: 'followers.partner', + }), + followers: one2many('mail.follower', { + inverse: 'followedThread', + }), + group_based_subscription: attr({ + default: false, + }), + id: attr(), + isCurrentPartnerFollowing: attr({ + compute: '_computeIsCurrentPartnerFollowing', + default: false, + dependencies: [ + 'followersPartner', + 'messagingCurrentPartner', + ], + }), + isModeratedByCurrentPartner: attr({ + compute: '_computeIsModeratedByCurrentPartner', + dependencies: [ + 'model', + 'messagingCurrentPartner', + ], + }), + isPinned: attr({ + default: false, + }), + isTemporary: attr({ + default: false, + }), + is_moderator: attr({ + default: false, + }), + lastCurrentPartnerMessageSeenByEveryone: many2one('mail.message', { + compute: '_computeLastCurrentPartnerMessageSeenByEveryone', + dependencies: [ + 'partnerSeenInfos', + 'mainCacheOrderedMessages', + 'messagingCurrentPartner', + ], + }), + lastMessage: many2one('mail.message', { + related: 'mainCache.lastMessage', + }), + lastNeedactionMessage: many2one('mail.message', { + compute: '_computeLastNeedactionMessage', + dependencies: ['needactionMessages'], + }), + mainCache: one2one('mail.thread_cache', { + compute: '_computeMainCache', + dependencies: ['caches'], + }), + mainCacheMessages: many2many('mail.message', { + related: 'mainCache.messages', + }), + mainCacheOrderedMessages: many2many('mail.message', { + related: 'mainCache.orderedMessages', + }), + mass_mailing: attr({ + default: false, + }), + members: many2many('mail.partner', { + inverse: 'memberThreads', + }), + message_needaction_counter: attr({ + default: 0, + }), + message_unread_counter: attr({ + default: 0, + }), + messages: many2many('mail.message', { + compute: '_computeMessages', + dependencies: [ + 'mainCacheMessages', + 'originThreadMessages', + ], + }), + messageSeenIndicators: one2many('mail.message_seen_indicator', { + inverse: 'thread', + isCausal: true, + }), + messaging: many2one('mail.messaging', { + compute: '_computeMessaging', + }), + messagingCurrentPartner: many2one('mail.partner', { + related: 'messaging.currentPartner', + }), + model: attr(), + model_name: attr(), + moderation: attr({ + default: false, + }), + moduleIcon: attr(), + name: attr(), + needactionMessages: many2many('mail.message', { + compute: '_computeNeedactionMessages', + dependencies: ['messages'], + }), + /** + * Ordered typing members on this thread, excluding the current partner. + */ + orderedOtherTypingMembers: many2many('mail.partner', { + compute: '_computeOrderedOtherTypingMembers', + dependencies: ['orderedTypingMembers'], + }), + /** + * Ordered typing members on this thread. Lower index means this member + * is currently typing for the longest time. This list includes current + * partner as typer. + */ + orderedTypingMembers: many2many('mail.partner', { + compute: '_computeOrderedTypingMembers', + dependencies: [ + 'orderedTypingMemberLocalIds', + 'typingMembers', + ], + }), + /** + * Technical attribute to manage ordered list of typing members. + */ + orderedTypingMemberLocalIds: attr({ + default: [], + }), + originThreadAttachments: one2many('mail.attachment', { + inverse: 'originThread', + }), + originThreadMessages: one2many('mail.message', { + inverse: 'originThread', + }), + partnerSeenInfos: one2many('mail.thread_partner_seen_info', { + inverse: 'thread', + isCausal: true, + }), + /** + * Determine if there is a pending fold state change, which is a change + * of fold state requested by the client but not yet confirmed by the + * server. + * + * This field can be updated to immediately change the fold state on the + * interface and to notify the server of the new state. + */ + pendingFoldState: attr(), + public: attr(), + seen_message_id: attr(), + /** + * Determine the last fold state known by the server, which is the fold + * state displayed after initialization or when the last pending + * fold state change was confirmed by the server. + * + * This field should be considered read only in most situations. Only + * the code handling fold state change from the server should typically + * update it. + */ + serverFoldState: attr({ + default: 'closed', + }), + /** + * Members that are currently typing something in the composer of this + * thread, including current partner. + */ + typingMembers: many2many('mail.partner'), + /** + * Text that represents the status on this thread about typing members. + */ + typingStatusText: attr({ + compute: '_computeTypingStatusText', + default: '', + dependencies: ['orderedOtherTypingMembers'], + }), + uuid: attr(), + viewers: one2many('mail.thread_viewer', { + inverse: 'thread', + }), + viewersChatWindow: many2many('mail.chat_window', { + related: 'viewers.chatWindow', + }), + }; + + Thread.modelName = 'mail.thread'; + + return Thread; +} + +registerNewModel('mail.thread', factory); + +}); diff --git a/addons/mail/static/src/models/thread/thread_tests.js b/addons/mail/static/src/models/thread/thread_tests.js new file mode 100644 index 00000000000..02b3966ecde --- /dev/null +++ b/addons/mail/static/src/models/thread/thread_tests.js @@ -0,0 +1,162 @@ +odoo.define('mail/static/src/models/thread/thread_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start: utilsStart, +} = require('mail/static/src/utils/test_utils.js'); + +QUnit.module('mail', {}, function () { +QUnit.module('models', {}, function () { +QUnit.module('thread', {}, function () { +QUnit.module('thread_tests.js', { + beforeEach() { + utilsBeforeEach(this); + + this.start = async params => { + if (this.widget) { + this.widget.destroy(); + } + let { env, widget } = await utilsStart(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + this.env = undefined; + if (this.widget) { + this.widget.destroy(); + this.widget = undefined; + } + }, +}); + +QUnit.test('inbox & starred mailboxes', async function (assert) { + assert.expect(10); + + await this.start(); + const mailboxInbox = this.env.messaging.inbox; + const mailboxStarred = this.env.messaging.starred; + assert.ok(mailboxInbox, "should have mailbox inbox"); + assert.ok(mailboxStarred, "should have mailbox starred"); + assert.strictEqual(mailboxInbox.model, 'mail.box'); + assert.strictEqual(mailboxInbox.counter, 0); + assert.strictEqual(mailboxInbox.id, 'inbox'); + assert.strictEqual(mailboxInbox.name, "Inbox"); // language-dependent + assert.strictEqual(mailboxStarred.model, 'mail.box'); + assert.strictEqual(mailboxStarred.counter, 0); + assert.strictEqual(mailboxStarred.id, 'starred'); + assert.strictEqual(mailboxStarred.name, "Starred"); // language-dependent +}); + +QUnit.test('create (channel)', async function (assert) { + assert.expect(23); + + await this.start(); + assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 9)); + assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 10)); + assert.notOk(this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )); + + const thread = this.env.models['mail.thread'].create({ + channel_type: 'channel', + id: 100, + members: [['insert', [{ + email: "john@example.com", + id: 9, + name: "John", + }, { + email: "fred@example.com", + id: 10, + name: "Fred", + }]]], + message_needaction_counter: 6, + message_unread_counter: 5, + model: 'mail.channel', + name: "General", + public: 'public', + }); + assert.ok(thread); + assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 9)); + assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 10)); + assert.ok(this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )); + const partner9 = this.env.models['mail.partner'].find(partner => partner.id === 9); + const partner10 = this.env.models['mail.partner'].find(partner => partner.id === 10); + assert.strictEqual(thread, this.env.models['mail.thread'].find(thread => + thread.id === 100 && + thread.model === 'mail.channel' + )); + assert.strictEqual(thread.model, 'mail.channel'); + assert.strictEqual(thread.channel_type, 'channel'); + assert.strictEqual(thread.id, 100); + assert.ok(thread.members.includes(partner9)); + assert.ok(thread.members.includes(partner10)); + assert.strictEqual(thread.message_needaction_counter, 6); + assert.strictEqual(thread.message_unread_counter, 5); + assert.strictEqual(thread.name, "General"); + assert.strictEqual(thread.public, 'public'); + assert.strictEqual(partner9.email, "john@example.com"); + assert.strictEqual(partner9.id, 9); + assert.strictEqual(partner9.name, "John"); + assert.strictEqual(partner10.email, "fred@example.com"); + assert.strictEqual(partner10.id, 10); + assert.strictEqual(partner10.name, "Fred"); +}); + +QUnit.test('create (chat)', async function (assert) { + assert.expect(15); + + await this.start(); + assert.notOk(this.env.models['mail.partner'].find(partner => partner.id === 5)); + assert.notOk(this.env.models['mail.thread'].find(thread => + thread.id === 200 && + thread.model === 'mail.channel' + )); + + const channel = this.env.models['mail.thread'].create({ + channel_type: 'chat', + correspondent: [['insert', { + email: "demo@example.com", + id: 5, + im_status: 'online', + name: "Demo", + }]], + id: 200, + model: 'mail.channel', + }); + assert.ok(channel); + assert.ok(this.env.models['mail.thread'].find(thread => + thread.id === 200 && + thread.model === 'mail.channel' + )); + assert.ok(this.env.models['mail.partner'].find(partner => partner.id === 5)); + const partner = this.env.models['mail.partner'].find(partner => partner.id === 5); + assert.strictEqual(channel, this.env.models['mail.thread'].find(thread => + thread.id === 200 && + thread.model === 'mail.channel' + )); + assert.strictEqual(channel.model, 'mail.channel'); + assert.strictEqual(channel.channel_type, 'chat'); + assert.strictEqual(channel.id, 200); + assert.ok(channel.correspondent); + assert.strictEqual(partner, channel.correspondent); + assert.strictEqual(partner.email, "demo@example.com"); + assert.strictEqual(partner.id, 5); + assert.strictEqual(partner.im_status, 'online'); + assert.strictEqual(partner.name, "Demo"); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js new file mode 100644 index 00000000000..edd991c693c --- /dev/null +++ b/addons/mail/static/src/models/thread_cache/thread_cache.js @@ -0,0 +1,263 @@ +odoo.define('mail/static/src/models/thread_cache/thread_cache.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one } = require('mail/static/src/model/model_field.js'); + +const MESSAGE_FETCH_LIMIT = 30; + +function factory(dependencies) { + + class ThreadCache extends dependencies['mail.model'] { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Load this thread cache, by fetching the most recent messages in this + * conversation. + */ + async loadMessages() { + if (this.isLoaded && this.isLoading) { + return; + } + const searchDomain = JSON.parse(this.stringifiedDomain); + let domain = searchDomain.length ? searchDomain : []; + domain = this._extendMessageDomain(domain); + this.update({ isLoading: true }); + let messagesData = []; + if (!this.thread.isTemporary) { + messagesData = await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'message_fetch', + args: [domain], + kwargs: this._getFetchMessagesKwargs(), + }, { shadow: true })); + } + this._handleMessagesLoaded(messagesData); + } + + async loadMoreMessages() { + const searchDomain = JSON.parse(this.stringifiedDomain); + let domain = searchDomain.length ? searchDomain : []; + domain = this._extendMessageDomain(domain); + if (this.isAllHistoryLoaded && this.isLoadingMore) { + return; + } + this.update({ isLoadingMore: true }); + const minMessageId = Math.min( + ...this.messages.map(message => message.id) + ); + domain = [['id', '<', minMessageId]].concat(domain); + const messagesData = await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'message_fetch', + args: [domain], + kwargs: this._getFetchMessagesKwargs(), + }, { shadow: true })); + for (const viewer of this.thread.viewers) { + viewer.addComponentHint('more-messages-loaded'); + } + this._handleMessagesLoaded(messagesData); + } + + async loadNewMessages() { + if (this.isLoading) { + return; + } + if (!this.isLoaded) { + await this.async(() => this.loadMessages()); + return; + } + const messageIds = this.messages.map(message => message.id); + const searchDomain = JSON.parse(this.stringifiedDomain); + let domain = searchDomain.length ? searchDomain : []; + domain = this._extendMessageDomain(domain); + if (messageIds.length > 0) { + const lastMessageId = Math.max(...messageIds); + domain = [['id', '>', lastMessageId]].concat(domain); + } + this.update({ isLoading: true }); + const messageFetchKwargs = this._getFetchMessagesKwargs(); + messageFetchKwargs.limit = false; + const messagesData = await this.async(() => this.env.services.rpc({ + model: 'mail.message', + method: 'message_fetch', + args: [domain], + kwargs: messageFetchKwargs, + }, { shadow: true })); + this._handleMessagesLoaded(messagesData); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.message|undefined} + */ + _computeLastMessage() { + const { length: l, [l - 1]: lastMessage } = this.orderedMessages; + if (!lastMessage) { + return [['unlink-all']]; + } + return [['replace', lastMessage]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeOrderedMessages() { + return [['replace', this.messages.sort((m1, m2) => m1.id < m2.id ? -1 : 1)]]; + } + + /** + * @private + * @returns {mail.message[]} + */ + _computeUncheckedMessages() { + return [['replace', this.messages.filter( + message => message.hasCheckbox && !this.checkedMessages.includes(message) + )]]; + } + + /** + * @override + */ + _createRecordLocalId(data) { + const { + stringifiedDomain = '[]', + thread: [[commandInsert, thread]], + } = data; + const ThreadCache = this.env.models['mail.thread_cache']; + return `${ThreadCache.modelName}_[${thread.localId}]_<${stringifiedDomain}>`; + } + + /** + * @private + * @param {Array} domain + * @returns {Array} + */ + _extendMessageDomain(domain) { + const thread = this.thread; + if (thread.model === 'mail.channel') { + return domain.concat([['channel_ids', 'in', [thread.id]]]); + } else if (thread === this.env.messaging.inbox) { + return domain.concat([['needaction', '=', true]]); + } else if (thread === this.env.messaging.starred) { + return domain.concat([['starred', '=', true]]); + } else if (thread === this.env.messaging.history) { + return domain.concat([['needaction', '=', false]]); + } else if (thread === this.env.messaging.moderation) { + return domain.concat([['need_moderation', '=', true]]); + } else { + return domain.concat([['model', '=', thread.model], ['res_id', '=', thread.id]]); + } + } + + /** + * @private + * @returns {Object} + */ + _getFetchMessagesKwargs() { + const thread = this.thread; + let kwargs = { + limit: MESSAGE_FETCH_LIMIT, + context: this.env.session.user_context, + }; + if (thread.moderation) { + // thread is a channel + kwargs.moderated_channel_ids = [thread.id]; + } + return kwargs; + } + + /** + * @private + * @param {Object[]} messageData + */ + _handleMessagesLoaded(messagesData) { + this.update({ + isAllHistoryLoaded: messagesData.length < MESSAGE_FETCH_LIMIT, + isLoaded: true, + isLoading: false, + isLoadingMore: false, + messages: [['insert', messagesData.map(data => + this.env.models['mail.message'].convertData(data)) + ]], + }); + if (!this.thread) { + return; + } + this.thread.update({ + messageSeenIndicators: [['insert', messagesData.map(messageData => { + return { + id: this.env.models['mail.message_seen_indicator'].computeId(messageData.id, this.thread.id), + message: [['insert', { id: messageData.id }]], + }; + })]], + }); + for (const viewer of this.thread.viewers) { + viewer.handleThreadCacheLoaded(this); + } + } + + } + + ThreadCache.fields = { + checkedMessages: many2many('mail.message', { + inverse: 'checkedThreadCaches', + }), + isAllHistoryLoaded: attr({ + default: false, + }), + isLoaded: attr({ + default: false, + }), + isLoading: attr({ + default: false, + }), + isLoadingMore: attr({ + default: false, + }), + lastMessage: many2one('mail.message', { + compute: '_computeLastMessage', + dependencies: ['orderedMessages'], + }), + messagesCheckboxes: attr({ + related: 'messages.hasCheckbox', + }), + messages: many2many('mail.message', { + inverse: 'threadCaches', + }), + orderedMessages: many2many('mail.message', { + compute: '_computeOrderedMessages', + dependencies: ['messages'], + }), + stringifiedDomain: attr({ + default: '[]', + }), + thread: many2one('mail.thread', { + inverse: 'caches', + }), + uncheckedMessages: many2many('mail.message', { + compute: '_computeUncheckedMessages', + dependencies: [ + 'checkedMessages', + 'messagesCheckboxes', + 'messages', + ], + }), + }; + + ThreadCache.modelName = 'mail.thread_cache'; + + return ThreadCache; +} + +registerNewModel('mail.thread_cache', factory); + +}); diff --git a/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js new file mode 100644 index 00000000000..6ea0148d6d0 --- /dev/null +++ b/addons/mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js @@ -0,0 +1,28 @@ +odoo.define('mail/static/src/models/thread_partner_seen_info/thread_partner_seen_info.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ThreadPartnerSeenInfo extends dependencies['mail.model'] {} + + ThreadPartnerSeenInfo.modelName = 'mail.thread_partner_seen_info'; + + ThreadPartnerSeenInfo.fields = { + id: attr(), + lastFetchedMessage: many2one('mail.message'), + lastSeenMessage: many2one('mail.message'), + partner: many2one('mail.partner'), + thread: many2one('mail.thread', { + inverse: 'partnerSeenInfos', + }), + }; + + return ThreadPartnerSeenInfo; +} + +registerNewModel('mail.thread_partner_seen_info', factory); + +}); diff --git a/addons/mail/static/src/models/thread_viewer/thread_viewer.js b/addons/mail/static/src/models/thread_viewer/thread_viewer.js new file mode 100644 index 00000000000..4da88fd9513 --- /dev/null +++ b/addons/mail/static/src/models/thread_viewer/thread_viewer.js @@ -0,0 +1,270 @@ +odoo.define('mail/static/src/models/thread_viewer/thread_viewer.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, many2many, many2one, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class ThreadViewer extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + this._stopLoading(); + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * This function register a hint for the component related to this + * record. Hints are information on changes around this viewer that + * make require adjustment on the component. For instance, if this + * thread viewer initiated a thread cache load and it now has become + * loaded, then it may need to auto-scroll to last message. + * + * @param {string} hintType name of the hint. Used to determine what's + * the broad type of adjustement the component has to do. + * @param {any} [hintData] data of the hint. Used to fine-tune + * adjustments on the component. + */ + addComponentHint(hintType, hintData) { + const hint = this._makeComponentHint(hintType, hintData); + this.update({ + componentHintList: this.componentHintList.concat([hint]), + }); + } + + /** + * @param {mail.thread_cache} threadCache + */ + handleThreadCacheLoaded(threadCache) { + if (threadCache !== this.threadCache) { + return; + } + this._stopLoading(); + } + + /** + * @param {Object} hint + */ + markComponentHintProcessed(hint) { + let filterFun; + switch (hint.type) { + case 'current-partner-just-posted-message': + filterFun = h => h.type !== hint.type && h.messageId !== hint.messageId; + break; + default: + filterFun = h => h.type !== hint.type; + break; + } + this.update({ + componentHintList: this.componentHintList.filter(filterFun), + }); + } + + /** + * @param {string} scrollTop + */ + saveThreadCacheScrollPositionsAsInitial(scrollTop) { + if (!this.threadCache) { + return; + } + this.update({ + threadCacheInitialScrollPositions: Object.assign({}, this.threadCacheInitialScrollPositions, { + [this.threadCache.localId]: scrollTop, + }), + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @returns {mail.thread_cache|undefined} + */ + _computeThreadCache() { + if (!this.thread) { + return []; + } + return [['replace', this.thread.cache(this.stringifiedDomain)]]; + } + + /** + * @private + * @returns {integer|undefined} + */ + _computeThreadCacheInitialPosition() { + if (!this.threadCache) { + return undefined; + } + return this.threadCacheInitialScrollPositions[this.threadCache.localId]; + } + + /** + * @private + */ + _prepareLoading() { + this._isPreparingLoading = true; + this._loaderTimeout = setTimeout(() => { + this.isShowingLoading = true; + this._isPreparingLoading = false; + }, 400); + } + + /** + * @private + */ + _stopLoading() { + clearTimeout(this._loaderTimeout); + this._loaderTimeout = null; + this.isShowingLoading = false; + this._isPreparingLoading = false; + } + + /** + * @private + * @param {string} hintType + * @param {any} hintData + * @returns {Object} + */ + _makeComponentHint(hintType, hintData) { + let hint; + switch (hintType) { + case 'change-of-thread-cache': + hint = { type: hintType }; + break; + case 'current-partner-just-posted-message': + hint = { + messageId: hintData, + type: hintType, + }; + break; + case 'more-messages-loaded': + hint = { type: hintType }; + break; + default: + throw new Error(`Undefined component hint "${hintType}" for ThreadViewer`); + } + return hint; + } + + /** + * @override + */ + _updateAfter(previous) { + if (this.thread && this.thread !== previous.thread) { + this._stopLoading(); + if (!this.threadCache.isLoaded && !this.threadCache.isLoading) { + this.threadCache.loadMessages(); + } + } + if (this.threadCache !== previous.threadCache) { + this._stopLoading(); + this.addComponentHint('change-of-thread-cache'); + } + + if ( + this.thread && this.threadCache.isLoading && + !this.isShowingLoading && !this._isPreparingLoading + ) { + this._prepareLoading(); + } + } + + /** + * @override + */ + _updateBefore() { + return { + thread: this.thread, + threadCache: this.threadCache, + }; + } + + } + + ThreadViewer.fields = { + chatWindow: one2one('mail.chat_window', { + inverse: 'threadViewer', + }), + checkedMessages: many2many('mail.message', { + related: 'threadCache.checkedMessages', + }), + /** + * List of component hints. Hints contain information that help + * components make UI/UX decisions based on their UI state. + * For instance, on receiving new messages and the last message + * is visible, it should auto-scroll to this new last message. + * + * Format of a component hint: + * + * { + * type: {string} the name of the component hint. Useful + * for components to dispatch behaviour + * based on its type. + * data: {Object} data related to the component hint. + * For instance, if hint suggests to scroll + * to a certain message, data may contain + * message id. + * } + */ + componentHintList: attr({ + default: [], + }), + messages: many2many('mail.message', { + related: 'threadCache.messages', + }), + stringifiedDomain: attr({ + default: '[]', + }), + thread: many2one('mail.thread', { + inverse: 'viewers', + }), + threadCache: many2one('mail.thread_cache', { + compute: '_computeThreadCache', + dependencies: [ + 'stringifiedDomain', + 'thread', + 'threadCaches', + ], + }), + threadCacheInitialPosition: attr({ + compute: '_computeThreadCacheInitialPosition', + dependencies: [ + 'threadCache', + 'threadCacheInitialScrollPositions', + ], + }), + /** + * List of saved initial scroll positions of thread caches. + * Useful to restore scroll position on changing back to this + * thread cache. Note that this is only applied when opening + * the thread cache, because scroll position may change fast so + * save is already throttled + */ + threadCacheInitialScrollPositions: attr({ + default: {}, + }), + threadCaches: many2many('mail.thread_cache', { + related: 'thread.caches', + }), + uncheckedMessages: many2many('mail.message', { + related: 'threadCache.uncheckedMessages', + }), + }; + + ThreadViewer.modelName = 'mail.thread_viewer'; + + return ThreadViewer; +} + +registerNewModel('mail.thread_viewer', factory); + +}); diff --git a/addons/mail/static/src/models/user/user.js b/addons/mail/static/src/models/user/user.js new file mode 100644 index 00000000000..2e6206c79f5 --- /dev/null +++ b/addons/mail/static/src/models/user/user.js @@ -0,0 +1,78 @@ +odoo.define('mail/static/src/models/user/user.js', function (require) { +'use strict'; + +const { registerNewModel } = require('mail/static/src/model/model_core.js'); +const { attr, one2one } = require('mail/static/src/model/model_field.js'); + +function factory(dependencies) { + + class User extends dependencies['mail.model'] { + + /** + * @override + */ + delete() { + if (this.env.messaging) { + if (this === this.env.messaging.currentUser) { + this.env.messaging.update({ currentUser: [['unlink-all']] }); + } + } + super.delete(); + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @returns {string} + */ + nameOrDisplayName() { + const partner = this.partner; + if (!partner) { + return this.partnerDisplayName; + } + return partner.nameOrDisplayName; + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @override + */ + _createRecordLocalId(data) { + return `${this.env.models['mail.user'].modelName}_${data.id}`; + } + + /** + * @override + */ + _updateAfter(previous) { + if (this.partnerDisplayName && this.partner) { + this.partner.update({ display_name: this.partnerDisplayName }); + } + } + + } + + User.fields = { + id: attr(), + model: attr({ + default: 'res.user', + }), + partner: one2one('mail.partner', { + inverse: 'user', + }), + partnerDisplayName: attr(), + }; + + User.modelName = 'mail.user'; + + return User; +} + +registerNewModel('mail.user', factory); + +}); diff --git a/addons/mail/static/src/scss/abstract_thread_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss deleted file mode 100644 index 3a7f028f2f2..00000000000 --- a/addons/mail/static/src/scss/abstract_thread_window.scss +++ /dev/null @@ -1,194 +0,0 @@ -$o-mail-thread-window-zindex: $zindex-modal + 1 !default; - -.o_thread_window { - direction: ltr; - display: flex; - flex-flow: column nowrap; - position: fixed; - width: $o-mail-thread-window-width; - max-width: 100%; - height: 400px; - max-height: 100%; - font-size: 12px; - background-color: $o-mail-thread-window-bg; - border-radius: 6px 6px 0 0; - z-index: $o-mail-thread-window-zindex; - box-shadow: -5px -5px 10px rgba(black, 0.18); - - @include media-breakpoint-down(sm) { - width: 100%; - height: 100%!important; - box-shadow: none; - &.o_folded { - display: none; - } - } - - @media print { - display: none; - } - - .o_thread_window_header { - align-items: center; - display: flex; - flex: 0 0 auto; - color: white; - padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap; - border-radius: 3px 3px 0 0; - border-bottom: 1px solid gray('300'); - background-color: $o-brand-odoo; - padding: 8px; - - @include media-breakpoint-down(sm) { - align-items: center; - height: $o-mail-chat-header-height; - padding: 0; - border-radius: 0px; - .o_thread_window_title { - font-size: 16px; - margin-left: 10px; - } - - .o_thread_window_close { - $o-close-font-size: 17px; - padding: (($o-mail-chat-header-height - $o-close-font-size) / 2); - font-size: $o-close-font-size; - color: white; - } - } - .o_thread_window_avatar { - margin: -6px 6px -6px 0; - position: relative; - - img { - height: 25px; - width: 25px; - border-radius: 50%; - } - span { - bottom: -4px; - right: -2px; - position: absolute; - - .fa-circle-o { - display: none; - } - } - } - - .o_thread_window_title { - cursor: pointer; - flex: 1 1 auto; - @include o-text-overflow; - - .o_mail_thread_typing_icon { - padding-left: 2px; - - .o_mail_thread_typing_icon_dot { - background: gray('300'); - } - } - } - - .o_thread_window_buttons { - flex: 0 0 auto; - .o_thread_window_close, .o_thread_window_expand { - color: white; - padding: 3px; - margin-left: 5px; - @include o-hover-opacity(0.7, 1); - } - } - - } - - .o_mail_thread { - flex: 1 1 100%; - overflow: auto; - -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) - - .o_thread_date_separator { - margin: 0px 0px 15px 0px; - .o_thread_date { - background-color: $o-mail-thread-window-bg; - } - } - .o_thread_message { - padding: 4px 5px; - .o_thread_message_sidebar { - margin-right: 5px; - } - .o_attachment { - @include media-breakpoint-up(md) { - width: percentage(1/3); - } - } - } - } - - .o_thread_composer input { - width: 100%; - } -} - -.o_thread_window_dropdown { - width: auto; - height: 28px; - color: white; - background-color: gray('900'); - cursor: pointer; - box-shadow: none; - - @include media-breakpoint-down(sm) { - display: none; - } - - .o_thread_window_header { - border-radius: 0; - } - - .o_thread_window_dropdown_toggler { - padding: 5px; - - .o_total_unread_counter { - @include o-position-absolute(-10px, 0, auto, auto); - background-color: $o-brand-primary; - padding: 0 2px; - font-size: smaller; - } - } - &.show .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand { - display: none; - } - - > ul { - max-width: $o-mail-thread-window-width; - padding: 0; - - > li.o_thread_window_header { - font-size: 12px; - padding: 3px 5px; - &~li.o_thread_window_header { - border-top: 1px solid white; - } - &:hover { - background-color: darken($o-brand-odoo, 10%); - } - } - } -} - -.o_ui_blocked .o_thread_window { - // We cannot put the z-index of thread windows directly to be greater than - // blockUI's as ui-autocomplete dropdowns (which are below blockUI) would - // appear under the thread windows (and ui-autocomplete is used to choose the - // person you want to chat with). So we only raise the z-index value when - // the ui is really blocked (in that case, the ui-autocomplete dropdowns - // will disappear under the thread windows but this is not really an issue as - // there should not be any at that time). - z-index: 1101; // blockUI's z-index is 1100 -} - -.o_no_thread_window .o_thread_window { - display: none; -} diff --git a/addons/mail/static/src/scss/attachment_box.scss b/addons/mail/static/src/scss/attachment_box.scss deleted file mode 100644 index 7008fcce61e..00000000000 --- a/addons/mail/static/src/scss/attachment_box.scss +++ /dev/null @@ -1,34 +0,0 @@ -.oe_chatter { - - .o_mail_chatter_attachments { - min-height: 50px; - - .o_chatter_attachment { - padding: $o-attachment-margin; - - input.o_input_file { - position: absolute; - top: 1px; - opacity: 0; - pointer-events: none; - } - - .o_border_dashed { - position: relative; - margin-top: 5px; - margin-bottom: 5px; - border-bottom: 1px dashed gray('300'); - text-align: center; - - .o_attach_title { - position: relative; - top: 10px; - margin: 0 auto; - padding: 0 10px; - font-weight: bold; - background: white; - } - } - } - } -} diff --git a/addons/mail/static/src/scss/chatter.scss b/addons/mail/static/src/scss/chatter.scss deleted file mode 100644 index 6784121da58..00000000000 --- a/addons/mail/static/src/scss/chatter.scss +++ /dev/null @@ -1,35 +0,0 @@ -.o_chatter { - .o_spinner { - text-align: center; - font-size: large; - } - - .o_chatter_topbar { - display: flex; - border-bottom: $nav-tabs-border-width solid transparent; - - .btn { - @extend .text-nowrap; - margin-bottom: -$nav-tabs-border-width; - padding: $nav-link-padding-y $nav-link-padding-x; - border-radius: $border-radius-sm $border-radius-sm 0 0; - } - - > .o_topbar_right_area { - display: flex; - margin-left: auto; - justify-content: flex-end; - } - } - - &.o_chatter_composer_active .o_chatter_topbar { - border-bottom-color: gray('300'); - - .btn.o_active { - color: $o-brand-odoo; - background-color: theme-color('light'); - border: $nav-tabs-border-width solid gray('300'); - border-bottom-color: transparent; - } - } -} diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index ad3338fb794..b478c0b0941 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -12,274 +12,6 @@ local(Symbola); } -.o_thread_composer { - flex: 0 0 auto; - display: flex; - border-top: none; - background: theme-color('light'); - - .o_composer, .o_composer_subject { - position: relative; - background: white; - - textarea, input { - border: none; - } - } - - .o_composer_subject { - display: flex; - padding-bottom: 5px; - - > input { - flex: 1 1 auto; - padding: 5px; - margin-right: 30px; - } - } - - .o_composer { - .o_composer_input > textarea { - width: 100%; - resize: none; - } - .o_composer_mention_dropdown .o_mention_proposition { - @include o-text-overflow; - width: 100%; - padding: 3px 15px; - .o_mention_name { - font-weight: 500; - } - .o_mention_info { - font-style: italic; - color: gray; - } - .fa { - font-size: 0.8em; - } - } - textarea { - resize: none; - } - .btn.fa { - @include o-hover-opacity($default-opacity: 0.8, $hover-opacity: 1); - border: none; - } - .o_composer_button_full_composer { - @include o-position-absolute(auto, 0); - } - - .o_mail_emoji_container { - padding: 0 $o-mail-chatter-gap $o-mail-chatter-gap 0; - border: 1px solid gray('300'); - border-width: 1px 0 0 0; - background-color: theme-color('light'); - - .o_mail_emoji { - margin: $o-mail-chatter-gap 0 0 $o-mail-chatter-gap; - cursor: pointer; // only in the popover - min-width: 22px; - min-height: 22px; - } - } - } - - // Both inline and not-inline design. Mini-composer disabled. - &:not(.o_chat_mini_composer) { - padding: $o-mail-thread-avatar-size*0.5; - padding-left: $grid-gutter-width*0.5 + $o-mail-thread-avatar-size + $o-mail-chatter-gap; - border: 1px solid gray('300'); - border-top: none; - - .o_chatter_avatar { - @include o-position-absolute(0, $left: -$o-mail-thread-avatar-size - $o-mail-chatter-gap); - } - - .o_chatter_composer_info, .o_composer_suggested_partners { - flex: 0 0 100%; - margin-bottom: $o-mail-chatter-gap*0.5; - } - - .o_composer_container { - position: relative; - } - - .o_composer { - border-radius: 2px; - textarea { - padding-top: $o-mail-chatter-gap !important; - padding-right: $grid-gutter-width; // avoid overlaps betwenn textarea and o_composer_button_full_composer - } - - .o_mail_emoji_container { - margin: 0 -$o-mail-chatter-gap; - } - - @include media-breakpoint-down(sm) { - .o_composer_button_send { - color: $o-brand-primary; - } - } - } - - .o_composer, .o_composer_subject { - border: 1px solid gray('300'); - - textarea, input { - padding: 0 0 $o-mail-chatter-gap; - border: none; - } - } - .o_composer_subject { - border-bottom: 0; - border-radius: 2px 2px 0 0; - } - - .o_chatter_composer_tools { - border-top: 1px solid gray('300'); - } - - &:not(.o_chat_inline_composer) { - flex-flow: row wrap; - - .o_composer_container { - max-width: 100%; - } - .o_composer_container, .o_composer_button_send { - flex: 1 0 100%; - } - .o_composer, .o_composer_subject { - padding: 0 $o-mail-chatter-gap 0; - } - .o_composer_send { - margin-top: $o-mail-chatter-gap; - - > .o_composer_button_discard:not(:hover) { - background: theme-color('light'); - border-color: transparent; - } - } - } - - &.o_chat_inline_composer { - .o_composer_container { - flex: 1 1 auto; - } - .o_composer { - padding: $o-mail-chatter-gap $o-mail-chatter-gap 0 $o-mail-chatter-gap; - border-radius: 2px 0 0 2px; - border-right-width: 0; - - .o_mail_emoji_container { - text-align: right; - border-right-width: 1px; - } - } - .o_composer_input { - display: flex; - flex-flow: row wrap; - - textarea { - padding-top: 0px !important; - &:disabled { - position: absolute; - } - } - } - .o_composer_text_field { - flex: 1 1 50%; - margin: 0; - padding: 0; - } - .o_chatter_composer_tools { - flex: 0 1 auto; - } - .o_composer_attachments_list { - flex: 1 0 100%; - } - - .o_composer_thread_typing { - font-size: 0.9em; - color: gray('600'); - } - - .o_chatter_composer_tools { - border: none; - border-left: 1px solid gray('300'); - padding-left: $o-mail-chatter-gap*0.5; - transform: translateY(-$o-mail-chatter-gap*0.5); - } - - .o_composer_button_send { - border-radius: 0 3px 3px 0; - // Make the button appear as the same size as the subject input - padding: 10px $input-btn-padding-x; - border: 1px solid darken($o-brand-primary, 5%); - border-left-width: 0; - } - } - } - - // Mini-composer enabled - &.o_chat_mini_composer { - .o_composer_container { - border-top: 1px solid gray('300'); - width: 100%; - } - .o_composer { - .o_mail_emoji_container { - @include o-position-absolute(auto, 0, 100%, 0); - border-bottom-width: 1px; - max-height: calc(100vh - 100%); - overflow-y: auto; - } - } - .o_composer_input { - display: flex; - flex-flow: row nowrap; - - textarea.o_composer_text_field { - flex: 1 1 auto; - } - - .o_chatter_composer_tools { - flex: 0 0 auto; - } - @include media-breakpoint-down(sm) { - .o_composer_button_send { - display: inline-block; - } - } - } - .o_composer_send .o_composer_button_send, .o_chatter_avatar { - display: none !important; - } - .o_attachment { - width: 50%; - } - } - - .o_file_drop_zone_container { - display: flex; - position: absolute; - left: 0px; - bottom: 0px; - height: 100%; - width: 100%; - color: $o-brand-primary; - background: rgba(255, 255, 255, 0.9); - border: 2px dashed $o-brand-primary; - z-index: 1; - align-items: center; - justify-content: center; - } -} - -.o_composer_attachments_list:not(:empty) { - margin-top: $o-mail-chatter-gap; - text-align: justify; -} - // Emoji .o_mail_emoji { display: inline-block; @@ -291,80 +23,6 @@ font-size: 100%; } -// Attachment Icons (common for chat thread and chat composer) -.o_attachments_list, .o_attachments_previews { - margin: 0 (-$o-attachment-margin) 0; - display: flex; - flex-flow: row wrap; -} - - .o_attachment_wrap { - padding-right: 0px; - } - -.o_attachments_previews { - > .o_attachment { - width: 50%; - - @include media-breakpoint-up(md) { - width: percentage(1/3); - } - @include media-breakpoint-up(lg) { - width: percentage(1/4); - } - @include media-breakpoint-up(xl) { - width: percentage(1/6); - } - - .o_attachment_wrap { - padding: 0; - border-radius: 2px; - border: 1px solid gray('400'); - } - - .o_image_box { - overflow: hidden; - padding-top: 75%; - - .o_attachment_image { - @include o-position-absolute(0, 0, 0, 0); - background-size: cover; - background-position: center; - transform: scale(1.05); - transition: all 0.3s ease 0s; - } - - &:hover .o_attachment_image { - transform: scale(1); - } - } - - .o_image_overlay { - @include o-position-absolute($top: 0, $left: 0, $right:0, $bottom:0); - @include o-hover-opacity($default-opacity: 0, $hover-opacity: 1); - background-image: linear-gradient(180deg, rgba(black, 0.2), rgba(black, 0.9)); - transition: all 0.3s ease 0s; - padding: 10px; - - .o_attachment_title { - @include o-position-absolute($bottom: 7px); - width: 80%; - @include o-text-overflow(inline-block); - font-size: $font-size-sm; - } - - .o_attachment_delete_cross { - color: white; - } - - .o_attachment_download { - @include o-position-absolute($right: 7px, $bottom:7px); - } - } - } -} - - @mixin o-viewer-black-btn { background-color: rgba(black, 0.4); color: rgba(theme-color('light'), 0.7); diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index 81b7460325b..22e5e71c677 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -2,258 +2,10 @@ // Layout // ------------------------------------------------------------------ -.o_mail_discuss { - @include o-position-absolute(0, 0, 0, 0); - height: 100%; - overflow: hidden; - - .o_mail_discuss_loading { - display: flex; - flex: 1 1 auto; - align-items: center; - justify-content: center; - min-height: 100%; - - > i { - margin-right: 5px; - } - } - - .o_mail_discuss_sidebar { - width: $o-mail-chat-sidebar-width; - height: 100%; - float: left; - overflow: auto; - padding: $o-horizontal-padding 0; - - @include media-breakpoint-up(xl) { - width: $o-mail-chat-sidebar-width + 50px; - } - - .o_discuss_sidebar_quick_search { - display: flex; - justify-content: center; - - > input { - border-radius: 15px; - flex-basis: 80%; - } - } - - .o_mail_sidebar_title { - display: flex; - padding: 6px $o-horizontal-padding 0 $o-horizontal-padding; - align-items: center; - justify-content: space-between; - h4 { - text-transform: uppercase; - margin-bottom: 5px; - } - .o_add { - padding: 6px; - } - .o_mail_open_channels { - cursor: pointer; - } - } - .o_mail_discuss_item { - display: flex; - align-items: center; - width: 100%; - padding: 2px $o-horizontal-padding 2px ($o-horizontal-padding + 12px); // compensate icon - - .o_thread_name { - flex: 1 1 auto; - @include o-text-overflow; - } - .o_mail_thread_typing_icon_dot { - background: gray('600');; - } - .o_mail_sidebar_needaction, .o_mail_partner_unpin, .o_mail_channel_settings { - flex: 0 0 auto; - } - .o_mail_sidebar_needaction { - margin: 0 0 0 5px; - } - .o_mail_partner_unpin, .o_mail_channel_settings { - padding: 2px 6px; - } - i.fa-envelope-o { - margin-left: 3px; - } - - &.o_mail_discuss_title_main { - padding: $input-btn-padding-y $o-horizontal-padding; - margin-bottom: 2px; - } - } - - .o_mail_discuss_item + .o_mail_sidebar_title { - margin-top: $input-btn-padding-y; - } - - .o_mail_add_thread { - width: 100%; - padding: 5px ($o-horizontal-padding + 23px); // compensate icon - position: relative; - - > input { - width: 100%; - padding: 2px 5px; - } - > span { - @include o-position-absolute(7px, $left: 27px); - } - } - } - - .o_mail_discuss_content { - display: flex; - flex-flow: column nowrap; - height: 100%; - margin-left: $o-mail-chat-sidebar-width; - position: relative; - overflow: auto; - - @include media-breakpoint-up(xl) { - margin-left: $o-mail-chat-sidebar-width + 50px; - } - - .o_mail_thread { - position: relative; - flex: 1 0 0; - overflow: auto; - -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari) - } - - .o_mail_snackbar { - @include o-position-absolute(15px, 15px); - padding-top: 10px; - padding-bottom: 10px; - cursor: pointer; - background-color: darken(gray('700'), 8%); - color: white; - } - } -} - // ------------------------------------------------------------------ // Style // ------------------------------------------------------------------ -.o_mail_discuss { - .o_mail_discuss_sidebar { - background-color: gray('900'); - color: gray('300'); - - .o_discuss_sidebar_quick_search { - - > input:focus { - &::placeholder { - color: transparent; - } - } - - > input { - outline: none; - background-color: gray('800'); - border: gray('600'); - text-align: center; - color: gray('400'); - } - } - - .o_mail_discuss_item { - cursor: pointer; - - &:hover { - background-color: darken(gray('900'),2%); - - .o_mail_partner_unpin, .o_mail_channel_settings { - display: inline-block; - } - } - - &.o_active { - background-color: gray('900'); - box-shadow: inset 3px 0 0 $o-brand-primary; - } - - .o_mail_hash { - opacity: 0.6 - } - - .o_mail_partner_unpin, .o_mail_channel_settings { - display: none; - } - &.o_unread_message { - color: white; - font-weight: bold; - } - - > .badge { - color: white; - background-color: $o-brand-primary; - } - - &.o_mail_discuss_title_main { - font-size: $font-size-base; - - &.o_mail_mailbox_title_starred > .badge { - background-color: gray('600'); - border-color: gray('600'); - } - } - } - - > hr { - border-color: gray('700'); - } - - .o_mail_add_thread { - display: none; - > input { - color: black; - } - > span { - color: gray; - } - } - - .o_mail_sidebar_title { - h4 { - margin: 0; - font-size: $font-size-sm; - text-transform: uppercase; - color: gray('600'); - &.o_mail_open_channels:hover { - color: white; - } - } - .o_add { - cursor: pointer; - @include o-hover-opacity($o-mail-sidebar-icon-opacity, 1); - @include o-hover-text-color(gray('600'), white); - } - } - } - - .o_mail_discuss_content { - background-color: white; - } - - &.o_mail_selection_mode { - .o_thread_message { - opacity: 0.5; - &.o_thread_selected_message { - padding: 4px $o-horizontal-padding; - opacity: 1; - background-color: lightgray; - } - } - } -} - .o_mail_user_status { font-size: 1em; position: relative; @@ -270,66 +22,6 @@ } } - -// ------------------------------------------------------------------ -// Mobile -// ------------------------------------------------------------------ -@include media-breakpoint-down(sm) { - .o_mail_discuss { - display: flex; - flex-flow: column nowrap; - background: white; - - .o_mail_discuss_mobile_mailboxes_buttons { - display: flex; - flex: 0 0 auto; - z-index: 2; - box-shadow: 0 2px 4px gray('400'); - - .o_mailbox_inbox_item { - flex: 1 1 auto; - width: 50%; - padding: 2%; - - &.btn-secondary { - border-bottom: 1px solid gray('400') - } - } - } - - .o_mail_discuss_content { - flex: 1 1 auto; - min-height: 0; - margin-left: 0; - background-color: transparent; - } - - .o_mail_discuss_tab_pane { - .o_mail_add_thread { - display: none; - position: relative; - padding: 5px 0px; - margin: 10px; - border: 1px solid #afafaf; - border-radius: 4px; - > span { - @include o-position-absolute($top: 10px, $left: 10px); - } - > input { - border: none; - padding: 5px 8px; - padding-left: 20px; - } - } - } - .o_mail_mobile_tabs { - flex: 0 0 auto; - z-index: 1; - background-color: white; - } - } -} - // ------------------------------------------------------------------ // Thread preview: shared between discuss (mobile) and systray // ------------------------------------------------------------------ diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss deleted file mode 100644 index 031affb6e52..00000000000 --- a/addons/mail/static/src/scss/followers.scss +++ /dev/null @@ -1,92 +0,0 @@ -.o_followers { - display: flex; - justify-content: flex-end; - order: 1; - - &.o_field_widget { - margin-bottom: initial; - } - - > .o_followers_title_box { - order: 1; - } - - .o_followers_follow_button { - color: $body-color; - font-weight: bold; - text-align: right; - - .o_follow, .o_followers_unfollow, .o_followers_following { - display: none; - } - - // when following, show 'following' - &.o_followers_following .o_followers_following { - display: initial; - } - - // when following and hovering, show 'unfollow' - &.o_followers_following:hover { - .o_followers_following { - display: none; - } - - .o_followers_unfollow { - display: initial; - } - } - - // when not following show 'follow' - &.o_followers_notfollow .o_follow { - display: initial; - } - - .fa { - &.fa-times { - color: theme-color('warning'); - } - - &.fa-check { - color: theme-color('success'); - } - } - } - - .o_followers_list { - width: 20vh; - max-height: 50vh; - overflow: auto; - } - - .o_followers_title_box .dropdown-item.o_partner { - padding: 0; - display: flex; - align-items: center; - - .o_mail_redirect { - margin-right: auto; - color: $dropdown-link-color; - - img { - width: $o-mail-partner-avatar-size; - height: $o-mail-partner-avatar-size; - object-fit: cover; - } - } - - .btn { - padding: 0 $grid-gutter-width*0.25; - } - - &.o_inactive { - .o_mail_redirect { - color: $gray-500; - font-style: italic; - - img { - opacity: 0.25; - } - } - } - } -} diff --git a/addons/mail/static/src/scss/out_of_office.scss b/addons/mail/static/src/scss/out_of_office.scss deleted file mode 100644 index bcdb3a182f7..00000000000 --- a/addons/mail/static/src/scss/out_of_office.scss +++ /dev/null @@ -1,8 +0,0 @@ -.o_out_of_office { - background-color: #9dd0e8; - padding:8px; - - .o_out_of_office_info { - font-weight: bold; - } -} diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index 2fc55c7d637..17c0ae72655 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -1,32 +1,4 @@ -.o_mail_thread_loading { - display: flex; - align-items: center; - justify-content: center; -} - -.o_mail_thread_loading_icon { - margin-right: 5px; -} - -.o_mail_thread, .o_mail_activity { - .o_thread_show_more { - text-align: center; - } - - .o_mail_thread_content { - display: flex; - flex-direction: column; - min-height: 100%; - } - - .o_thread_bottom_free_space { - height: 15px; - } - - .o_thread_tooltip_container { - display: inline; - position: relative; - } +.o_mail_activity { .o_thread_date_separator { margin-top: 15px; @@ -48,30 +20,11 @@ } } - .o_thread_new_messages_separator { - margin-bottom: 15px; - border-bottom: solid lighten($o-brand-odoo, 15%) 1px; - text-align: right; - .o_thread_separator_label { - position: relative; - top: 8px; - padding: 0 10px; - background: white; - color: lighten($o-brand-odoo, 15%); - font-size: smaller; - } - } - .o_thread_message { display: flex; padding: 4px $o-horizontal-padding; margin-bottom: 0px; - &.o_mail_not_discussion { - background-color: rgba(gray('300'), 0.5); - border-bottom: 1px solid gray('400'); - } - .o_thread_message_sidebar { flex: 0 0 $o-mail-thread-avatar-size; margin-right: 10px; @@ -106,37 +59,13 @@ height: $o-mail-thread-avatar-size; object-fit: cover; } - .o_thread_message_side_date { - display: none; - margin-left: -5px; - } - .o_thread_message_star { - display: none; - margin-right: -5px; - } - - .o_thread_message_side_date { - opacity: 0; - } - - .o_mail_thread_message_seen_icon:not(.o_all_seen) { - opacity: $o-mail-thread-icon-opacity*0.5; - } } .o_thread_icon { cursor: pointer; opacity: 0; - &.fa-star { - opacity: $o-mail-thread-icon-opacity; - color: gold; - } } - &:hover, &.o_thread_selected_message { - .o_thread_message_side_date { - display: inline-block; - opacity: $o-mail-thread-side-date-opacity; - } + &:hover { .o_thread_icon { display: inline-block; opacity: $o-mail-thread-icon-opacity; @@ -144,11 +73,6 @@ opacity: 1; } } - .o_thread_message_sidebar { - .o_mail_thread_message_seen_icon { - display: none; - } - } } .o_mail_redirect { @@ -210,29 +134,9 @@ } } - .o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_notification { + .o_thread_message_needaction, .o_thread_message_reply { padding: 4px; } - - .o_thread_message_notification { - color: grey; - &.o_thread_message_notification_error { - color: red; - opacity: 1; - cursor: pointer; - } - } - - .o_attachments_list, .o_attachments_previews { - &:last-child { - margin-bottom: $grid-gutter-width; - } - } - - .o_thread_tooltip_container { - display: inline; - position: relative; - } } } .o_thread_title { @@ -268,68 +172,3 @@ opacity: $o-mail-thread-icon-opacity; } } - -// ------------------------------------------------------------------ -// Thread typing icon: shared between discuss and chat windows -// ------------------------------------------------------------------ - -.o_mail_thread_typing_icon { - position: relative; - text-align: center; - margin-left: auto; - margin-right: auto; - - .o_mail_thread_typing_icon_dot { - display: inline-block; - width: 3px; - height: 3px; - border-radius: 50%; - background: gray('800'); - animation: o_mail_thread_typing_icon_dot 1.5s linear infinite; - - &:nth-child(2) { - animation-delay: -1.35s; - } - - &:nth-child(3) { - animation-delay: -1.2s; - } - } -} - -@keyframes o_mail_thread_typing_icon_dot { - 0%, 40%, 100% { - transform: initial; - } - 20% { - transform: translateY(-5px); - } -} - -// ------------------------------------------------------------------ -// Thread seen icon: shared between discuss and chat windows -// ------------------------------------------------------------------ - -.o_mail_thread_message_seen_icon { - position: relative; - opacity: 0.6; - - &.o_all_seen { - color: $o-enterprise-color; - } - - &:hover { - cursor: pointer; - opacity: 0.8; - } - - .fa-check:nth-child(1) { - padding-left: 3px; - } - - .fa-check:nth-child(2) { - position: absolute; - top: -1px; - left: 0px; - } -} diff --git a/addons/mail/static/src/scss/thread_window.scss b/addons/mail/static/src/scss/thread_window.scss deleted file mode 100644 index abc8ff2846d..00000000000 --- a/addons/mail/static/src/scss/thread_window.scss +++ /dev/null @@ -1,63 +0,0 @@ -.o_thread_window { - &.o_thread_less { - .o_thread_search_input { - display: flex; - align-items: baseline; - padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap; - - > span { - flex: 0 0 auto; - } - - > input { - margin-left: 5px; - padding: $o-mail-chatter-gap*0.5; - flex: 1 1 100%; - } - } - - .o_mail_thread { - background-color: $o-webclient-background-color; - } - - .o_thread_composer { - display: none; - } - } - - .o_thread_composer { - .o_file_drop_zone_container { - top: 35px; - } - .o_composer { - .o_composer_input { - width: 80%; - > textarea { - padding: 10px; - font-size: 13px; - } - @include media-breakpoint-down(sm) { - width: 70%; - > textarea { - padding: 15px 10px; - } - } - } - .o_chatter_composer_tools { - @include o-position-absolute($right: 0px, $top: 0px); - button { - padding: 10px; - @include media-breakpoint-down(sm) { - padding: 13px; - } - &.btn.fa { - font-size: 1.3em; - } - } - } - } - .o_composer_attachments_list { - margin: 0px; - } - } -} diff --git a/addons/mail/static/src/services/chat_window_service/chat_window_service.js b/addons/mail/static/src/services/chat_window_service/chat_window_service.js new file mode 100644 index 00000000000..8a11c20257c --- /dev/null +++ b/addons/mail/static/src/services/chat_window_service/chat_window_service.js @@ -0,0 +1,104 @@ +odoo.define('mail/static/src/services/chat_window_service/chat_window_service.js', function (require) { +'use strict'; + +const components = { + ChatWindowManager: require('mail/static/src/components/chat_window_manager/chat_window_manager.js'), +}; + +const AbstractService = require('web.AbstractService'); +const { bus, serviceRegistry } = require('web.core'); + +const ChatWindowService = AbstractService.extend({ + /** + * @override {web.AbstractService} + */ + start() { + this._super(...arguments); + this._webClientReady = false; + this._listenHomeMenu(); + }, + /** + * @private + */ + destroy() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Node} + */ + _getParentNode() { + return document.querySelector('body'); + }, + /** + * @private + */ + _listenHomeMenu() { + bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this)); + bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this)); + bus.on('web_client_ready', this, this._onWebClientReady.bind(this)); + }, + /** + * @private + */ + async _mount() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + const ChatWindowManagerComponent = components.ChatWindowManager; + this.component = new ChatWindowManagerComponent(null); + const parentNode = this._getParentNode(); + await this.component.mount(parentNode); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _onHideHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_ChatWindowManager')) { + return; + } + await this._mount(); + }, + /** + * @private + */ + async _onShowHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_ChatWindowManager')) { + return; + } + await this._mount(); + }, + /** + * @private + */ + async _onWebClientReady() { + await this._mount(); + this._webClientReady = true; + }, +}); + +serviceRegistry.add('chat_window', ChatWindowService); + +return ChatWindowService; + +}); diff --git a/addons/mail/static/src/services/dialog_service/dialog_service.js b/addons/mail/static/src/services/dialog_service/dialog_service.js new file mode 100644 index 00000000000..88762a2992d --- /dev/null +++ b/addons/mail/static/src/services/dialog_service/dialog_service.js @@ -0,0 +1,101 @@ +odoo.define('mail/static/src/services/dialog_service/dialog_service.js', function (require) { +'use strict'; + +const components = { + DialogManager: require('mail/static/src/components/dialog_manager/dialog_manager.js'), +}; + +const AbstractService = require('web.AbstractService'); +const { bus, serviceRegistry } = require('web.core'); + +const DialogService = AbstractService.extend({ + /** + * @override {web.AbstractService} + */ + start() { + this._super(...arguments); + this._webClientReady = false; + this._listenHomeMenu(); + }, + /** + * @private + */ + destroy() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Node} + */ + _getParentNode() { + return document.querySelector('body'); + }, + /** + * @private + */ + _listenHomeMenu() { + bus.on('hide_home_menu', this, this._onHideHomeMenu.bind(this)); + bus.on('show_home_menu', this, this._onShowHomeMenu.bind(this)); + bus.on('web_client_ready', this, this._onWebClientReady.bind(this)); + }, + /** + * @private + */ + async _mount() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + const DialogManagerComponent = components.DialogManager; + this.component = new DialogManagerComponent(null); + const parentNode = this._getParentNode(); + await this.component.mount(parentNode); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _onHideHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_DialogManager')) { + return; + } + await this._mount(); + }, + async _onShowHomeMenu() { + if (!this._webClientReady) { + return; + } + if (document.querySelector('.o_DialogManager')) { + return; + } + await this._mount(); + }, + /** + * @private + */ + async _onWebClientReady() { + await this._mount(); + this._webClientReady = true; + } +}); + +serviceRegistry.add('dialog', DialogService); + +return DialogService; + +}); diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js new file mode 100644 index 00000000000..f96696fb992 --- /dev/null +++ b/addons/mail/static/src/utils/deferred/deferred.js @@ -0,0 +1,21 @@ +odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) { +'use strict'; + +/** + * @returns {Deferred} + */ +function makeDeferred() { + let resolve; + let reject; + const prom = new Promise(function (res, rej) { + resolve = res.bind(this); + reject = rej.bind(this); + }); + prom.resolve = (...args) => resolve(...args); + prom.reject = (...args) => reject(...args); + return prom; +} + +return { makeDeferred }; + +}); diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js new file mode 100644 index 00000000000..e8e0c6dbc10 --- /dev/null +++ b/addons/mail/static/src/utils/test_utils.js @@ -0,0 +1,860 @@ +odoo.define('mail/static/src/utils/test_utils.js', function (require) { +'use strict'; + +const BusService = require('bus.BusService'); + +const { + addMessagingToEnv, + addTimeControlToEnv, +} = require('mail/static/src/env/test_env.js'); +const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js'); +const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); +const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js'); +const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js'); + +const AbstractStorageService = require('web.AbstractStorageService'); +const NotificationService = require('web.NotificationService'); +const RamStorage = require('web.RamStorage'); +const { + createActionManager, + createView, + makeTestPromise, + mock: { + addMockEnvironment, + patch: legacyPatch, + unpatch: legacyUnpatch, + }, +} = require('web.test_utils'); +const Widget = require('web.Widget'); + +const { Component } = owl; + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * Create a fake object 'dataTransfer', linked to some files, + * which is passed to drag and drop events. + * + * @param {Object[]} files + * @returns {Object} + */ +function _createFakeDataTransfer(files) { + return { + dropEffect: 'all', + effectAllowed: 'all', + files, + items: [], + types: ['Files'], + }; +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useChatWindow(callbacks) { + const { + mount: prevMount, + destroy: prevDestroy, + } = callbacks; + return Object.assign({}, callbacks, { + mount: prevMount.concat(() => { + // trigger mounting of chat window manager + Component.env.services['chat_window']._onWebClientReady(); + }), + destroy: prevDestroy.concat(() => { + Component.env.services['chat_window'].destroy(); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useDialog(callbacks) { + const { + mount: prevMount, + destroy: prevDestroy, + } = callbacks; + return Object.assign({}, callbacks, { + mount: prevMount.concat(() => { + // trigger mounting of dialog manager + Component.env.services['dialog']._onWebClientReady(); + }), + destroy: prevDestroy.concat(() => { + Component.env.services['dialog'].destroy(); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @return {Object} update callbacks + */ +function _useDiscuss(callbacks) { + const { + init: prevInit, + mount: prevMount, + return: prevReturn, + } = callbacks; + let discussWidget; + const state = { + autoOpenDiscuss: false, + discussData: {}, + }; + return Object.assign({}, callbacks, { + init: prevInit.concat(params => { + const { + autoOpenDiscuss = state.autoOpenDiscuss, + discuss: discussData = state.discussData + } = params; + Object.assign(state, { autoOpenDiscuss, discussData }); + delete params.autoOpenDiscuss; + delete params.discuss; + }), + mount: prevMount.concat(async params => { + const { selector, widget } = params; + DiscussWidget.prototype._pushStateActionManager = () => {}; + discussWidget = new DiscussWidget(widget, state.discussData); + await discussWidget.appendTo($(selector)); + if (state.autoOpenDiscuss) { + discussWidget.on_attach_callback(); + } + }), + return: prevReturn.concat(result => { + Object.assign(result, { discussWidget }); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useMessagingMenu(callbacks) { + const { + mount: prevMount, + return: prevReturn, + } = callbacks; + let messagingMenuWidget; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async ({ selector, widget }) => { + messagingMenuWidget = new MessagingMenuWidget(widget, {}); + await messagingMenuWidget.appendTo($(selector)); + messagingMenuWidget.on_attach_callback(); + }), + return: prevReturn.concat(result => { + Object.assign(result, { messagingMenuWidget }); + }), + }); +} + +//------------------------------------------------------------------------------ +// Public: rendering timers +//------------------------------------------------------------------------------ + +/** + * Returns a promise resolved at the next animation frame. + * + * @returns {Promise} + */ +function nextAnimationFrame() { + const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame; + return new Promise(function (resolve) { + setTimeout(() => requestAnimationFrame(() => resolve())); + }); +} + +/** + * Returns a promise resolved the next time OWL stops rendering. + * + * @param {function} func function which, when called, is + * expected to trigger OWL render(s). + * @param {number} [timeoutDelay=5000] in ms + * @returns {Promise} + */ +const afterNextRender = (function () { + const stop = owl.Component.scheduler.stop; + const stopPromises = []; + + owl.Component.scheduler.stop = function () { + const wasRunning = this.isRunning; + stop.call(this); + if (wasRunning) { + while (stopPromises.length) { + stopPromises.pop().resolve(); + } + } + }; + + async function afterNextRender(func, timeoutDelay = 5000) { + // Define the potential errors outside of the promise to get a proper + // trace if they happen. + const startError = new Error("Timeout: the render didn't start."); + const stopError = new Error("Timeout: the render didn't stop."); + // Set up the timeout to reject if no render happens. + let timeoutNoRender; + const timeoutProm = new Promise((resolve, reject) => { + timeoutNoRender = setTimeout(() => { + let error = startError; + if (owl.Component.scheduler.isRunning) { + error = stopError; + } + console.error(error); + reject(error); + }, timeoutDelay); + }); + // Set up the promise to resolve if a render happens. + const prom = makeTestPromise(); + stopPromises.push(prom); + // Start the function expected to trigger a render after the promise + // has been registered to not miss any potential render. + const funcRes = func(); + // Make them race (first to resolve/reject wins). + await Promise.race([prom, timeoutProm]); + clearTimeout(timeoutNoRender); + // Wait the end of the function to ensure all potential effects are + // taken into account during the following verification step. + await funcRes; + // Wait one more frame to make sure no new render has been queued. + await nextAnimationFrame(); + if (owl.Component.scheduler.isRunning) { + await afterNextRender(() => {}, timeoutDelay); + } + } + + return afterNextRender; +})(); + + +//------------------------------------------------------------------------------ +// Public: test lifecycle +//------------------------------------------------------------------------------ + +function beforeEach(self) { + const data = { + initMessaging: { + channel_slots: {}, + commands: [], + is_moderator: false, + mail_failures: [], + mention_partner_suggestions: [], + menu_id: false, + moderation_counter: 0, + moderation_channel_ids: [], + needaction_inbox_counter: 0, + partner_root: { + active: false, + display_name: "OdooBot", + id: 2, + }, + public_partner: { + active: false, + display_name: "Public user", + id: 4, + }, + shortcodes: [], + starred_counter: 0, + }, + 'ir.attachment': { + fields: { + name: { type: 'char', string: "attachment name", required: true }, + res_model: { type: 'char', string: "res model" }, + res_id: { type: 'integer', string: "res id" }, + url: { type: 'char', string: 'url' }, + type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] }, + mimetype: { type: 'char', string: "mimetype" }, + }, + }, + 'mail.activity': { + fields: { + can_write: { + type: 'boolean', + }, + icon: { + type: 'string', + }, + id: { + type: 'integer', + }, + res_id: { + type: 'integer', + }, + res_model: { + type: 'string', + }, + }, + }, + 'mail.channel': { + fields: { + channel_type: { + string: "Channel Type", + type: "selection", + }, + id: { + string: "Id", + type: 'integer', + }, + message_unread_counter: { + string: "# unread messages", + type: 'integer', + }, + name: { + string: "Name", + type: "char", + required: true, + }, + }, + }, + 'mail.followers': { + fields: { + channel_id: { + type: 'integer', + }, + email: { + type: 'string', + }, + id: { + type: 'integer', + }, + is_active: { + type: 'boolean', + }, + is_editable: { + type: 'boolean', + }, + name: { + type: 'string', + }, + partner_id: { + type: 'integer', + }, + }, + }, + 'mail.message': { + fields: { + attachment_ids: { + string: "Attachments", + type: 'many2many', + relation: 'ir.attachment', + default: [], + }, + author_id: { + string: "Author", + relation: 'res.partner', + }, + body: { + string: "Contents", + type: 'html', + }, + channel_ids: { + string: "Channels", + type: 'many2many', + relation: 'mail.channel', + }, + date: { + string: "Date", + type: 'datetime', + }, + history_partner_ids: { + string: "Partners with History", + type: 'many2many', + relation: 'res.partner', + }, + id: { + string: "Id", + type: 'integer', + }, + is_discussion: { + string: "Discussion", + type: 'boolean', + }, + is_note: { + string: "Note", + type: 'boolean', + }, + is_notification: { + string: "Notification", + type: 'boolean', + }, + is_starred: { + string: "Starred", + type: 'boolean', + }, + message_type: { + string: "Type", + type: 'selection', + }, + model: { + string: "Related Document model", + type: 'char', + }, + needaction: { + string: "Need Action", + type: 'boolean', + }, + needaction_partner_ids: { + string: "Partners with Need Action", + type: 'many2many', + relation: 'res.partner', + }, + record_name: { + string: "Name", + type: 'string', + }, + res_id: { + string: "Related Document ID", + type: 'integer', + }, + starred: { + string: "Starred", + type: 'boolean', + }, + starred_partner_ids: { + string: "Favorited By", + type: 'many2many', + relation: 'res.partner', + }, + }, + }, + 'mail.notification': { + fields: { + is_read: { + string: "Is Read", + type: 'boolean', + }, + mail_message_id: { + string: "Message", + type: 'many2one', + relation: 'mail.message', + }, + res_partner_id: { + string: "Needaction Recipient", + type: 'many2one', + relation: 'res.partner', + }, + }, + }, + 'res.partner': { + fields: { + display_name: { string: "Displayed name", type: "char" }, + im_status: { + string: "status", + type: 'char', + }, + message_follower_ids: { + relation: 'follower', + string: "Followers", + type: "one2many", + }, + }, + records: [], + }, + 'res.users': { + fields: { + partner_id: { + string: "Related partners", + type: 'many2one', + relation: 'res.partner', + }, + }, + }, + }; + + const originals = { + '_.debounce': _.debounce, + '_.throttle': _.throttle, + 'window.fetch': window.fetch, + }; + + (function patch() { + // patch _.debounce and _.throttle to be fast and synchronous + _.debounce = _.identity; + _.throttle = _.identity; + let uploadedAttachmentsCount = 1; + window.fetch = async function (route, form) { + const formData = form.body; + return { + async text() { + const ufiles = formData.getAll('ufile'); + const files = ufiles.map(ufile => JSON.stringify({ + filename: ufile.name, + id: uploadedAttachmentsCount, + mimetype: ufile.type, + name: ufile.name, + })); + const callback = formData.get('callback'); + uploadedAttachmentsCount++; + return ` + <script language="javascript" type="text/javascript"> + var win = window.top.window; + win.jQuery(win).trigger('${callback}', ${files.join(', ')}); + </script>`; + } + }; + }; + })(); + + function unpatch() { + _.debounce = originals['_.debounce']; + _.throttle = originals['_.throttle']; + window.fetch = originals['window.fetch']; + } + + Object.assign(self, { data, unpatch }); + + return { + data, + unpatch, + }; +} + +function afterEach(self) { + self.unpatch(); +} + +/** + * Main function used to make a mocked environment with mocked messaging env. + * + * @param {Object} [param0={}] + * @param {string} [param0.arch] makes only sense when `param0.hasView` is set: + * the arch to use in createView. + * @param {Object} [param0.archs] + * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when + * `param0.hasDiscuss` is set: determine whether mounted discuss should be + * open initially. + * @param {boolean} [param0.debug=false] + * @param {Object} [param0.data] makes only sense when `param0.hasView` is set: + * the data to use in createView. + * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss` + * is set: provide data that is passed to discuss widget (= client action) as + * 2nd positional argument. + * @param {Object} [param0.env={}] + * @param {function} [param0.mockRPC] + * @param {boolean} [param0.hasActionManager=false] if set, use + * createActionManager. + * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window + * service. + * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app. + * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging + * menu. + * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time + * with `env.browser.setTimeout` are fully controlled by test itself. + * @see addTimeControlToEnv that adds `advanceTime` function in + * `env.testUtils`. + * @param {boolean} [param0.hasView=false] if set, use createView to create a + * view instead of a generic widget. + * @param {string} [param0.model] makes only sense when `param0.hasView` is set: + * the model to use in createView. + * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set: + * the res_id to use in createView. + * @param {Object} [param0.services] + * @param {Object} [param0.session] + * @param {Object} [param0.View] makes only sense when `param0.hasView` is set: + * the View class to use in createView. + * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView` + * is set: the view options to use in createView. + * @param {boolean} [param0.waitUntilMessagingInitialized=true] + * @param {...Object} [param0.kwargs] + * @returns {Object} + */ +async function start(param0 = {}) { + let callbacks = { + init: [], + mount: [], + destroy: [], + return: [], + }; + const { + hasActionManager = false, + hasChatWindow = false, + hasDialog = false, + hasDiscuss = false, + hasMessagingMenu = false, + hasTimeControl = false, + hasView = false, + waitUntilMessagingInitialized = true, + } = param0; + delete param0.hasActionManager; + delete param0.hasChatWindow; + delete param0.hasDiscuss; + delete param0.hasMessagingMenu; + delete param0.hasTimeControl; + delete param0.hasView; + if (hasChatWindow) { + callbacks = _useChatWindow(callbacks); + } + if (hasDialog) { + callbacks = _useDialog(callbacks); + } + if (hasDiscuss) { + callbacks = _useDiscuss(callbacks); + } + if (hasMessagingMenu) { + callbacks = _useMessagingMenu(callbacks); + } + const { + init: initCallbacks, + mount: mountCallbacks, + destroy: destroyCallbacks, + return: returnCallbacks, + } = callbacks; + const { debug = false } = param0; + initCallbacks.forEach(callback => callback(param0)); + + let env = addMessagingToEnv(param0.env, { debug }); + if (hasTimeControl) { + env = addTimeControlToEnv(env); + } + + const services = Object.assign({}, { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused() { + return true; + }, + updateOption() {}, + }), + chat_window: ChatWindowService.extend({ + _getParentNode() { + return document.querySelector(debug ? 'body' : '#qunit-fixture'); + }, + _listenHomeMenu: () => {}, + }), + dialog: DialogService.extend({ + _getParentNode() { + return document.querySelector(debug ? 'body' : '#qunit-fixture'); + }, + _listenHomeMenu: () => {}, + }), + local_storage: AbstractStorageService.extend({ storage: new RamStorage() }), + notification: NotificationService.extend(), + }, param0.services); + + const kwargs = Object.assign({}, param0, { + archs: Object.assign({}, { + 'mail.message,false,search': '<search/>' + }, param0.archs), + debug: param0.debug || false, + services: Object.assign({}, services, param0.services), + }, { env }); + let widget; + let mockServer; // only in basic mode + let testEnv; + const selector = debug ? 'body' : '#qunit-fixture'; + if (hasView) { + widget = await createView(kwargs); + legacyPatch(widget, { + destroy() { + this._super(...arguments); + destroyCallbacks.forEach(callback => callback({ widget })); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else if (hasActionManager) { + widget = await createActionManager(kwargs); + legacyPatch(widget, { + destroy() { + this._super(...arguments); + destroyCallbacks.forEach(callback => callback({ widget })); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else { + const Parent = Widget.extend({ do_push_state() {} }); + const parent = new Parent(); + mockServer = await addMockEnvironment(parent, kwargs); + widget = new Widget(parent); + await widget.appendTo($(selector)); + Object.assign(widget, { + destroy() { + delete widget.destroy; + destroyCallbacks.forEach(callback => callback({ widget })); + parent.destroy(); + if (testEnv) { + testEnv.destroyMessaging(); + } + }, + }); + } + + testEnv = Component.env; + + /** + * Components cannot use web.bus, because they cannot use + * EventDispatcherMixin, and webclient cannot easily access env. + * Communication between webclient and components by core.bus + * (usable by webclient) and messagingBus (usable by components), which + * the messaging service acts as mediator since it can easily use both + * kinds of buses. + */ + testEnv.bus.on( + 'hide_home_menu', + null, + () => testEnv.messagingBus.trigger('hide_home_menu') + ); + testEnv.bus.on( + 'show_home_menu', + null, + () => testEnv.messagingBus.trigger('show_home_menu') + ); + testEnv.bus.on( + 'will_hide_home_menu', + null, + () => testEnv.messagingBus.trigger('will_hide_home_menu') + ); + testEnv.bus.on( + 'will_show_home_menu', + null, + () => testEnv.messagingBus.trigger('will_show_home_menu') + ); + testEnv.modelManager.start(testEnv); + /** + * Create the messaging singleton record. + */ + testEnv.messaging = testEnv.models['mail.messaging'].create(); + testEnv.messaging.start().then(() => + testEnv.messagingInitializedDeferred.resolve() + ); + + const result = { env: testEnv, mockServer, widget }; + + if (waitUntilMessagingInitialized) { + // env key only accessible after MessagingService has started + await testEnv.messagingInitializedDeferred; + } + + if (mountCallbacks.length > 0) { + await afterNextRender(async () => { + await Promise.all(mountCallbacks.map(callback => callback({ selector, widget }))); + }); + } + returnCallbacks.forEach(callback => callback(result)); + return result; +} + +//------------------------------------------------------------------------------ +// Public: file utilities +//------------------------------------------------------------------------------ + +/** + * Drag some files over a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} file must have been create beforehand + * @see testUtils.file.createFile + */ +function dragenterFiles(el, files) { + const ev = new Event('dragenter', { bubbles: true }); + Object.defineProperty(ev, 'dataTransfer', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +/** + * Drop some files on a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function dropFiles(el, files) { + const ev = new Event('drop', { bubbles: true }); + Object.defineProperty(ev, 'dataTransfer', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +/** + * Set files in a file input + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function inputFiles(el, files) { + const dataTransfer = new window.DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + el.files = dataTransfer.files; + /** + * Changing files programatically is not supposed to trigger the event but + * it does in Chrome versions before 73 (which is on runbot), so in that + * case there is no need to make a manual dispatch, because it would lead to + * the files being added twice. + */ + const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); + const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false; + if (!chromeVersion || chromeVersion >= 73) { + el.dispatchEvent(new Event('change')); + } +} + +/** + * Paste some files on a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function pasteFiles(el, files) { + const ev = new Event('paste', { bubbles: true }); + Object.defineProperty(ev, 'clipboardData', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + afterEach, + afterNextRender, + beforeEach, + dragenterFiles, + dropFiles, + inputFiles, + nextAnimationFrame, + nextTick, + pasteFiles, + start, +}; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js new file mode 100644 index 00000000000..6b9ff00844a --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle.js @@ -0,0 +1,382 @@ +odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +/** + * This module define an utility function that enables throttling calls on a + * provided function. Such throttled calls can be canceled, flushed and/or + * cleared: + * + * - cancel: Canceling a throttle function call means that if a function call is + * pending invocation, cancel removes this pending call invocation. It however + * preserves the internal timer of the cooling down phase of this throttle + * function, meaning that any following throttle function call will be pending + * and has to wait for the remaining time of the cooling down phase before + * being invoked. + * + * - flush: Flushing a throttle function call means that if a function call is + * pending invocation, flush immediately terminates the cooling down phase and + * the pending function call is immediately invoked. Flush also works without + * any pending function call: it just terminates the cooling down phase, so + * that a following function call is guaranteed to be immediately called. + * + * - clear: Clearing a throttle function combines canceling and flushing + * together. + */ + +//------------------------------------------------------------------------------ +// Errors +//------------------------------------------------------------------------------ + +/** + * List of internal and external Throttle errors. + * Internal errors are prefixed with `_`. + */ + + /** + * Error when throttle function has been canceled with `.cancel()`. Used to + * let the caller know of throttle function that the call has been canceled, + * which means the inner function will not be called. Usually caller should + * just accept it and kindly treat this error as a polite warning. + */ +class ThrottleCanceledError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = 'ThrottleCanceledError'; + this.throttleId = throttleId; + } +} +/** + * Error when throttle function has been reinvoked again. Used to let know + * caller of throttle function that the call has been canceled and replaced with + * another one, which means the (potentially) following inner function will be + * in the context of another call. Same as for `ThrottleCanceledError`, usually + * caller should just accept it and kindly treat this error as a polite + * warning. + */ +class ThrottleReinvokedError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = 'ThrottleReinvokedError'; + this.throttleId = throttleId; + } +} +/** + * Error when throttle function has been flushed with `.flush()`. Used + * internally to immediately invoke pending inner functions, since a flush means + * the termination of cooling down phase. + * + * @private + */ +class _ThrottleFlushedError extends Error { + /** + * @override + */ + constructor(throttleId, ...args) { + super(...args); + this.name = '_ThrottleFlushedError'; + this.throttleId = throttleId; + } +} + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * This class models the behaviour of the cancelable, flushable and clearable + * throttle version of a provided function. See definitions at the top of this + * file. + */ +class Throttle { + + /** + * @param {Object} env the OWL env + * @param {function} func provided function for making throttled version. + * @param {integer} duration duration of the 'cool down' phase, i.e. + * the minimum duration between the most recent function call that has + * been made and the following function call (of course, assuming no flush + * in-between). + */ + constructor(env, func, duration) { + /** + * Reference to the OWL envirionment. Useful to fine-tune control of + * time flow in tests. + * @see mail/static/src/utils/test_utils.js:start.hasTimeControl + */ + this.env = env; + /** + * Unique id of this throttle function. Useful for the ThrottleError + * management, in order to determine whether these errors come from + * this throttle or from another one (e.g. inner function makes use of + * another throttle). + */ + this.id = _.uniqueId('throttle_'); + /** + * Deferred of current cooling down phase in progress. Defined only when + * there is a cooling down phase in progress. Resolved when cooling down + * phase terminates from timeout, and rejected if flushed. + * + * @see _ThrottleFlushedError for rejection of this deferred. + */ + this._coolingDownDeferred = undefined; + /** + * Duration, in milliseconds, of the cool down phase. + */ + this._duration = duration; + /** + * Inner function to be invoked and throttled. + */ + this._function = func; + /** + * Determines whether the throttle function is currently in cool down + * phase. Cool down phase happens just after inner function has been + * invoked, and during this time any following function call are pending + * and will be invoked only after the end of the cool down phase (except + * if canceled). + */ + this._isCoolingDown = false; + /** + * Deferred of a currently pending invocation to inner function. Defined + * only during a cooling down phase and just after when throttle + * function has been called during this cooling down phase. It is kept + * until cooling down phase ends (either from timeout or flushed + * throttle) or until throttle is canceled (i.e. removes pending invoke + * while keeping cooling down phase live on). + */ + this._pendingInvokeDeferred = undefined; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Cancel any buffered function call while keeping the cooldown phase + * running. + */ + cancel() { + if (!this._isCoolingDown) { + return; + } + if (!this._pendingInvokeDeferred) { + return; + } + this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id)); + } + + /** + * Clear any buffered function call and immediately terminates any cooling + * down phase in progress. + */ + clear() { + this.cancel(); + this.flush(); + } + + /** + * Called when there is a call to the function. This function is throttled, + * so the time it is called depends on whether the "cooldown stage" occurs + * or not: + * + * - no cooldown stage: function is called immediately, and it starts + * the cooldown stage when successful. + * - in cooldown stage: function is called when the cooldown stage has + * ended from timeout. + * + * Note that after the cooldown stage, only the last attempted function + * call will be considered. + * + * @param {...any} args + * @throws {ThrottleReinvokedError|ThrottleCanceledError} + * @returns {any} result of called function, if it's called. + */ + async do(...args) { + if (!this._isCoolingDown) { + return this._invokeFunction(...args); + } + if (this._pendingInvokeDeferred) { + this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id)); + } + try { + this._pendingInvokeDeferred = makeDeferred(); + await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]); + } catch (error) { + if ( + !(error instanceof _ThrottleFlushedError) || + error.throttleId !== this.id + ) { + throw error; + } + } finally { + this._pendingInvokeDeferred = undefined; + } + return this._invokeFunction(...args); + } + + /** + * Flush the internal throttle timer, so that the following function call + * is immediate. For instance, if there is a cooldown stage, it is aborted. + */ + flush() { + if (!this._isCoolingDown) { + return; + } + const coolingDownDeferred = this._coolingDownDeferred; + this._coolingDownDeferred = undefined; + this._isCoolingDown = false; + coolingDownDeferred.reject(new _ThrottleFlushedError(this.id)); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Invoke the inner function of this throttle and starts cooling down phase + * immediately after. + * + * @private + * @param {...any} args + */ + _invokeFunction(...args) { + const res = this._function(...args); + this._startCoolingDown(); + return res; + } + + /** + * Called just when the inner function is being called. Starts the cooling + * down phase, which turn any call to this throttle function as pending + * inner function calls. This will be called after the end of cooling down + * phase (except if canceled). + */ + async _startCoolingDown() { + if (this._coolingDownDeferred) { + throw new Error("Cannot start cooling down if there's already a cooling down in progress."); + } + // Keep local reference of cooling down deferred, because the one stored + // on `this` could be overwritten by another call to this throttle. + const coolingDownDeferred = makeDeferred(); + this._coolingDownDeferred = coolingDownDeferred; + this._isCoolingDown = true; + const cooldownTimeoutId = this.env.browser.setTimeout( + () => coolingDownDeferred.resolve(), + this._duration + ); + let unexpectedError; + try { + await coolingDownDeferred; + } catch (error) { + if ( + !(error instanceof _ThrottleFlushedError) || + error.throttleId !== this.id + ) { + // This branching should never happen. + // Still defined in case of programming error. + unexpectedError = error; + } + } finally { + this.env.browser.clearTimeout(cooldownTimeoutId); + this._coolingDownDeferred = undefined; + this._isCoolingDown = false; + } + if (unexpectedError) { + throw unexpectedError; + } + } + +} + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +/** + * A function that creates a cancelable, flushable and clearable throttle + * version of a provided function. See definitions at the top of this file. + * + * This throttle mechanism allows calling a function at most once during a + * certain period: + * + * - When a function call is made, it enters a 'cooldown' phase, in which any + * attempt to call the function is buffered until the cooldown phase ends. + * - At most 1 function call can be buffered during the cooldown phase, and the + * latest one in this phase will be considered at its end. + * - When a cooldown phase ends, any buffered function call will be performed + * and another cooldown phase will follow up. + * + * @param {Object} env the OWL env + * @param {function} func the function to throttle. + * @param {integer} duration duration, in milliseconds, of the cooling down + * phase of the throttling. + * @param {Object} [param2={}] + * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller + * of throttle function will observe some errors that come from current + * throttle call that has been canceled, such as when throttle function has + * been explicitly canceled with `.cancel()` or when another new throttle call + * has been registered. + * @see ThrottleCanceledError for when a call has been canceled from explicit + * call. + * @see ThrottleReinvokedError for when a call has been canceled from another + * new throttle call has been registered. + * @returns {function} the cancelable, flushable and clearable throttle version + * of the provided function. + */ +function throttle( + env, + func, + duration, + { silentCancelationErrors = true } = {} +) { + const throttleObj = new Throttle(env, func, duration); + const callable = async (...args) => { + try { + // await is important, otherwise errors are not intercepted. + return await throttleObj.do(...args); + } catch (error) { + const isSelfReinvokedError = ( + error instanceof ThrottleReinvokedError && + error.throttleId === throttleObj.id + ); + const isSelfCanceledError = ( + error instanceof ThrottleCanceledError && + error.throttleId === throttleObj.id + ); + + if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) { + // Silently ignore cancelation errors. + // Promise is indefinitely pending for async functions. + return new Promise(() => {}); + } else { + throw error; + } + } + }; + Object.assign(callable, { + cancel: () => throttleObj.cancel(), + clear: () => throttleObj.clear(), + flush: () => throttleObj.flush(), + }); + return callable; +} + +/** + * Make external throttle errors accessible from throttle function. + */ +Object.assign(throttle, { + ThrottleReinvokedError, + ThrottleCanceledError, +}); + + +return throttle; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js new file mode 100644 index 00000000000..ecd3710d55c --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle_tests.js @@ -0,0 +1,410 @@ +odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); +const throttle = require('mail/static/src/utils/throttle/throttle.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); + +const { ThrottleReinvokedError, ThrottleCanceledError } = throttle; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('throttle', {}, function () { +QUnit.module('throttle_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.throttles = []; + + this.start = async (...args) => { + const { env, widget } = await start(...args); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + this.widget.destroy(); + for (const t of this.throttles) { + t.clear(); + } + }, +}); + +QUnit.test('single call', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasInvokedFunc = false; + const throttledFunc = throttle( + this.env, + () => { + hasInvokedFunc = true; + return 'func_result'; + }, + 0 + ); + this.throttles.push(throttledFunc); + + assert.notOk( + hasInvokedFunc, + "func should not have been invoked on immediate throttle initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasInvokedFunc, + "func should not have been invoked from throttle initialization after 0ms" + ); + + throttledFunc().then(res => { + assert.step('throttle_observed_invoke'); + assert.strictEqual( + res, + 'func_result', + "throttle call return should forward result of inner func" + ); + }); + await nextTick(); + assert.ok( + hasInvokedFunc, + "func should have been immediately invoked on first throttle call" + ); + assert.verifySteps( + ['throttle_observed_invoke'], + "throttle should have observed invoked on first throttle call" + ); +}); + +QUnit.test('2nd (throttled) call', async function (assert) { + assert.expect(8); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + funcCalledAmount++; + return `func_result_${funcCalledAmount}`; + }, + 1000 + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(result => { + assert.step('throttle_observed_invoke_1'); + assert.strictEqual( + result, + 'func_result_1', + "throttle call return should forward result of inner func 1" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc().then(res => { + assert.step('throttle_observed_invoke_2'); + assert.strictEqual( + res, + 'func_result_2', + "throttle call return should forward result of inner func 2" + ); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)" + ); +}); + +QUnit.test('throttled call reinvocation', async function (assert) { + assert.expect(11); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + funcCalledAmount++; + return `func_result_${funcCalledAmount}`; + }, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(result => { + assert.step('throttle_observed_invoke_1'); + assert.strictEqual( + result, + 'func_result_1', + "throttle call return should forward result of inner func 1" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleReinvokedError, + "Should generate a Throttle reinvoked error (from another throttle function call)" + ); + assert.step('throttle_reinvoked_1'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc() + .then(result => { + assert.step('throttle_observed_invoke_2'); + assert.strictEqual( + result, + 'func_result_2', + "throttle call return should forward result of inner func 2" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_reinvoked_1'], + "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)" + ); +}); + +QUnit.test('flush throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_2')); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(10); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.flush(); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_2'], + "inner function of throttle should have been invoked from 2nd call after flush" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + await this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(1); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); +}); + +QUnit.test('cancel throttled call', async function (assert) { + assert.expect(10); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.cancel()`)" + ); + assert.step('throttle_canceled'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.cancel(); + await nextTick(); + assert.verifySteps( + ['throttle_canceled'], + "2nd throttle function call should have been canceled" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + assert.verifySteps( + [], + "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)" + ); +}); + +QUnit.test('clear throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 1000, + { silentCancelationErrors: false } + ); + this.throttles.push(throttledFunc); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + throttledFunc() + .then(() => { + throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.clear()`)" + ); + assert.step('throttle_canceled'); + }); + await nextTick(); + assert.verifySteps( + [], + "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.clear(); + await nextTick(); + assert.verifySteps( + ['throttle_canceled'], + "2nd throttle function call should have been canceled (from `.clear()`)" + ); + + throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js new file mode 100644 index 00000000000..56d7f58e39e --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer.js @@ -0,0 +1,165 @@ +odoo.define('mail/static/src/utils/timer/timer.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +//------------------------------------------------------------------------------ +// Errors +//------------------------------------------------------------------------------ + +/** + * List of Timer errors. + */ + + /** + * Error when timer has been cleared with `.clear()` or `.reset()`. Used to + * let know caller of timer that the countdown has been aborted, which + * means the inner function will not be called. Usually caller should just + * accept it and kindly treated this error as a polite warning. + */ + class TimerClearedError extends Error { + /** + * @override + */ + constructor(timerId, ...args) { + super(...args); + this.name = 'TimerClearedError'; + this.timerId = timerId; + } +} + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * This class creates a timer which, when times out, calls a function. + * Note that the timer is not started on initialization (@see start method). + */ +class Timer { + + /** + * @param {Object} env the OWL env + * @param {function} onTimeout + * @param {integer} duration + * @param {Object} [param3={}] + * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller + * of timer will observe some errors that come from current timer calls + * that has been cleared with `.clear()` or `.reset()`. + * @see TimerClearedError for when timer has been aborted from `.clear()` + * or `.reset()`. + */ + constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) { + this.env = env; + /** + * Determine whether the timer has a pending timeout. + */ + this.isRunning = false; + /** + * Duration, in milliseconds, until timer times out and calls the + * timeout function. + */ + this._duration = duration; + /** + * Determine whether the caller of timer `.start()` and `.reset()` + * should observe cancelation errors from `.clear()` or `.reset()`. + */ + this._hasSilentCancelationErrors = silentCancelationErrors; + /** + * The function that is called when the timer times out. + */ + this._onTimeout = onTimeout; + /** + * Deferred of a currently pending invocation to inner function on + * timeout. + */ + this._timeoutDeferred = undefined; + /** + * Internal reference of `setTimeout()` that is used to invoke function + * when timer times out. Useful to clear it when timer is cleared/reset. + */ + this._timeoutId = undefined; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Clear the timer, which basically sets the state of timer as if it was + * just instantiated, without being started. This function makes sense only + * when this timer is running. + */ + clear() { + this.env.browser.clearTimeout(this._timeoutId); + this.isRunning = false; + if (!this._timeoutDeferred) { + return; + } + this._timeoutDeferred.reject(new TimerClearedError(this.id)); + } + + /** + * Reset the timer, i.e. the pending timeout is refreshed with initial + * duration. This function makes sense only when this timer is running. + */ + async reset() { + this.clear(); + await this.start(); + } + + /** + * Starts the timer, i.e. after a certain duration, it times out and calls + * a function back. This function makes sense only when this timer is not + * yet running. + * + * @throws {Error} in case the timer is already running. + */ + async start() { + if (this.isRunning) { + throw new Error("Cannot start a timer that is currently running."); + } + this.isRunning = true; + const timeoutDeferred = makeDeferred(); + this._timeoutDeferred = timeoutDeferred; + const timeoutId = this.env.browser.setTimeout( + () => { + this.isRunning = false; + timeoutDeferred.resolve(this._onTimeout()); + }, + this._duration + ); + this._timeoutId = timeoutId; + let result; + try { + result = await timeoutDeferred; + } catch (error) { + if ( + !this._hasSilentCancelationErrors || + !(error instanceof TimerClearedError) || + error.timerId !== this.id + ) { + // This branching should never happens. + // Still defined in case of programming error. + throw error; + } + } finally { + this.env.browser.clearTimeout(timeoutId); + this._timeoutDeferred = undefined; + this.isRunning = false; + } + return result; + } + +} + +/** + * Make external timer errors accessible from timer class. + */ +Object.assign(Timer, { + TimerClearedError, +}); + +return Timer; + +}); diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js new file mode 100644 index 00000000000..9b464ac9396 --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer_tests.js @@ -0,0 +1,431 @@ +odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); +const { nextTick } = require('mail/static/src/utils/test_utils.js'); + +const { TimerClearedError } = Timer; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('timer', {}, function () { +QUnit.module('timer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.timers = []; + + this.start = async (...args) => { + const { env, widget } = await start(...args); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + this.widget.destroy(); + for (const timer of this.timers) { + timer.clear(); + } + }, +}); + +QUnit.test('timer does not timeout on initialization', async function (assert) { + assert.expect(3); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + assert.notOk( + hasTimedOut, + "timer should not have timed out on immediate initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out from initialization after 0ms" + ); + + await this.env.testUtils.advanceTime(1000 * 1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out from initialization after 1000s" + ); +}); + +QUnit.test('timer start (duration: 0ms)', async function (assert) { + assert.expect(2); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 0ms" + ); +}); + +QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => { + hasTimedOut = true; + return 'timeout_result'; + }, + 0 + ) + ); + + this.timers[0].start() + .then(result => { + assert.strictEqual( + result, + 'timeout_result', + "value returned by start should be value returned by function on timeout" + ); + assert.step('timeout'); + }); + await nextTick(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + assert.verifySteps( + [], + "timer.start() should not have yet observed timeout" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 0ms" + ); + assert.verifySteps( + ['timeout'], + "timer.start() should have observed timeout after 0ms" + ); +}); + +QUnit.test('timer start (duration: 1000s)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 * 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 0ms" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 1000ms" + ); + + await this.env.testUtils.advanceTime(998 * 1000 + 999); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 9999ms" + ); + + await this.env.testUtils.advanceTime(1); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 10s" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + this.timers[0].clear(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start and clear" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 0ms of clear" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1s of clear" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(999); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after 999ms of start" + ); + + this.timers[0].clear(); + await this.env.testUtils.advanceTime(1); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1001ms after clear (timer fully cleared)" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(999); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 999ms of start" + ); + + this.timers[0].reset(); + await this.env.testUtils.advanceTime(1); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1ms of reset which happens 999ms after start" + ); + + await this.env.testUtils.advanceTime(998); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 999ms of reset" + ); + + await this.env.testUtils.advanceTime(1); + assert.ok( + hasTimedOut, + "timer should not have timed out after 1s of reset" + ); +}); + +QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0, + { silentCancelationErrors: false } + ) + ); + + this.timers[0].start() + .then(() => { + throw new Error("timer.start() should not be resolved (should have been canceled by clear)"); + }) + .catch(error => { + assert.ok( + error instanceof TimerClearedError, + "Should generate a Timer cleared error (from `.clear()`)" + ); + assert.step('timer_cleared'); + }); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + await nextTick(); + assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)"); + + this.timers[0].clear(); + await nextTick(); + assert.verifySteps( + ['timer_cleared'], + "timer.start() should have observed it has been cleared" + ); +}); + +QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0, + { silentCancelationErrors: false } + ) + ); + + this.timers[0].start() + .then(() => { + throw new Error("timer.start() should not observe a timeout"); + }) + .catch(error => { + assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)"); + assert.step('timer_cleared'); + }); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + await nextTick(); + assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)"); + + this.timers[0].reset() + .then(() => assert.step('timer_reset_timeout')); + await nextTick(); + assert.verifySteps( + ['timer_cleared'], + "timer.start() should have observed it has been cleared" + ); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after reset" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out after reset timeout" + ); + assert.verifySteps( + ['timer_reset_timeout'], + "timer.reset() should have observed it has timed out" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js new file mode 100644 index 00000000000..da0855437f7 --- /dev/null +++ b/addons/mail/static/src/utils/utils.js @@ -0,0 +1,127 @@ +odoo.define('mail/static/src/utils/utils.js', function (require) { +'use strict'; + +const { delay } = require('web.concurrency'); +const { + patch: webUtilsPatch, + unpatch: webUtilsUnpatch, +} = require('web.utils'); + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +const classPatchMap = new WeakMap(); + +/** + * Wait a task tick, so that anything in micro-task queue that can be processed + * is processed. + */ +async function nextTick() { + await delay(0); +} + +/** + * Inspired by web.utils:patch utility function + * + * @param {Class} Class + * @param {string} patchName + * @param {Object} patch + * @returns {function} unpatch function + */ +function patchClassMethods(Class, patchName, patch) { + let metadata = classPatchMap.get(Class); + if (!metadata) { + metadata = { + origMethods: {}, + patches: {}, + current: [] + }; + classPatchMap.set(Class, metadata); + } + if (metadata.patches[patchName]) { + throw new Error(`Patch [${patchName}] already exists`); + } + metadata.patches[patchName] = patch; + applyPatch(Class, patch); + metadata.current.push(patchName); + + function applyPatch(Class, patch) { + Object.keys(patch).forEach(function (methodName) { + const method = patch[methodName]; + if (typeof method === "function") { + const original = Class[methodName]; + if (!(methodName in metadata.origMethods)) { + metadata.origMethods[methodName] = original; + } + Class[methodName] = function (...args) { + const previousSuper = this._super; + this._super = original; + const res = method.call(this, ...args); + this._super = previousSuper; + return res; + }; + } + }); + } + + return () => unpatchClassMethods.bind(Class, patchName); +} + +/** + * @param {Class} Class + * @param {string} patchName + * @param {Object} patch + * @returns {function} unpatch function + */ +function patchInstanceMethods(Class, patchName, patch) { + return webUtilsPatch(Class, patchName, patch); +} + +/** + * Inspired by web.utils:unpatch utility function + * + * @param {Class} Class + * @param {string} patchName + */ +function unpatchClassMethods(Class, patchName) { + let metadata = classPatchMap.get(Class); + if (!metadata) { + return; + } + classPatchMap.delete(Class); + + // reset to original + for (let k in metadata.origMethods) { + Class[k] = metadata.origMethods[k]; + } + + // apply other patches + for (let name of metadata.current) { + if (name !== patchName) { + patchClassMethods(Class, name, metadata.patches[name]); + } + } +} + +/** + * @param {Class} Class + * @param {string} patchName + */ +function unpatchInstanceMethods(Class, patchName) { + return webUtilsUnpatch(Class, patchName); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + nextTick, + patchClassMethods, + patchInstanceMethods, + unpatchClassMethods, + unpatchInstanceMethods, +}; + +}); diff --git a/addons/mail/static/src/variables.scss b/addons/mail/static/src/variables.scss new file mode 100644 index 00000000000..8d8caab19d6 --- /dev/null +++ b/addons/mail/static/src/variables.scss @@ -0,0 +1,14 @@ +// ----------------------------------------------------------------------------- +// Variables +// ----------------------------------------------------------------------------- + +$o-mail-thread-window-zindex: $zindex-modal + 1 !default; + +$o-mail-chat-window-header-height: 36px !default; +$o-mail-chat-window-header-height-mobile: 46px !default; +$o-mail-discuss-sidebar-active-indicator-margin-right: 10px !default; +$o-mail-discuss-sidebar-active-indicator-width: 3px !default; +$o-mail-discuss-sidebar-scrollbar-width: 15px !default; + +$o-mail-moderation-accept-color: theme-color('success') !default; +$o-mail-moderation-reject-color: theme-color('danger') !default; diff --git a/addons/mail/static/src/widgets/common.xml b/addons/mail/static/src/widgets/common.xml new file mode 100644 index 00000000000..9106f96f4ea --- /dev/null +++ b/addons/mail/static/src/widgets/common.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + AKU FIXME: use mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js component instead + @param {string} status + --> + <t t-name="mail.widgets.UserStatus"> + <span> + <t t-if="status == 'online'"> + <i class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/> + </t> + <t t-if="status == 'away'"> + <i class="fa fa-circle o_mail_user_status o_user_idle" title="Idle" role="img" aria-label="User is idle"/> + </t> + <t t-if="status == 'offline'"> + <i class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/> + </t> + <t t-if="status == 'bot'"> + <i class="o_mail_user_status o_user_online fa fa-heart" title="Bot" role="img" aria-label="User is a bot"/> + </t> + </span> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/discuss/discuss.js b/addons/mail/static/src/widgets/discuss/discuss.js new file mode 100644 index 00000000000..6f55834895f --- /dev/null +++ b/addons/mail/static/src/widgets/discuss/discuss.js @@ -0,0 +1,383 @@ +odoo.define('mail/static/src/widgets/discuss/discuss.js', function (require) { +'use strict'; + +const components = { + Discuss: require('mail/static/src/components/discuss/discuss.js'), +}; +const InvitePartnerDialog = require('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js'); + +const AbstractAction = require('web.AbstractAction'); +const { action_registry, qweb } = require('web.core'); + +const { Component } = owl; + +const DiscussWidget = AbstractAction.extend({ + template: 'mail.widgets.Discuss', + hasControlPanel: true, + loadControlPanel: true, + withSearchBar: true, + searchMenuTypes: ['filter', 'favorite'], + /** + * @override {web.AbstractAction} + * @param {web.ActionManager} parent + * @param {Object} action + * @param {Object} [action.context] + * @param {string} [action.context.active_id] + * @param {Object} [action.params] + * @param {string} [action.params.default_active_id] + * @param {Object} [options={}] + */ + init(parent, action, options={}) { + this._super(...arguments); + + // render buttons in control panel + this.$buttons = $(qweb.render('mail.widgets.Discuss.DiscussControlButtons')); + this.$buttons.find('button').css({ display: 'inline-block' }); + this.$buttons.on('click', '.o_invite', ev => this._onClickInvite(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonMarkAllRead', + ev => this._onClickMarkAllAsRead(ev) + ); + this.$buttons.on('click', '.o_mobile_new_channel', ev => this._onClickMobileNewChannel(ev)); + this.$buttons.on('click', '.o_mobile_new_message', ev => this._onClickMobileNewMessage(ev)); + this.$buttons.on('click', '.o_unstar_all', ev => this._onClickUnstarAll(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonSelectAll', ev => this._onClickSelectAll(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonUnselectAll', ev => this._onClickUnselectAll(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-accept', ev => this._onClickModerationAccept(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-discard', ev => this._onClickModerationDiscard(ev)); + this.$buttons.on('click', '.o_widget_Discuss_controlPanelButtonModeration.o-reject', ev => this._onClickModerationReject(ev)); + + // control panel attributes + this.action = action; + this.actionManager = parent; + this.controlPanelModelConfig.modelName = 'mail.message'; + this.discuss = undefined; + this.options = options; + + this.component = undefined; + + this._lastPushStateActiveThread = null; + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + this.env = Component.env; + const initActiveId = this.options.active_id || + (this.action.context && this.action.context.active_id) || + (this.action.params && this.action.params.default_active_id) || + 'mail.box_inbox'; + this.discuss = this.env.messaging.discuss; + this.discuss.update({ initActiveId }); + }, + /** + * @override {web.AbstractAction} + */ + destroy() { + if (this.component) { + this.component.destroy(); + this.component = undefined; + } + if (this.$buttons) { + this.$buttons.off().remove(); + } + this._super(...arguments); + }, + /** + * @override {web.AbstractAction} + */ + on_attach_callback() { + this._super(...arguments); + if (this.component) { + // prevent twice call to on_attach_callback (FIXME) + return; + } + const DiscussComponent = components.Discuss; + this.component = new DiscussComponent(); + this._pushStateActionManagerEventListener = ev => { + ev.stopPropagation(); + if (this._lastPushStateActiveThread === this.discuss.thread) { + return; + } + this._pushStateActionManager(); + this._lastPushStateActiveThread = this.discuss.thread; + }; + this._showRainbowManEventListener = ev => { + ev.stopPropagation(); + this._showRainbowMan(); + }; + this._updateControlPanelEventListener = ev => { + ev.stopPropagation(); + this._updateControlPanel(); + }; + + this.el.addEventListener( + 'o-push-state-action-manager', + this._pushStateActionManagerEventListener + ); + this.el.addEventListener( + 'o-show-rainbow-man', + this._showRainbowManEventListener + ); + this.el.addEventListener( + 'o-update-control-panel', + this._updateControlPanelEventListener + ); + return this.component.mount(this.el); + }, + /** + * @override {web.AbstractAction} + */ + on_detach_callback() { + this._super(...arguments); + if (this.component) { + this.component.destroy(); + } + this.component = undefined; + this.el.removeEventListener( + 'o-push-state-action-manager', + this._pushStateActionManagerEventListener + ); + this.el.removeEventListener( + 'o-show-rainbow-man', + this._showRainbowManEventListener + ); + this.el.removeEventListener( + 'o-update-control-panel', + this._updateControlPanelEventListener + ); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _pushStateActionManager() { + this.actionManager.do_push_state({ + action: this.action.id, + active_id: this.discuss.activeId, + }); + }, + /** + * @private + */ + _showRainbowMan() { + this.trigger_up('show_effect', { + message: this.env._t("Congratulations, your inbox is empty!"), + type: 'rainbow_man', + }); + }, + /** + * @private + */ + _updateControlPanel() { + // Invite + if ( + this.discuss.thread && + this.discuss.thread.channel_type === 'channel' + ) { + this.$buttons.find('.o_invite').removeClass('o_hidden'); + } else { + this.$buttons.find('.o_invite').addClass('o_hidden'); + } + // Mark All Read + if ( + this.discuss.thread && + this.discuss.thread === this.env.messaging.inbox + ) { + this.$buttons + .find('.o_widget_Discuss_controlPanelButtonMarkAllRead') + .removeClass('o_hidden') + .prop('disabled', this.discuss.threadViewer.messages.length === 0); + } else { + this.$buttons + .find('.o_widget_Discuss_controlPanelButtonMarkAllRead') + .addClass('o_hidden'); + } + // Unstar All + if ( + this.discuss.thread && + this.discuss.thread === this.env.messaging.starred + ) { + this.$buttons + .find('.o_unstar_all') + .removeClass('o_hidden') + .prop('disabled', this.discuss.threadViewer.messages.length === 0); + } else { + this.$buttons + .find('.o_unstar_all') + .addClass('o_hidden'); + } + // Mobile: Add channel + if ( + this.env.messaging.device.isMobile && + this.discuss.activeMobileNavbarTabId === 'channel' + ) { + this.$buttons + .find('.o_mobile_new_channel') + .removeClass('o_hidden'); + } else { + this.$buttons + .find('.o_mobile_new_channel') + .addClass('o_hidden'); + } + // Mobile: Add message + if ( + this.env.messaging.device.isMobile && + this.discuss.activeMobileNavbarTabId === 'chat' + ) { + this.$buttons + .find('.o_mobile_new_message') + .removeClass('o_hidden'); + } else { + this.$buttons + .find('.o_mobile_new_message') + .addClass('o_hidden'); + } + // Select All & Unselect All + const $selectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonSelectAll'); + const $unselectAll = this.$buttons.find('.o_widget_Discuss_controlPanelButtonUnselectAll'); + + if ( + this.discuss.threadViewer.checkedMessages.length > 0 || + this.discuss.threadViewer.uncheckedMessages.length > 0 + ) { + $selectAll.removeClass('o_hidden'); + $selectAll.toggleClass('disabled', this.discuss.threadViewer.uncheckedMessages.length === 0); + $unselectAll.removeClass('o_hidden'); + $unselectAll.toggleClass('disabled', this.discuss.threadViewer.checkedMessages.length === 0); + } else { + $selectAll.addClass('o_hidden'); + $selectAll.addClass('disabled'); + $unselectAll.addClass('o_hidden'); + $unselectAll.addClass('disabled'); + } + + // Moderation Actions + const $moderationButtons = this.$buttons.find('.o_widget_Discuss_controlPanelButtonModeration'); + if ( + this.discuss.threadViewer.checkedMessages.length > 0 && + this.discuss.threadViewer.checkedMessages.filter( + message => !message.isModeratedByCurrentPartner + ).length === 0 + ) { + $moderationButtons.removeClass('o_hidden'); + } else { + $moderationButtons.addClass('o_hidden'); + } + + let title; + if (this.env.messaging.device.isMobile || !this.discuss.thread) { + title = this.env._t("Discuss"); + } else { + const prefix = + this.discuss.thread.channel_type === 'channel' && + this.discuss.thread.public !== 'private' + ? '#' + : ''; + title = `${prefix}${this.discuss.thread.displayName}`; + } + + this.updateControlPanel({ + cp_content: { + $buttons: this.$buttons, + }, + title, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClickInvite() { + new InvitePartnerDialog(this, { + activeThreadLocalId: this.discuss.thread.localId, + messagingEnv: this.env, + }).open(); + }, + /** + * @private + */ + _onClickMarkAllAsRead() { + this.env.models['mail.message'].markAllAsRead({ domain: this.domain }); + }, + /** + * @private + */ + _onClickMobileNewChannel() { + this.discuss.update({ isAddingChannel: true }); + }, + /** + * @private + */ + _onClickMobileNewMessage() { + this.discuss.update({ isAddingChat: true }); + }, + /** + * @private + */ + _onClickModerationAccept() { + this.env.models['mail.message'].moderate( + this.discuss.threadViewer.checkedMessages, + 'accept' + ); + }, + /** + * @private + */ + _onClickModerationDiscard() { + this.discuss.update({ hasModerationDiscardDialog: true }); + }, + /** + * @private + */ + _onClickModerationReject() { + this.discuss.update({ hasModerationRejectDialog: true }); + }, + /** + * @private + */ + _onClickSelectAll() { + this.env.models['mail.message'].checkAll( + this.discuss.thread, + this.discuss.threadStringifiedDomain + ); + }, + /** + * @private + */ + _onClickUnselectAll() { + this.env.models['mail.message'].uncheckAll( + this.discuss.thread, + this.discuss.threadStringifiedDomain + ); + }, + /** + * @private + */ + _onClickUnstarAll() { + this.env.models['mail.message'].unstarAll(); + }, + /** + * @private + * @param {Object} searchQuery + */ + _onSearch: function (searchQuery) { + this.discuss.threadViewer.update({ + stringifiedDomain: JSON.stringify(searchQuery.domain), + }); + }, +}); + +action_registry.add('mail.widgets.discuss', DiscussWidget); + +return DiscussWidget; + +}); diff --git a/addons/mail/static/src/widgets/discuss/discuss.scss b/addons/mail/static/src/widgets/discuss/discuss.scss new file mode 100644 index 00000000000..2bed9b7c373 --- /dev/null +++ b/addons/mail/static/src/widgets/discuss/discuss.scss @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_widget_Discuss { + display: flex; + flex: 0 0 100%; + flex-flow: column; + height: 100%; +} + +// ------------------------------------------------------------------ +// Style +// ------------------------------------------------------------------ + +.o_widget_Discuss { + + .o_control_panel { + border-bottom: 0; // cancel default border, so that we only apply it on top of discuss content + } + + .o_Discuss_content { + border-top: 1px solid darken($o-control-panel-background-color, 20%); + } + + .o_Discuss.o-mobile { + + &:not(.o-adding-item) { + border-top: 1px solid darken($o-control-panel-background-color, 20%); + } + + &.o-adding-item .o_Discuss_mobileAddItemHeader { + border-bottom: 1px solid darken($o-control-panel-background-color, 20%); + } + } +} diff --git a/addons/mail/static/src/widgets/discuss/discuss.xml b/addons/mail/static/src/widgets/discuss/discuss.xml new file mode 100644 index 00000000000..f8e6de37b8e --- /dev/null +++ b/addons/mail/static/src/widgets/discuss/discuss.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + @param {mail/static/src/widgets/discuss/discuss.js} widget + --> + <t t-name="mail.widgets.Discuss"> + <div class="o_widget_Discuss"/> + </t> + + <!-- @param {boolean} isMobile --> + <t t-name="mail.widgets.Discuss.DiscussControlButtons"> + <div> + <button class="o_widget_Discuss_controlPanelButtonInvite o_invite o_hidden btn btn-primary" type="button" title="Invite people">Invite</button> + <button class="o_widget_Discuss_controlPanelButtonMarkAllRead o_hidden btn btn-secondary" type="button" title="Mark all as read">Mark all read</button> + <button class="o_widget_Discuss_controlPanelButtonUnstarAll o_unstar_all o_hidden btn btn-secondary" type="button" title="Unstar all messages">Unstar all</button> + <button class="o_widget_Discuss_controlPanelButtonMobileNewMessage o_mobile_new_message o_hidden btn btn-secondary" type="button" title="New Message">New Message</button> + <button class="o_widget_Discuss_controlPanelButtonMobileNewChannel o_mobile_new_channel o_hidden btn btn-secondary" title="New Channel" type="button">New Channel</button> + <button class="o_widget_Discuss_controlPanelButtonSelectAll btn btn-secondary o_hidden" title="Select all messages">Select All</button> + <button class="o_widget_Discuss_controlPanelButtonUnselectAll btn btn-secondary o_hidden" title="Unselect all messages">Unselect All</button> + <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-accept" title="Accept selected messages">Accept</button> + <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-reject" title="Reject selected messages">Reject</button> + <button class="o_widget_Discuss_controlPanelButtonModeration btn btn-secondary o_hidden o-discard" title="Discard selected messages">Discard</button> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js new file mode 100644 index 00000000000..e94d58e752c --- /dev/null +++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js @@ -0,0 +1,125 @@ +odoo.define('mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js', function (require) { +'use strict'; + +const core = require('web.core'); +const Dialog = require('web.Dialog'); + +const _lt = core._lt; +const QWeb = core.qweb; + +/** + * Widget : Invite People to Channel Dialog + * + * Popup containing a 'many2many_tags' custom input to select multiple partners. + * Searches user according to the input, and triggers event when selection is + * validated. + */ +const PartnerInviteDialog = Dialog.extend({ + dialog_title: _lt("Invite people"), + template: 'mail.widgets.DiscussInvitePartnerDialog', + /** + * @override {web.Dialog} + * @param {mail/static/src/widgets/discuss/discuss.js} parent + * @param {Object} param1 + * @param {string} param1.activeThreadLocalId + * @param {Object} param1.messagingEnv + * @param {Object} param1.messagingEnv.store + */ + init(parent, { activeThreadLocalId, messagingEnv }) { + const env = messagingEnv; + const channel = env.models['mail.thread'].get(activeThreadLocalId); + this.channelId = channel.id; + this.env = env; + this._super(parent, { + title: _.str.sprintf(this.env._t("Invite people to #%s"), channel.displayName), + size: 'medium', + buttons: [{ + text: this.env._t("Invite"), + close: true, + classes: 'btn-primary', + click: ev => this._invite(ev), + }], + }); + }, + /** + * @override {web.Dialog} + * @returns {Promise} + */ + start() { + this.$input = this.$('.o_input'); + this.$input.select2({ + width: '100%', + allowClear: true, + multiple: true, + formatResult: item => { + let status; + // TODO FIXME fix this, why do we even have an old widget here + if (item.id === 'odoobot') { + status = 'bot'; + } else { + const partner = this.env.models['mail.partner'].find(partner => + partner.id === item.id + ); + status = partner.im_status; + } + const $status = QWeb.render('mail.widgets.UserStatus', { status }); + return $('<span>').text(item.text).prepend($status); + }, + query: query => { + this.env.models['mail.partner'].imSearch({ + callback: partners => { + let results = partners.map(partner => { + return { + id: partner.id, + label: partner.nameOrDisplayName, + text: partner.nameOrDisplayName, + value: partner.nameOrDisplayName, + }; + }); + results = _.sortBy(results, 'label'); + query.callback({ results }); + }, + keyword: query.term, + limit: 20, + }); + } + }); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + async _invite() { + const data = this.$input.select2('data'); + if (data.length === 0) { + return; + } + await this._rpc({ + model: 'mail.channel', + method: 'channel_invite', + args: [this.channelId], + kwargs: { + partner_ids: _.pluck(data, 'id') + }, + }); + const names = _.escape(_.pluck(data, 'text').join(', ')); + const notification = _.str.sprintf( + this.env._t("You added <b>%s</b> to the conversation."), + names + ); + this.env.services['notification'].notify({ + message: notification, + title: this.env._t("New people"), + type: 'warning', + }); + }, +}); + +return PartnerInviteDialog; + +}); diff --git a/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml new file mode 100644 index 00000000000..82553476e21 --- /dev/null +++ b/addons/mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <!-- + @param {mail/static/src/widgets/discuss_invite_partner_dialog/discuss_invite_partner_dialog.js} widget + --> + <t t-name="mail.widgets.DiscussInvitePartnerDialog"> + <div> + <input class="o_dialog o_input o_invite_partner" type="text"/> + </div> + </t> + +</templates> diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.js b/addons/mail/static/src/widgets/form_renderer/form_renderer.js new file mode 100644 index 00000000000..df9c8b90d25 --- /dev/null +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.js @@ -0,0 +1,199 @@ +odoo.define('mail/static/src/widgets/form_renderer/form_renderer.js', function (require) { +"use strict"; + +const components = { + ChatterContainer: require('mail/static/src/components/chatter_container/chatter_container.js'), +}; + +const FormRenderer = require('web.FormRenderer'); +const { ComponentWrapper } = require('web.OwlCompatibility'); + +class ChatterContainerWrapperComponent extends ComponentWrapper {} + +/** + * Include the FormRenderer to instantiate the chatter area containing (a + * subset of) the mail widgets (mail_thread, mail_followers and mail_activity). + */ +FormRenderer.include({ + /** + * @override + */ + init(parent, state, params) { + this._super(...arguments); + this.chatterFields = params.chatterFields; + this.mailFields = params.mailFields; + this._chatterContainerComponent = undefined; + /** + * The target of chatter, if chatter has to be appended to the DOM. + * This is set when arch contains `div.oe_chatter`. + */ + this._chatterContainerTarget = undefined; + // Do not load chatter in form view dialogs + this._isFromFormViewDialog = params.isFromFormViewDialog; + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this._chatterContainerComponent = undefined; + this.off('o_attachments_changed', this); + this.off('o_chatter_rendered', this); + this.off('o_message_posted', this); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns whether the form renderer has a chatter to display or not. + * This is based on arch, which should have `div.oe_chatter`. + * + * @private + * @returns {boolean} + */ + _hasChatter() { + return !!this._chatterContainerTarget; + }, + /** + * @private + */ + _makeChatterContainerComponent() { + const props = this._makeChatterContainerProps(); + this._chatterContainerComponent = new ChatterContainerWrapperComponent( + this, + components.ChatterContainer, + props + ); + // Not in custom_events because other modules may remove this listener + // while attempting to extend them. + this.on('o_chatter_rendered', this, ev => this._onChatterRendered(ev)); + if (this.chatterFields.hasRecordReloadOnMessagePosted) { + this.on('o_message_posted', this, ev => this.trigger_up('reload')); + } + if (this.chatterFields.hasRecordReloadOnAttachmentsChanged) { + this.on('o_attachments_changed', this, ev => this.trigger_up('reload')); + } + }, + /** + * @private + * @returns {Object} + */ + _makeChatterContainerProps() { + const context = this.record ? this.record.getContext() : {}; + const activityIds = this.state.data.activity_ids + ? this.state.data.activity_ids.res_ids + : []; + const followerIds = this.state.data.message_follower_ids + ? this.state.data.message_follower_ids.res_ids + : []; + const messageIds = this.state.data.message_ids + ? this.state.data.message_ids.res_ids + : []; + const threadAttachmentCount = this.state.data.message_attachment_count || 0; + return { + activityIds, + context, + followerIds, + hasActivities: this.chatterFields.hasActivityIds, + hasFollowers: this.chatterFields.hasMessageFollowerIds, + hasThread: this.chatterFields.hasMessageIds, + isAttachmentBoxVisible: this.chatterFields.isActivityBoxVisible, + messageIds, + threadAttachmentCount, + threadId: this.state.res_id, + threadModel: this.state.model, + }; + }, + /** + * Create the DOM element that will contain the chatter. This is made in + * a separate method so it can be overridden (like in mail_enterprise for + * example). + * + * @private + * @returns {jQuery.Element} + */ + _makeChatterContainerTarget() { + const $el = $('<div class="o_FormRenderer_chatterContainer"/>'); + this._chatterContainerTarget = $el[0]; + return $el; + }, + /** + * Mount the chatter + * + * Force re-mounting chatter component in DOM. This is necessary + * because each time `_renderView` is called, it puts old content + * in a fragment. + * + * @private + */ + async _mountChatterContainerComponent() { + try { + await this._chatterContainerComponent.mount(this._chatterContainerTarget); + } catch (error) { + if (error.message !== "Mounting operation cancelled") { + throw error; + } + } + }, + /** + * @override + */ + _renderNode(node) { + if ( + !this._isFromFormViewDialog && + node.tag === 'div' && + node.attrs.class === 'oe_chatter' + ) { + return this._makeChatterContainerTarget(); + } + return this._super(...arguments); + }, + /** + * Overrides the function to render the chatter once the form view is + * rendered. + * + * @override + */ + async _renderView() { + await this._super(...arguments); + if (this._hasChatter()) { + if (!this._chatterContainerComponent) { + this._makeChatterContainerComponent(); + } else { + await this._updateChatterContainerComponent(); + } + await this._mountChatterContainerComponent(); + } + }, + /** + * @private + */ + async _updateChatterContainerComponent() { + const props = this._makeChatterContainerProps(); + try { + await this._chatterContainerComponent.update(props); + } catch (error) { + if (error.message !== "Mounting operation cancelled") { + throw error; + } + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @abstract + * @private + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {mail.attachment[]} ev.data.attachments + * @param {mail.thread} ev.data.thread + */ + _onChatterRendered(ev) {}, +}); + +}); diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer.scss b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss new file mode 100644 index 00000000000..3092055b645 --- /dev/null +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer.scss @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------ +// Layout +// ------------------------------------------------------------------ + +.o_FormRenderer_chatterContainer { + display: flex; + flex: 1 1 auto; + margin: 0 auto; + max-width: $o-form-view-sheet-max-width; + padding: map-get($spacers, 3) map-get($spacers, 3) map-get($spacers, 5); + width: 100%; +} + +// FIX to hide chatter in dialogs when they are opened from an action returned by python code +.modal .modal-dialog .o_form_view .o_FormRenderer_chatterContainer { + display: none; +} diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js new file mode 100644 index 00000000000..7c3ecea8ab9 --- /dev/null +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js @@ -0,0 +1,455 @@ +odoo.define('mail/static/src/widgets/form_renderer/form_renderer_tests.js', function (require) { +"use strict"; + +const { + afterEach: utilsAfterEach, + afterNextRender, + beforeEach: utilsBeforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); + +const FormView = require('web.FormView'); + +QUnit.module('mail', {}, function () { +QUnit.module('widgets', {}, function () { +QUnit.module('form_renderer', {}, function () { +QUnit.module('form_renderer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.data['res.partner'].fields = { + activity_ids: { + string: "Activities", + type: 'one2many', + relation: 'mail.activity', + }, + message_attachment_count: { + string: 'Attachment count', + type: 'integer', + }, + message_follower_ids: { + string: "Followers", + type: 'one2many', + relation: 'mail.followers', + }, + message_ids: { + string: "Messages", + type: 'one2many', + relation: 'mail.message', + }, + name: { + string: "Name", + type: 'char', + }, + }; + this.underscoreDebounce = _.debounce; + this.underscoreThrottle = _.throttle; + _.debounce = _.identity; + _.throttle = _.identity; + // FIXME archs could be removed once task-2248306 is done + this.createView = async (viewParams, ...args) => { + await afterNextRender(async () => { + const viewArgs = Object.assign({ + archs: { + 'mail.activity,false,list': '<tree/>', + 'mail.followers,false,list': '<tree/>', + 'mail.message,false,list': '<tree/>', + }}, + viewParams, + ); + const { widget } = await start(viewArgs, ...args); + this.view = widget; + }); + }; + }, + afterEach() { + _.debounce = this.underscoreDebounce; + _.throttle = this.underscoreThrottle; + if (this.view) { + this.view.destroy(); + } + utilsAfterEach(this); + }, +}); + +QUnit.test('basic chatter rendering', async function (assert) { + assert.expect(1); + + this.data['res.partner'].records = [{ + id: 2, + display_name: "second partner", + }]; + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"></div> + </form> + `, + res_id: 2, + }); + + assert.strictEqual( + document.querySelectorAll(`.o_Chatter`).length, + 1, + "there should be a chatter" + ); +}); + +QUnit.test('basic chatter rendering without followers', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records = [{ + activity_ids: [], + id: 2, + display_name: "second partner", + message_ids: [], + message_follower_ids: [], + }]; + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="activity_ids"/> + <field name="message_ids"/> + </div> + </form> + `, + res_id: 2, + }); + + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar', + "there should be a chatter topbar" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonAttachments', + "there should be an attachment button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonScheduleActivity', + "there should be a schedule activity button" + ); + assert.containsNone( + document.body, + '.o_FollowerListMenu', + "there should be no followers menu" + ); + assert.containsOnce( + document.body, + '.o_Chatter_thread', + "there should be a thread" + ); +}); + +QUnit.test('basic chatter rendering without activities', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records = [{ + activity_ids: [], + id: 2, + display_name: "second partner", + message_ids: [], + message_follower_ids: [], + }]; + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids"/> + <field name="message_ids"/> + </div> + </form> + `, + res_id: 2, + }); + + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar', + "there should be a chatter topbar" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonAttachments', + "there should be an attachment button" + ); + assert.containsNone( + document.body, + '.o_ChatterTopbar_buttonScheduleActivity', + "there should be a schedule activity button" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "there should be a followers menu" + ); + assert.containsOnce( + document.body, + '.o_Chatter_thread', + "there should be a thread" + ); +}); + +QUnit.test('basic chatter rendering without messages', async function (assert) { + assert.expect(6); + + this.data['res.partner'].records = [{ + activity_ids: [], + id: 2, + display_name: "second partner", + message_ids: [], + message_follower_ids: [], + }]; + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids"/> + <field name="activity_ids"/> + </div> + </form> + `, + res_id: 2, + }); + + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar', + "there should be a chatter topbar" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonAttachments', + "there should be an attachment button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonScheduleActivity', + "there should be a schedule activity button" + ); + assert.containsOnce( + document.body, + '.o_FollowerListMenu', + "there should be a followers menu" + ); + assert.containsNone( + document.body, + '.o_Chatter_thread', + "there should be a thread" + ); +}); + +QUnit.test('chatter updating', async function (assert) { + assert.expect(3); + + this.data['ir.attachment'].records = [{ + id: 1, + mimetype: 'image/png', + name: 'filename.jpg', + res_id: 7, + res_model: 'partner', + type: 'url', + }, { + id: 2, + mimetype: "application/x-msdos-program", + name: "file2.txt", + res_id: 7, + res_model: 'partner', + type: 'binary', + }, { + id: 3, + mimetype: "application/x-msdos-program", + name: "file3.txt", + res_id: 5, + res_model: 'partner', + type: 'binary', + }]; + this.data['mail.message'].records = [{ + id: 1000, + body: "<p>test 1</p>", + author_id: [100, "Someone"], + model: 'res.partner', + res_id: 2, + moderation_status: 'accepted', + }]; + this.data['res.partner'].records = [{ + id: 1, + display_name: "first partner", + message_ids: [], + }, { + id: 2, + display_name: "second partner", + message_ids: [], + }]; + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + res_id: 1, + viewOptions: { + ids: [1, 2], + index: 0, + }, + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsNone( + document.body, + '.o_Message', + "there should be no message" + ); + + await afterNextRender(() => { + document.querySelector('.o_pager_next').click(); + }); + assert.containsOnce( + document.body, + '.o_Message', + "there should be a message" + ); +}); + +QUnit.test('chatter should become enabled when creation done', async function (assert) { + assert.expect(10); + + await this.createView({ + data: this.data, + hasView: true, + // View params + View: FormView, + model: 'res.partner', + arch: ` + <form string="Partners"> + <sheet> + <field name="name"/> + </sheet> + <div class="oe_chatter"> + <field name="message_ids"/> + </div> + </form> + `, + viewOptions: { + mode: 'edit', + }, + }); + assert.containsOnce( + document.body, + '.o_Chatter', + "there should be a chatter" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonSendMessage', + "there should be a send message button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonLogNote', + "there should be a log note button" + ); + assert.containsOnce( + document.body, + '.o_ChatterTopbar_buttonLogNote', + "there should be an attachments button" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should be disabled" + ); + assert.ok( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should be disabled" + ); + + document.querySelectorAll('.o_field_char')[0].focus(); + document.execCommand('insertText', false, "hello"); + await afterNextRender(() => { + document.querySelector('.o_form_button_save').click(); + }); + assert.notOk( + document.querySelector(`.o_ChatterTopbar_buttonSendMessage`).disabled, + "send message button should now be enabled" + ); + assert.notOk( + document.querySelector(`.o_ChatterTopbar_buttonLogNote`).disabled, + "log note button should now be enabled" + ); + assert.notOk( + document.querySelector(`.o_ChatterTopbar_buttonAttachments`).disabled, + "attachments button should now be enabled" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js new file mode 100644 index 00000000000..edfef630605 --- /dev/null +++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.js @@ -0,0 +1,56 @@ +odoo.define('mail/static/src/widgets/messaging_menu/messaging_menu.js', function (require) { +'use strict'; + +const components = { + MessagingMenu: require('mail/static/src/components/messaging_menu/messaging_menu.js'), +}; + +const SystrayMenu = require('web.SystrayMenu'); +const Widget = require('web.Widget'); + +/** + * Odoo Widget, necessary to instantiate component. + */ +const MessagingMenu = Widget.extend({ + template: 'mail.widgets.MessagingMenu', + /** + * @override + */ + init() { + this._super(...arguments); + this.component = undefined; + }, + /** + * @override + */ + destroy() { + if (this.component) { + this.component.destroy(); + } + this._super(...arguments); + }, + async on_attach_callback() { + const MessagingMenuComponent = components.MessagingMenu; + this.component = new MessagingMenuComponent(null); + await this.component.mount(this.el); + // unwrap + this.el.parentNode.insertBefore(this.component.el, this.el); + this.el.parentNode.removeChild(this.el); + }, +}); + +// Systray menu items display order matches order in the list +// lower index comes first, and display is from right to left. +// For messagin menu, it should come before activity menu, if any +// otherwise, it is the next systray item. +const activityMenuIndex = SystrayMenu.Items.findIndex(SystrayMenuItem => + SystrayMenuItem.prototype.name === 'activity_menu'); +if (activityMenuIndex > 0) { + SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu); +} else { + SystrayMenu.Items.push(MessagingMenu); +} + +return MessagingMenu; + +}); diff --git a/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml new file mode 100644 index 00000000000..308c1f31134 --- /dev/null +++ b/addons/mail/static/src/widgets/messaging_menu/messaging_menu.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <t t-name="mail.widgets.MessagingMenu"> + <li class="o_widget_SystrayMessagingItem"/> + </t> + +</templates> diff --git a/addons/mail/static/src/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml deleted file mode 100644 index ae29a21b5ba..00000000000 --- a/addons/mail/static/src/xml/abstract_thread_window.xml +++ /dev/null @@ -1,85 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates xml:space="preserve"> - - <!-- - @param {mail.AbstractThreadWindow} widget - --> - <t t-name="mail.AbstractThreadWindow"> - <div class="o_thread_window o_in_home_menu" t-att-data-thread-id="widget.getID()"> - <div class="o_thread_window_header"> - <t t-call="mail.AbstractThreadWindow.HeaderContent"> - <t t-set="status" t-value="widget.getThreadStatus()"/> - <t t-set="title" t-value="widget.getTitle()"/> - <t t-set="unreadCounter" t-value="widget.getUnreadCounter()"/> - <t t-set="thread" t-value="widget.getThread()"/> - </t> - </div> - <div class="o_thread_window_content"> - </div> - <div t-if="widget.needsComposer()" class="o_thread_composer o_chat_mini_composer"> - <input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/> - </div> - </div> - </t> - - <!-- - @param {boolean} [open=false] tell whether the hidden thread window - dropdown menu is open or not. - @param {mail.ThreadWindow[]} threadWindows list of thread windows - that are hidden, i.e. put in the hidden window dropdown menu. - @param {integer} [unreadCounter] the total unread counter of all - threads which are linked to the thread windows that are put in the - hidden thread window dropdown menu. - --> - <t t-name="mail.HiddenThreadWindowsDropdown"> - <div t-attf-class="o_thread_window o_in_home_menu o_thread_window_dropdown dropup #{open ? 'open' : ''}"> - <span role="alert" class="o_thread_window_dropdown_toggler dropdown-toggle" data-toggle="dropdown" title="Unread messages"> - <i class="fa fa-comments-o" role="img" aria-label="Messages"/> <t t-esc="threadWindows.length"/> - <span class="o_total_unread_counter"> - <t t-if="unreadCounter"><t t-esc="unreadCounter"/></t> - </span> - </span> - <ul class="dropdown-menu" role="menu"> - <t t-foreach="threadWindows" t-as="threadWindow"> - <li role="menuitem" class="o_thread_window_header" t-att-data-thread-id="threadWindow.getID()"> - <t t-call="mail.AbstractThreadWindow.HeaderContent"> - <t t-set="status" t-value="threadWindow.getThreadStatus()"/> - <t t-set="title" t-value="threadWindow.getTitle()"/> - <t t-set="unreadCounter" t-value="threadWindow.getUnreadCounter()"/> - </t> - </li> - </t> - </ul> - </div> - </t> - - <!-- - @param {string} [status] e.g. 'online', 'offline'. - @param {string} title the title of the thread window, e.g. the record - name of the document. - @param {integer} [unreadCounter] the number of unread messages on the - thread. - @param {mail.model.Thread|undefined} thread - @param {Object} widget - @param {function} widget.isMobile function without any param that - states whether it should render for desktop or mobile screen. - --> - <t t-name="mail.AbstractThreadWindow.HeaderContent"> - <span t-if="widget.isMobile()"> - <a href="#" class="o_thread_window_close fa fa-1x fa-arrow-left" aria-label="Close chat window" title="Close chat window"/> - </span> - <div t-if="thread and thread.getType() === 'dm_chat'" class="o_thread_window_avatar"> - <img alt="" t-att-src="'/web/image/res.partner/' + thread.getDirectPartnerID() + '/image_128'"/> - <t t-call="mail.UserStatus"/> - </div> - <span class="o_thread_window_title"> - <t t-esc="title"/> - <span t-if="unreadCounter"> (<t t-esc="unreadCounter"/>)</span> - <t t-if="thread and thread.hasTypingNotification() and thread.isSomeoneTyping()" t-call="mail.ThreadTypingIcon"/> - </span> - <span t-if="!widget.isMobile()" class="o_thread_window_buttons"> - <a href="#" class="o_thread_window_close fa fa-close"/> - </span> - </t> - -</templates> diff --git a/addons/mail/static/src/xml/chatter.xml b/addons/mail/static/src/xml/chatter.xml deleted file mode 100644 index 1f36287cdf8..00000000000 --- a/addons/mail/static/src/xml/chatter.xml +++ /dev/null @@ -1,152 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates> - - <!-- - Chatter attachment tab - --> - - <div t-name="mail.chatter.AttachmentBox" class="o_mail_chatter_attachments"> - <div class="o_chatter_attachment"> - <div class="o_border_dashed"> - <span class="o_attach_title">Attachments</span> - </div> - <div t-if="widget.imageList.length > 0" class="o_attachments_previews"> - <t t-foreach="widget.imageList" t-as="attachment"> - <t t-call="mail.AttachmentPreview"> - <t t-set="isDeletable" t-value="true"/> - </t> - </t> - </div> - <div t-if="widget.otherList.length > 0" class="o_attachments_list"> - <t t-foreach="widget.otherList" t-as="attachment"> - <t t-call="mail.Attachment"> - <t t-set="isDeletable" t-value="true"/> - </t> - </t> - </div> - <div class="o_chatter_attachment_form"> - <t t-call="HiddenInputFile"> - <t t-set="fileupload_id" t-value="widget.fileuploadId"/> - <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t> - <t t-set="multi_upload" t-value="true"/> - <input type="hidden" name="model" t-att-value="widget.currentResModel"/> - <input type="hidden" name="id" t-att-value="widget.currentResID"/> - <center><span class="btn btn-link o_upload_attachments_button"><span class="fa fa-plus-square"/> Add Attachments</span></center> - </t> - </div> - </div> - </div> - - <!-- - Chatter Suggested Partners - --> - <t t-name="mail.chatter.ComposerSuggestedPartners"> - <!-- List of followers --> - <div class="o_composer_suggested_partners"> - <t t-foreach='widget.suggestedPartners' t-as='recipient'> - <div t-attf-title="Add as recipient and follower (reason: {{recipient.reason}})" t-att-class="recipient_index > 2 ? 'o_suggested_toggle o_hidden' : ''"> - <div class="custom-control custom-checkbox"> - <input type="checkbox" - class="custom-control-input" - t-attf-id="o_composer_suggested_partners_p{{recipient_index}}" - t-att-checked="recipient.checked ? 'checked' : undefined" - t-att-data-fullname="recipient.full_name"/> - <label t-attf-for="o_composer_suggested_partners_p{{recipient_index}}" class="custom-control-label"> - <t t-esc="recipient.name"/> - <t t-if="recipient.email_address">(<t t-esc="recipient.email_address"/>)</t> - </label> - </div> - </div> - </t> - <t t-if='widget.suggestedPartners.length > 3'> - <a href="#" class='o_suggested_show_more_less o_suggested_toggle'> - Show <t t-esc="widget.suggestedPartners.length - 3"/> more - </a> - <a href="#" class='o_suggested_show_more_less o_suggested_toggle o_hidden'> - Show <t t-esc="widget.suggestedPartners.length - 3"/> less - </a> - </t> - </div> - </t> - - <!-- - Chatter composer - - @param {mail.composer.Chatter} widget - --> - - <t t-name="mail.chatter.Composer" t-extend="mail.Composer"> - <!-- Insert information before the composer --> - <t t-jquery=".o_composer_container" t-operation="before"> - <!-- New message composer --> - <t t-if="!widget.options.isLog"> - <!-- Text for followers --> - <small class="o_chatter_composer_info"> - <b class="text-muted">To: </b> - <em class="text-muted">Followers of </em> - <b> - <t t-if="widget.options.recordName"> -  "<t t-esc="widget.options.recordName"/>" - </t> - <t t-if="!widget.options.recordName"> - this document - </t> - </b> - </small> - <t t-call="mail.chatter.ComposerSuggestedPartners"/> - </t> - </t> - - <!-- Add a button to open the full composer dialog --> - <t t-jquery=".o_composer_button_add_attachment" t-operation="after"> - <button tabindex="6" class="btn btn-secondary fa fa-expand o_composer_button_full_composer" type="button" title="Full composer" aria-label="Full composer"/> - </t> - </t> - - <!-- - Chatter (mail_thread widget) buttons - - @param {boolean} [isMobile] - @param {boolean} [logNoteButton] - @param {boolean} [newMessageButton] - @param {boolean} [scheduleActivityButton] - --> - <t t-name="mail.chatter.Buttons"> - <button t-if="newMessageButton" type="button" class="btn btn-link o_chatter_button_new_message" title="Send a message" disabled="disabled"> - Send message - </button> - <button t-if="logNoteButton" class="btn btn-link o_chatter_button_log_note" title="Log a note. Followers will not be notified." disabled="disabled"> - Log note - </button> - <button t-if="scheduleActivityButton" class="btn btn-link o_chatter_button_schedule_activity" title="Log or schedule an activity" disabled="disabled"> - <i class="fa fa-clock-o" title="Dates"/> <t t-if="isMobile">Activity</t><t t-else="">Schedule activity</t> - </button> - </t> - - <!-- - Chatter attachment paperclip button. - - @param {integer} [count] - --> - <t t-name="mail.chatter.Attachment.Button"> - <t t-set="attachment_button_aria_label">Attachments</t> - <button class="btn btn-link o_chatter_button_attachment" title="View all the attachments of the current record" t-attf-aria-label="#{attachment_button_aria_label} #{count}"> - <span> - <i class="fa fa-paperclip"/> - <span class="o_chatter_attachment_button_count" t-esc="count"/> - </span> - </button> - </t> - - <!-- - Chatter main div - --> - <t t-name="mail.Chatter"> - <aside class="o_chatter"> - <div class="o_chatter_topbar"> - <div class="o_topbar_right_area"/> - </div> - </aside> - </t> - -</templates> diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index 071a8023e83..49c0a3bf9b4 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -1,54 +1,7 @@ <?xml version="1.0" encoding="UTF-8"?> <templates xml:space="preserve"> - <div t-name="mail.Composer" t-attf-class="o_thread_composer #{widget.extended ? 'o_thread_composer_extended' : (widget.notInline ? '' : 'o_chat_inline_composer')} #{widget.isMini ? 'o_chat_mini_composer' : ''}"> - <div t-if="widget.isMini || !widget.notInline" class="o_file_drop_zone_container d-none"> - <h4>Drag Files Here <span class="fa fa-download"/></h4> - </div> - <div class="o_composer_container"> - <img t-if="widget.avatarURL" height="36" width="36" class="o_thread_message_sidebar o_chatter_avatar o_object_fit_cover rounded-circle" t-att-src="widget.avatarURL" alt="Avatar"/> - <div t-if="widget.extended" class="o_composer_subject"> - <input tabindex="1" type="text" placeholder="Subject" class="o_input"/> - </div> - <div t-attf-class="o_composer #{widget.extended ? 'o_extended_composer' : ''}"> - <div t-if="widget.notInline" class="o_file_drop_zone_container d-none"> - <h4>Drag Files Here <span class="fa fa-download"/></h4> - </div> - <div class="o_composer_input"> - <textarea class="o_input o_composer_text_field" tabindex="2" placeholder="Write something..."/> - <div class="o_chatter_composer_tools"> - <button tabindex="4" class="btn btn-secondary fa fa-smile-o o_composer_button_emoji" type="button" data-toggle="popover" title="Emojis" aria-label="Emojis"/> - <button tabindex="5" class="btn btn-secondary fa fa-paperclip o_composer_button_add_attachment" type="button" aria-label="Add attachment" title="Add attachment"/> - <button t-if="widget.options.isMobile" tabindex="3" t-attf-class="btn #{widget.isMini ? 'btn-primary' : 'btn-secondary'} fa fa-paper-plane-o o_composer_button_send" type="button" title="Send" aria-label="Send"/> - </div> - </div> - </div> - <div class="o_composer_attachments_list"/> - <div class="o_composer_thread_typing"/> - </div> - <div class="o_composer_send"> - <button tabindex="3" class="btn btn-primary o_composer_button_send d-none d-md-inline-block" type="button"><t t-esc="widget.options.sendText"/></button> - <button tabindex="3" class="btn btn-secondary o_composer_button_discard d-none" type="button">Discard</button> - </div> - <span class="d-none"> - <t t-call="HiddenInputFile"> - <t t-set="fileupload_id" t-value="widget.fileuploadID"/> - <t t-set="fileupload_action" t-translation="off">/web/binary/upload_attachment</t> - <t t-set="multi_upload" t-value="true"/> - <input type="hidden" name="model" value="mail.compose.message"/> - <input type="hidden" name="id" value="0"/> - </t> - </span> - </div> - - <!-- - @param {mail.model.Thread} thread - --> - <t t-name="mail.Composer.ThreadTyping"> - <t t-esc="thread.getTypingMembersToText()"/> - </t> - - <div t-name="mail.Composer.emojis" class="o_mail_emoji_container"> + <div t-name="mail.legacy.Composer.emojis" class="o_mail_emoji_container"> <t t-foreach="emojis" t-as="emoji"> <span t-att-data-emoji="emoji.sources[0]" class="o_mail_emoji" t-att-title="emoji.description" t-att-aria-label="emoji.description"> <t t-raw="emoji.unicode"/> @@ -56,48 +9,6 @@ </t> </div> - <!-- - @param {Object[]} suggestions - @param {boolean} [suggestions.divider=false] - --> - <t t-name="mail.MentionSuggestions"> - <div class="dropdown-menu" role="menu"> - <t t-foreach="suggestions" t-as="suggestion"> - <div role="separator" t-if="suggestion.divider" class="dropdown-divider"/> - <a t-else="" href="#" class="dropdown-item o_mention_proposition" t-att-data-id="suggestion.id" role="menuitem"/> - </t> - </div> - </t> - <t t-name="mail.MentionSuggestions.Partner" t-extend="mail.MentionSuggestions"> - <t t-jquery=".o_mention_proposition" t-operation="append"> - <t t-call="mail.UserStatus"> - <t t-set="status" t-value="suggestion.im_status"/> - <t t-set="partnerID" t-value="suggestion.id"/> - </t> - <span class="o_mention_name"><t t-esc="suggestion.name"/></span> - <t t-if="suggestion.email"> - <span class="o_mention_info">(<t t-esc="suggestion.email"/>)</span> - </t> - </t> - </t> - <t t-name="mail.MentionSuggestions.Channel" t-extend="mail.MentionSuggestions"> - <t t-jquery=".o_mention_proposition" t-operation="append"> - <span class="o_mention_name"><t t-esc="suggestion.name"/></span> - </t> - </t> - <t t-name="mail.MentionSuggestions.CannedResponse" t-extend="mail.MentionSuggestions"> - <t t-jquery=".o_mention_proposition" t-operation="append"> - <span class="o_mention_name"><t t-esc="suggestion.source"/></span> - <span class="o_mention_info"><t t-esc="suggestion.substitution"/></span> - </t> - </t> - <t t-name="mail.MentionSuggestions.Command" t-extend="mail.MentionSuggestions"> - <t t-jquery=".o_mention_proposition" t-operation="append"> - <span class="o_mention_name">/<t t-esc="suggestion.name"/></span> - <span class="o_mention_info"><t t-esc="suggestion.help"/></span> - </t> - </t> - <t t-name="FieldMany2ManyTagsEmail" t-extend="FieldMany2ManyTag"> <t t-jquery="[t-attf-class*=badge]" t-operation="replace"> <div t-attf-class="badge badge-pill dropdown o_tag_color_0 #{el.email.indexOf('@') < 0 ? 'o_tag_error' : ''}" t-att-data-color="color" t-att-data-index="el_index" t-att-data-id="el.id" t-attf-title="Tag color: #{colornames[color]}"> diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml deleted file mode 100644 index 98788e303ff..00000000000 --- a/addons/mail/static/src/xml/discuss.xml +++ /dev/null @@ -1,352 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates xml:space="preserve"> - - <!-- - Discuss Main template - - @param {mail.discuss} widget - --> - <t t-name="mail.discuss"> - <div class="o_mail_discuss"> - <div class="o_mail_discuss_sidebar"> - <div class="o_mail_discuss_loading"> - <i class="fa fa-spinner fa-spin"/> Please wait... - </div> - </div> - <div class="o_mail_discuss_content"> - <div class="o_mail_discuss_loading"> - <i class="fa fa-spinner fa-spin"/> Please wait... - </div> - </div> - </div> - </t> - - <!-- - @param {string|integer} activeThreadID - @param {mail.model.Channel[]} channels - @param {boolean} isMyselfModerator - @param {mail.model.Mailbox} inbox - @param {mail.model.Mailbox} starred - @param {mail.model.Mailbox|undefined} moderation set if current user is moderator - @param {boolean} displayQuickSearch - @param {Object} options - --> - <t t-name="mail.discuss.Sidebar"> - <div class="o_mail_discuss_sidebar"> - <div class="o_mail_discuss_sidebar_mailboxes"> - <t t-call="mail.discuss.SidebarMailboxes"/> - </div> - <hr class="mb8"/> - <div t-if="displayQuickSearch" class="o_discuss_sidebar_quick_search"> - <t t-set="inputPlaceholder">Quick search...</t> - <input t-att-placeholder="inputPlaceholder"/> - </div> - <div class="o_mail_discuss_sidebar_channels"> - <t t-call="mail.discuss.SidebarChannels"/> - </div> - </div> - </t> - - <!-- - @param {string|integer} activeThreadID - @param {boolean} isMyselfModerator - @param {mail.model.Mailbox} inbox - @param {mail.model.Mailbox} starred - @param {mail.model.Mailbox|undefined} moderation set if current user is moderator - --> - <t t-name="mail.discuss.SidebarMailboxes"> - <div t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID === 'mailbox_inbox') ? 'o_active': ''}" - data-thread-id="mailbox_inbox"> - <span class="o_thread_name"><i class="fa fa-inbox mr8"/>Inbox</span> - <t t-set="counter" t-value="inbox.getMailboxCounter()"/> - <t t-call="mail.discuss.SidebarCounter"/> - </div> - <div t-attf-class="o_mail_discuss_title_main o_mail_mailbox_title_starred o_mail_discuss_item #{(activeThreadID === 'mailbox_starred') ? 'o_active': ''}" - data-thread-id="mailbox_starred"> - <span class="o_thread_name"><i class="fa fa-star-o mr8"/>Starred</span> - <t t-set="counter" t-value="starred.getMailboxCounter()"/> - <t t-call="mail.discuss.SidebarCounter"/> - </div> - <div t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID === 'mailbox_history') ? 'o_active': ''}" - data-thread-id="mailbox_history"> - <span class="o_thread_name"><i class="fa fa-history mr8"/>History</span> - </div> - <div t-if="isMyselfModerator" t-attf-class="o_mail_discuss_title_main o_mail_discuss_item #{(activeThreadID == 'mailbox_moderation') ? 'o_active': ''}" - data-thread-id="mailbox_moderation"> - <span class="o_thread_name"> <i class="fa fa-envelope mr8"/>Moderation Queue</span> - <t t-set="counter" t-value="moderation.getMailboxCounter()"/> - <t t-call="mail.discuss.SidebarCounter"/> - </div> - </t> - - <!-- - @param {string|integer} activeThreadID - @param {mail.model.Channel[]} channels - --> - <t t-name="mail.discuss.SidebarChannels"> - <t t-set="type" t-value="'multi_user_channel'"/> - <t t-call="mail.discuss.SidebarTitle"> - <t t-set="title">Channels</t> - <t t-set="icon" t-value="fa-users"/> - </t> - <t t-call="mail.discuss.SidebarItems"> - <t t-set="displayHash" t-value="true"/> - <t t-set="inputPlaceholder">Add a channel</t> - </t> - - <t t-set="type" t-value="'dm_chat'"/> - <t t-call="mail.discuss.SidebarTitle"> - <t t-set="title">Direct Messages</t> - <t t-set="icon" t-value="fa-user"/> - </t> - <t t-call="mail.discuss.SidebarItems"> - <t t-set="displayStatus" t-value="true"/> - <t t-set="inputPlaceholder">User name</t> - </t> - </t> - - <!-- - @param {integer} counter - --> - <t t-name="mail.discuss.SidebarCounter"> - <span t-attf-class="o_mail_sidebar_needaction badge badge-pill #{(!counter ? 'd-none' : '')}"> - <t t-esc="counter"/> - </span> - </t> - - <!-- - Don't display the thread type if there is no thread of such type - and the 'add a thread' option is disabled for this type - - @param {mail.model.Channel[]} channels - @param {boolean} [disableAddThread] whether the 'Add thread' action should disabled or not for the considered discuss item category - @param {string} [icon] classname to add on title to display icon - @param {string} title the displayed type of the discuss items (e.g. "Channels") - @param {string} type the type of the thread - --> - <t t-name="mail.discuss.SidebarTitle"> - <t t-if="disableAddThread"> - <t t-set="empty" t-value="true"/> - <t t-foreach="channels" t-as="channel"> - <t t-if="channel.getType() === type"> - <t t-set="empty" t-value="false"/> - </t> - </t> - </t> - <t t-if="!disableAddThread || !empty"> - <div class="o_mail_sidebar_title"> - <h4 t-att-class="type == 'multi_user_channel' ? 'o_mail_open_channels' : ''"> - <i t-if="icon" t-attf-class="mr4 fa-fw fa #{icon}" role="img" aria-label="Channel" title="Channel"></i> - <b><t t-esc="title"/></b> - </h4> - <span t-if="!disableAddThread" class="fa fa-plus o_add" title="Add" t-attf-data-type="#{type}" role="img" aria-label="Add"/> - </div> - </t> - </t> - - <!-- - @param {string|integer} activeThreadID - @param {mail.model.Channel[]} channels - @param {boolean} disableAddThread whether the 'Add thread' action should be disable or not for the considered discuss item category - @param {boolean} [displayHash] whether the hash ('#') character should be displayed as a prefix for item's name - @param {boolean} [displayStatus] whether the item should display its status (should be the case only for chats) - @param {string} inputPlaceholder text placeholder on showing input in a sidebar category - --> - <t t-name="mail.discuss.SidebarItems"> - <div t-if="!disableAddThread" class="o_mail_add_thread" t-attf-data-type="#{type}"> - <span t-if="displayHash">#</span> - <input type="text" class="o_input" t-attf-placeholder="#{inputPlaceholder}"/> - </div> - <t t-foreach="channels" t-as="channel"> - <t t-set="counter" t-value="channel.isTwoUserThread() ? channel.getUnreadCounter() : channel.getNeedactionCounter()"/> - <div t-if="channel.getType() === type" t-att-data-thread-id="channel.getID()" t-att-title="channel.getName()" - t-attf-class="o_mail_discuss_item #{channel.hasUnreadMessages() ? ' o_unread_message' : ''} #{(activeThreadID == channel.getID()) ? 'o_active': ''}"> - <span class="o_thread_name"> - <span t-if="displayStatus"> - <t t-call="mail.UserStatus"> - <t t-set="status" t-value="channel.getStatus()"/> - <t t-set="partnerID" t-value="channel.getDirectPartnerID()"/> - </t> - </span> - <span t-if="displayHash" class="o_mail_hash">#</span> - <i t-if="channel.getType() === 'multi_user_channel' and !channel.isPublic()" class="fa fa-lock" title="Private channel" role="img" aria-label="Private channel"/> - <t t-esc="_.unescape(channel.getName())"/> - <i t-if="channel.isMassMailing()" class="fa fa-envelope-o" title="Sends messages by email" role="img" aria-label="Send by mail"/> - </span> - <t t-if="channel.hasTypingNotification() and channel.isSomeoneTyping()"> - <t t-set="thread" t-value="channel"/> - <t t-call="mail.ThreadTypingIcon"/> - </t> - <span t-attf-class="fa fa-cog o_mail_channel_settings" - title="Channel settings" role="img" aria-label="Channel settings" t-att-data-thread-id="channel.getID()"/> - <t t-call="mail.discuss.SidebarCounter"/> - <span t-if="!channel.isGroupBasedSubscription()" t-attf-class="fa fa-times o_mail_partner_unpin #{counter ? 'd-none' : ''}" - title="Leave this channel" role="img" aria-label="Leave this channel" t-att-data-thread-id="channel.getID()"/> - </div> - </t> - </t> - - <!-- - @param {string} status - @param {integer|undefined} [partnerID] - --> - <t t-name="mail.UserStatus"> - <span t-att-class="partnerID ? 'o_updatable_im_status' : ''" t-att-data-partner-id="partnerID"> - <i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/> - <i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/> - <i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/> - </span> - </t> - - <!-- - @param {string} documentName - --> - <t t-name="mail.discuss.MessageSentSnackbar"> - <div class="alert o_mail_snackbar" data-dismiss="alert" role="alert"> - Message sent in "<t t-esc="documentName"/>". - </div> - </t> - - - <!-- - Buttons of the Control Panel - - @param {boolean} [disableAddThread] whether the 'Add channel' action should be disabled or not for the considered discuss item category - --> - <t t-name="mail.discuss.ControlButtons"> - <div> - <button type="button" class="btn btn-primary o_mail_discuss_button_invite d-none d-md-inline-block" title="Invite people">Invite</button> - <button type="button" class="btn btn-secondary o_mail_discuss_button_mark_all_read d-none d-md-inline-block" title="Mark all as read">Mark all read</button> - <button type="button" class="btn btn-secondary o_mail_discuss_button_unstar_all" title="Unstar all messages">Unstar all</button> - <button type="button" class="btn btn-secondary o_mail_discuss_button_dm_chat o_hidden d-md-none" title="New Message">New Message</button> - <button t-if="!disableAddThread" type="button" class="btn btn-secondary o_mail_discuss_button_multi_user_channel d-block d-md-none" title="New Channel">New Channel</button> - <button type="button" class="btn btn-secondary o_hidden o_mail_discuss_button_select_all" title="Select all messages to moderate">Select All</button> - <button type="button" class="btn btn-secondary o_hidden o_mail_discuss_button_unselect_all" title="Unselect all messages to moderate">Unselect All</button> - <button type="button" data-decision="accept" class="btn btn-secondary o_mail_discuss_button_moderate_all" title="Accept selected messages">Accept</button> - <button type="button" data-decision="reject" class="btn btn-secondary o_mail_discuss_button_moderate_all" title="Reject selected messages">Reject</button> - <button type="button" data-decision="discard" class="btn btn-secondary o_mail_discuss_button_moderate_all" title="Discard selected messages">Discard</button> - </div> - </t> - - <!-- Invite dialog's content --> - <div t-name="mail.PartnerInviteDialog"> - <input type="text" class="o_input o_mail_discuss_partner_invite_input" id="mail_search_partners"/> - </div> - - <!-- Rename channel dialog's content --> - <div t-name="mail.RenameConversationDialog"> - <input type="text" class="o_input o_mail_discuss_rename_channel_input"/> - </div> - - <div t-name="mail.ModeratorRejectMessageDialog"> - <input class="form-control" type="text" id="message_title" placeholder="Subject" value="Message Rejected"/> - <textarea class="form-control mt16" id="reject_message" placeholder="Mail Body">Your message was rejected by moderator.</textarea> - </div> - - <!-- Mobile templates --> - <t t-name="mail.discuss_mobile"> - <div class="o_mail_discuss"> - <div class="o_mail_discuss_mobile_mailboxes_buttons"> - <button type="button" class="btn btn-primary d-inline d-md-none o_mailbox_inbox_item" title="Inbox" data-type="mailbox_inbox"> - Inbox - </button> - <button type="button" class="btn btn-secondary d-inline d-md-none o_mailbox_inbox_item" title="Starred" data-type="mailbox_starred"> - Starred - </button> - <button type="button" class="btn btn-secondary d-inline d-md-none o_mailbox_inbox_item" title="History" data-type="mailbox_history"> - History - </button> - </div> - <div class="o_mail_discuss_content"/> - <div class="o_mail_mobile_tabs"> - <div class="o_mail_mobile_tab" data-type="mailbox_inbox"> - <span class="fa fa-inbox"/> - <span class="o_tab_title">Inbox</span> - </div> - <div class="o_mail_mobile_tab" data-type="dm_chat"> - <span class="fa fa-user"/> - <span class="o_tab_title">Chat</span> - </div> - <div class="o_mail_mobile_tab" data-type="multi_user_channel"> - <span class="fa fa-users"/> - <span class="o_tab_title">Channels</span> - </div> - </div> - </div> - </t> - - <!-- - @param {boolean} [disableAddThread] whether the 'Add thread' action should be disabled or not for the considered discuss item category - @param {Object[]} previews valid objects for preview rendering (see mail.Preview template) - --> - <t t-name="mail.discuss.MobileTabPane"> - <div class="o_mail_discuss_tab_pane" t-att-data-type="type"> - <div t-if="!disableAddThread" class="o_mail_add_thread" t-att-data-type="type"> - <span t-if="type == 'multi_user_channel'">#</span> - <t t-set="inputPlaceholder" t-if="type == 'multi_user_channel'">Add a channel</t> - <t t-set="inputPlaceholder" t-if="type == 'dm_chat'">Open chat</t> - <input type="text" t-attf-placeholder="#{inputPlaceholder}"/> - </div> - <t t-foreach="previews" t-as="preview"> - <t t-call="mail.Preview"/> - </t> - </div> - </t> - - <!-- - The preview template is used by the discuss in mobile, and by the systray menu - In order to show preview of threads - - FIXME: it is shared with systray, so it should not be in discuss.xml - - @param {Object} preview a valid object for preview rendering - @param {integer} [preview.documentID] - @param {string} [preview.documentModel] - @param {string|integer} preview.id - @param {string} [preview.imageSRC] - @param {boolean} [preview.isMyselfAuthor] - @param {moment} [preview.date] - @param {string} [preview.author] - @param {string} [preview.body] - @param {string} [preview.title] - @param {string} [preview.status] - @param {integer} [preview.unreadCounter] - @param {integer[]} [preview.messageIDs] - --> - <t t-name="mail.Preview"> - <div t-attf-class="o_mail_preview #{preview.unreadCounter > 0 ? 'o_preview_unread' : ''}" - t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel" t-att-data-unread-counter="preview.unreadCounter" t-att-data-message-ids="preview.messageIDs"> - <div t-attf-class="o_mail_preview_image #{preview.documentModel ? 'o_mail_preview_app' : ''}"> - <img class="o_mail_preview_image" t-att-src="preview.imageSRC" alt="Preview"/> - <t t-call="mail.UserStatus"> - <t t-set="status" t-value="preview.status"/> - </t> - </div> - <div class="o_preview_info"> - <div class="o_preview_title"> - <span class="o_preview_name"> - <t t-esc="preview.title"/> - </span> - <span class="o_preview_counter"> - <t t-if="preview.unreadCounter > 0">&nbsp;(<t t-esc="preview.unreadCounter"/>)</t> - </span> - <span t-if="preview.isLinkedToDocumentThread" class="o_discuss_icon o_thread_window_expand fa fa-expand"/> - <span class="o_last_message_date ml-auto mr-2"> <t t-esc="preview.date ? preview.date.fromNow() : ''"/> </span> - </div> - <div class="o_last_message_preview"> - <t t-if="preview.body"> - <t t-if="preview.isMyselfAuthor"> - <span class="fa fa-mail-reply" role="img" aria-label="Reply"/> You: - </t> - <t t-elif="preview.author"> - <t t-esc="preview.author"/>: - </t> - <t t-esc="preview.body"/> - </t> - </div> - <span title="Mark as Read" class="o_discuss_icon o_mail_preview_mark_as_read fa fa-check" t-if="preview.unreadCounter"/> - </div> - </div> - </t> - -</templates> diff --git a/addons/mail/static/src/xml/followers.xml b/addons/mail/static/src/xml/followers.xml deleted file mode 100644 index d1f6ebd63a4..00000000000 --- a/addons/mail/static/src/xml/followers.xml +++ /dev/null @@ -1,95 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates> - - <!-- - Followers, actions and subtypes in a record. - --> - <t t-name="mail.Followers"> - <div class="o_followers btn-group"> - <div class="o_followers_title_box btn-group"> - <button class="btn btn-link" title="Dropdown menu - Followers" - data-toggle="dropdown" aria-expanded="false"> - <i class="fa fa-user" role="img" aria-label="Followers"></i> - <span class="o_followers_count"/> - </button> - <div class="dropdown-menu dropdown-menu-right o_followers_list" role="menu"/> - </div> - <div class="o_followers_actions btn-group"> - <button class="btn btn-link o_followers_follow_button o_followers_notfollow"> - <span class="o_follow">Follow</span> - <i class="fa fa-times o_followers_unfollow"/> - <span class="o_followers_unfollow"> Unfollow</span> - <i class="fa fa-check o_followers_following"/> - <span class="o_followers_following"> Following</span> - </button> - </div> - </div> - </t> - - <!-- - Partner or channel following the record - --> - <t t-name="mail.Followers.partner"> - <div role="menuitem" t-attf-class="dropdown-item o_partner {{ !record.is_active ? 'o_inactive': '' }}"> - <a class="o_mail_redirect text-truncate" - href="#" - t-att-title="record.title" - t-att-data-oe-model="record.partner_id ? 'res.partner' : 'mail.channel'" - t-att-data-oe-id="record.partner_id ? record.partner_id : record.channel_id"> - <img t-att-src="record.avatar_url" alt="Avatar" class="o_image_64_cover"/> - <t t-esc="record.name"/> - </a> - <button t-if="record.is_editable" - class="btn btn-icon fa fa-pencil o_edit_subtype d-none" - title="Edit subscription" - aria-label="Edit subscription" - role="img" - t-att-data-follower-id="record.id" - t-att-data-oe-id="record.partner_id ? record.partner_id : record.channel_id" - t-att-data-oe-model="record.partner_id ? 'res.partner' : 'mail.channel'"/> - <button t-if="widget.isEditable" - class="btn btn-icon fa fa-remove o_remove_follower" - aria-label="Remove this follower" - role="img" - title="Remove this follower"/> - </div> - </t> - - <!-- - 'Add followers' and 'Add channels' buttons - --> - <t t-name="mail.Followers.add_more"> - <t t-if="widget.isEditable"> - <a href="#" role="menuitem" class="dropdown-item o_add_follower">Add Followers</a> - <a href="#" role="menuitem" class="dropdown-item o_add_follower_channel">Add Channels</a> - <div role="separator" t-if="widget.followers.length > 0" class="dropdown-divider"/> - </t> - </t> - - <!-- - Message subtypes of a follower subscription - --> - <t t-name="mail.Followers.subtype"> - <div role="menuitem" class="o_subtype dropdown-item"> - <div class="custom-control custom-checkbox"> - <input type="checkbox" - t-attf-id="input_mail_followers_subtype_#{record.id + (dialog ? '_in_dialog': '')}" - t-att-name="record.name" - class="custom-control-input o_subtype_checkbox" - t-att-checked="record.followed" - t-att-data-id="record.id"/> - <label t-attf-for="input_mail_followers_subtype_#{record.id + (dialog ? '_in_dialog': '')}" - class="custom-control-label"> - <t t-esc="record.name"/> - </label> - </div> - <i t-if="display_warning" class="fa fa-warning text-warning" role="img" aria-label="Warning" title="Warning"/> - </div> - </t> - <t t-name="mail.Followers.subtypes.warning"> - <span class="text-warning"> - <i class="fa fa-warning"/> Be careful with channels following internal notifications - </span> - </t> - -</templates> diff --git a/addons/mail/static/src/xml/out_of_office.xml b/addons/mail/static/src/xml/out_of_office.xml deleted file mode 100644 index 19fb2c341c6..00000000000 --- a/addons/mail/static/src/xml/out_of_office.xml +++ /dev/null @@ -1,9 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates xml:space="preserve"> - <!-- - @param {mail.model.AbstractThread} thread - --> - <div class="o_out_of_office" t-name="mail.thread.OutOfOffice"> - <div class="o_out_of_office_info" t-if="thread.getOutOfOfficeInfo()" t-esc="thread.getOutOfOfficeInfo()"/> - </div> -</templates> diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml index b10ef51a429..b9612e13a27 100644 --- a/addons/mail/static/src/xml/systray.xml +++ b/addons/mail/static/src/xml/systray.xml @@ -2,64 +2,6 @@ <templates> <!-- - @param {mail.systray.MessagingMenu} widget - --> - <t t-name="mail.systray.MessagingMenu"> - <li class="o_mail_systray_item"> - <a class="dropdown-toggle o-no-caret" data-toggle="dropdown" data-display="static" aria-expanded="false" title="Conversations" href="#" role="button"> - <i class="o_mail_messaging_menu_icon fa fa-comments fa-spinner fa-spin" role="img" aria-label="Messages"/> <span class="o_notification_counter badge badge-pill"/> - </a> - <div class="o_mail_systray_dropdown dropdown-menu dropdown-menu-right" role="menu"> - <t t-if="widget.isMobile()"> - <div class="o_mail_systray_mobile_header"> - <button type="button" class="btn btn-secondary o_new_message"> New message </button> - </div> - </t> - <div t-if="!widget.isMobile()" class="o_mail_systray_dropdown_top"> - <div> - <button type="button" class="btn btn-link o_filter_button active" role="tab"> All </button> - <button type="button" class="btn btn-link o_filter_button" data-filter='chat' role="tab"> Chat </button> - <button type="button" class="btn btn-link o_filter_button" data-filter='channels' role="tab"> Channels </button> - </div> - <button type="button" class="btn btn-link o_new_message"> New message </button> - </div> - <div class="o_mail_systray_dropdown_items"/> - <t t-if="widget.isMobile()"> - <div class="o_mail_mobile_tabs"> - <div class="o_mail_mobile_tab o_filter_button active" role="tab"> - <span class="fa fa-envelope"/> - <span class="o_tab_title">All</span> - </div> - <div class="o_mail_mobile_tab o_filter_button" data-filter='chat' role="tab"> - <span class="fa fa-user"/> - <span class="o_tab_title">Chat</span> - </div> - <div class="o_mail_mobile_tab o_filter_button" data-filter='channels' role="tab"> - <span class="fa fa-users"/> - <span class="o_tab_title">Channels</span> - </div> - </div> - </t> - </div> - </li> - </t> - - <!-- - @param {Object} previews valid list of objects for preview rendering - (see mail.Preview template) - --> - <t t-name="mail.systray.MessagingMenu.Previews"> - <t t-if="_.isEmpty(previews)"> - <li class="text-center o_no_activity mt16"> - <span>No conversation yet...</span> - </li> - </t> - <t t-foreach="previews" t-as="preview"> - <t t-call="mail.Preview"/> - </t> - </t> - - <!-- @param {mail.systray.ActivityMenu} widget @param {Object[]} widget.activities --> diff --git a/addons/mail/static/src/xml/text_emojis.xml b/addons/mail/static/src/xml/text_emojis.xml index 56a74a54fd3..4f333ba941f 100644 --- a/addons/mail/static/src/xml/text_emojis.xml +++ b/addons/mail/static/src/xml/text_emojis.xml @@ -10,7 +10,7 @@ <i class="fa fa-smile-o"/> </button> <div class="dropdown-menu dropdown-menu-right border-0 p-2" style="width: 406px"> - <t t-call="mail.Composer.emojis"> + <t t-call="mail.legacy.Composer.emojis"> <t t-set="emojis" t-value="widget.emojis"></t> </t> </div> diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 05f9f04f6f7..5a3205ed2cb 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -10,33 +10,6 @@ </t> </t> <!-- - @param {mail.model.AbstractThread} thread - @param {Object} options - @param {boolean} [options.displayEmptyThread] - @param {boolean} [options.displayModerationCommands] - @param {boolean} [options.displayNoMatchFound] - @param {Array} [options.domain=[]] the domain to restrict messages on the thread. - --> - <t t-name="mail.widget.Thread"> - <t t-if="thread.hasMessages({ 'domain': options.domain || [] })"> - <t t-call="mail.widget.Thread.Content"/> - </t> - <t t-elif="options.displayEmptyThread"> - <t t-call="mail.widget.Thread.Empty" /> - </t> - <div t-elif="options.displayNoMatchFound" class="o_mail_no_content"> - <div class="o_thread_title">No matches found</div> - <div>No message matches your search. Try to change your search filters.</div> - </div> - </t> - - <!-- Rendering of thread when messaging not yet ready --> - <div t-name="mail.widget.ThreadLoading" class="o_mail_thread_loading"> - <i class="o_mail_thread_loading_icon fa fa-spinner fa-spin"/> - <span>Please wait...</span> - </div> - - <!-- @param {mail.DocumentViewer} widget --> <t t-name="DocumentViewer.Content"> @@ -126,513 +99,4 @@ </html> </t> - <!-- - When a thread is empty - - @param {mail.model.Thread} thread - --> - <div t-name="mail.widget.Thread.Empty" class="o_mail_no_content"> - <t t-if="thread.getID() === 'mailbox_inbox'"> - <div class="o_thread_title">Congratulations, your inbox is empty</div> - <div>New messages appear here.</div> - </t> - <t t-if="thread.getID() === 'mailbox_starred'"> - <div class="o_thread_title">No starred messages</div> - <div>You can mark any message as 'starred', and it shows up in this mailbox.</div> - </t> - <t t-if="thread.getID() === 'mailbox_history'"> - <div class="o_thread_title o_neutral_face_icon">No history messages</div> - <div>Messages marked as read will appear in the history.</div> - </t> - <t t-if="thread.getID() === 'mailbox_moderation'"> - <div class="o_thread_title">You have no message to moderate</div> - <div>Pending moderation messages appear here.</div> - </t> - </div> - - <!-- - @param {mail.model.AbstractThread} thread - @param {Object} options - @param {integer} [options.displayOrder] 1 or -1 ascending (respectively, descending) order for - the thread messages (from top to bottom) - @param {Array} [options.domain=[]] the domain to restrict messages on the thread. - @param {Object} ORDER - @param {integer} ORDER.ASC=1 messages are ordered by ascending order of IDs, (from top to bottom) - @param {integer} ORDER.DESC=-1 messages are ordered by descending IDs, (from top to bottom) - - _____________ _____________ - | | | | - | message 1 | | message n | - | message 2 | | ... | - | ... | | message 2 | - | message n | | message 1 | - |_____________| |_____________| - - ORDER: ASC DESC - - --> - <t t-name="mail.widget.Thread.Content"> - <t t-set="messages" t-value="thread.getMessages({ 'domain': options.domain || [] })"/> - <t t-if="options.displayOrder === ORDER.ASC" t-call="mail.widget.Thread.Content.ASC"/> - <t t-else="" t-call="mail.widget.Thread.Content.DESC"/> - </t> - - <!-- - @param {mail.model.AbstractThread} thread - @param {Object} options - @param {boolean} [options.displayBottomThreadFreeSpace=false] - @param {boolean} [options.displayLoadMore=false] - - _____________ - | | - | message 1 | - | message 2 | - | ... | - | message n | - |_____________| - - ASC Order - --> - <t t-name="mail.widget.Thread.Content.ASC"> - <div class="o_mail_thread_content"> - <t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/> - <t t-call="mail.widget.Thread.Messages"/> - <t t-if="options.displayBottomThreadFreeSpace"> - <div class="o_thread_bottom_free_space"/> - </t> - </div> - </t> - - <!-- - @param {mail.model.AbstractThread} thread - @param {Object} options - @param {boolean} [options.displayLoadMore=false] - @param {string|integer} [options.messagesSeparatorPosition] 'top' or - message ID, the separator is placed just after this message. - - _____________ - | | - | message n | - | ... | - | message 2 | - | message 1 | - |_____________| - - DESC Order - - --> - <t t-name="mail.widget.Thread.Content.DESC"> - <div class="o_mail_thread_content"> - <t t-if="options.messagesSeparatorPosition == 'top'" t-call="mail.MessagesSeparator"/> - <t t-set="messages" t-value="messages.slice().reverse()"/> - <t t-call="mail.widget.Thread.Messages"/> - <t t-if="options.displayLoadMore" t-call="mail.widget.Thread.LoadMore"/> - </div> - </t> - - <!-- - @param {mail.model.AbstractMessage[]} messages messages are ordered based - on desired display order - --> - <t t-name="mail.widget.Thread.Messages"> - <t t-set="current_day" t-value="0"/> - <t t-foreach="messages" t-as="message"> - <div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator"> - <span class="o_thread_date"> - <t t-esc="message.getDateDay()"/> - </span> - <t t-set="current_day" t-value="message.getDateDay()"/> - </div> - - <t t-call="mail.widget.Thread.Message"/> - </t> - </t> - - <!-- - @param {mail.model.AbstractThread} thread - @param {string} dateFormat - @param {Object} options - @param {mail.model.AbstractMessage} message - @param {Object} options - @param {boolean} [options.displayAvatars] - @param {boolean} [options.displayDocumentLinks] - @param {boolean} [options.displayNotificationIcons] - @param {boolean} [options.displayMarkAsRead] - @param {boolean} [options.displayModerationCommands] when set, display the moderation commands on - the message. This includes the moderation checkboxes (needs a control panel such as in Discuss app). - @param {boolean} [options.displayReplyIcons] - @param {boolean} [options.displayStars] - @param {boolean} [options.displaySubjectsOnMessages] - @param {boolean} options.hasMessageAttachmentDeletable - @param {string|integer} [options.messagesSeparatorPosition] 'top' or - message ID, the separator is placed just after this message. - @param {integer} [options.selectedMessageID] - --> - <t t-name="mail.widget.Thread.Message"> - <div t-if="!message.isEmpty()" t-att-class="'o_thread_message ' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion ' : ' o_mail_not_discussion ')" t-att-data-message-id="message.getID()"> - <div t-if="options.displayAvatars" class="o_thread_message_sidebar"> - <t t-if="message.hasAuthor()"> - <div t-if="displayAuthorMessages[message.getID()]" class="o_thread_message_sidebar_image"> - <img - alt="" - t-att-src="message.getAvatarSource()" - data-oe-model="res.partner" - t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''" - t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/> - <t t-call="mail.UserStatus"> - <t t-set="status" t-value="message.getAuthorImStatus()"/> - <t t-set="partnerID" t-value="message.getAuthorID()"/> - </t> - </div> - </t> - <t t-else=""> - <img t-if="displayAuthorMessages[message.getID()]" - alt="" - t-att-src="message.getAvatarSource()" - class="o_thread_message_avatar rounded-circle"/> - </t> - <span t-if="!displayAuthorMessages[message.getID()]" t-att-title="message.getDate().format(dateFormat)" class="o_thread_message_side_date"> - <t t-esc="message.getDate().format('hh:mm')"/> - </span> - <i t-if="!displayAuthorMessages[message.getID()] and options.displayStars and message.getType() !== 'notification'" - t-att-class="'fa o_thread_message_star o_thread_icon ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')" - t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as todo"/> - <t t-if="!displayAuthorMessages[message.getID()] and thread.hasSeenFeature()" t-call="mail.widget.Thread.Message.SeenIcon"/> - </div> - <div class="o_thread_message_core"> - <p t-if="displayAuthorMessages[message.getID()]" class="o_mail_info text-muted"> - <input t-if="message.needsModeration() and message.needsModerationByUser() and options.displayModerationCommands" type="checkbox" class="moderation_checkbox" t-att-data-message-id="message.getID()"/> - <t t-if="message.isNote()"> - Note by - </t> - - <strong t-if="message.hasAuthor()" - data-oe-model="res.partner" t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''" - t-attf-class="o_thread_author #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"> - <t t-esc="message.getDisplayedAuthor()"/> - </strong> - <strong t-elif="message.hasEmailFrom()"> - <a class="text-muted" t-attf-href="mailto:#{message.getEmailFrom()}?subject=Re: #{message.hasSubject() ? message.getSubject() : ''}"> - <t t-esc="message.getEmailFrom()"/> - </a> - </strong> - <strong t-else="" class="o_thread_author"> - <t t-esc="message.getDisplayedAuthor()"/> - </strong> - - - <small class="o_mail_timestamp" t-att-title="message.getDate().format(dateFormat)"><t t-esc="message.getTimeElapsed()"/></small> - <t t-if="message.isLinkedToDocumentThread() and options.displayDocumentLinks"> - <small>on</small> <a t-att-href="message.getURL()" t-att-data-oe-model="message.getDocumentModel()" t-att-data-oe-id="message.getDocumentID()" class="o_document_link"><t t-esc="message.getDocumentName()"/></a> - </t> - <t t-if="message.originatesFromChannel() and (message.getOriginChannelID() !== thread.getID())"> - (<small>from</small> <a t-att-data-oe-id="message.getOriginChannelID()" href="#">#<t t-esc="message.getOriginChannelName()"/></a>) - </t> - <span t-if="options.displayNotificationIcons and message.hasNotifications()" class="o_thread_tooltip_container"> - <span name="notification_icon" t-attf-class="d-inline-flex align-items-center o_thread_tooltip o_thread_message_notification {{ message.hasNotificationsError() ? 'o_thread_message_notification_error' : '' }}" t-att-data-message-id="message.getID()" t-att-data-message-type="message.getType()"> - <i t-att-class="message.getNotificationIcon()"/> - <small t-if="message.getNotificationText()" t-esc="message.getNotificationText()" class="font-weight-bold ml-1"/> - </span> - </span> - <span t-attf-class="o_thread_icons"> - <t t-if="thread.hasSeenFeature()" t-call="mail.widget.Thread.Message.SeenIcon"/> - <i t-if="options.displayStars and !message.isSystemNotification() and !message.needsModeration()" - t-att-class="'fa fa-lg o_thread_icon o_thread_message_star ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')" - t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as Todo"/> - <i t-if="message.isLinkedToDocumentThread() and options.displayReplyIcons" - class="fa fa-reply o_thread_icon o_thread_message_reply" - t-att-data-message-id="message.getID()" title="Reply" role="img" aria-label="Reply"/> - <i t-if="message.isNeedaction() and options.displayMarkAsRead" - class="fa fa-check o_thread_icon o_thread_message_needaction" - t-att-data-message-id="message.getID()" title="Mark as Read" role="img" aria-label="Mark as Read"/> - - <t t-if="message.needsModeration() and message.originatesFromChannel()"> - <t t-call="mail.MessageModeration"/> - </t> - </span> - </p> - <div class="o_thread_message_content"> - <t t-raw="message.getBody()"/> - <t t-if="message.hasTrackingValues()"> - <t t-if="message.hasSubtypeDescription()"> - <p><t t-esc="message.getSubtypeDescription()"/></p> - </t> - <t t-call="mail.widget.Thread.MessageTracking"/> - </t> - <p t-if="options.displaySubjectOnMessages and message.shouldDisplaySubject()" class="o_mail_subject">Subject: <t t-esc="message.getSubject()"/></p> - <t t-if="message.hasAttachments()"> - <div t-if="message.hasImageAttachments()" class="o_attachments_previews"> - <t t-foreach="message.getImageAttachments()" t-as="attachment"> - <t t-call="mail.AttachmentPreview"> - <t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/> - </t> - </t> - </div> - <div t-if="message.hasNonImageAttachments()" class="o_attachments_list"> - <t t-foreach="message.getNonImageAttachments()" t-as="attachment"> - <t t-call="mail.Attachment"> - <t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/> - </t> - </t> - </div> - </t> - </div> - </div> - </div> - <t t-if="options.messagesSeparatorPosition == message.getID()"> - <t t-call="mail.MessagesSeparator"/> - </t> - </t> - - <!-- - Display the seen icon of a message in the thread. - It shows the fetch 'check' before the seen 'check'. We change the order in the template - so that fetch 'check' is on top of 'seen' check (z-index does not work with absolute positioning) - - @param {mail.model.Thread} thread - @param {mail.model.Message} message - --> - <t t-name="mail.widget.Thread.Message.SeenIcon"> - <t t-if="message.isMyselfAuthor() and message.getID() >= thread.getLastMessageIDSeenByEveryone()"> - <span t-attf-class="o_mail_thread_message_seen_icon #{thread.hasEveryoneSeen(message) ? 'o_all_seen' : ''}" t-att-data-message-id="message.getID()"> - <t t-if="thread.hasSomeoneFetched(message)"> - <i class="fa fa-check"/> - </t> - <t t-if="thread.hasSomeoneSeen(message)"> - <i class="fa fa-check"/> - </t> - </span> - </t> - </t> - - <!-- - Display the popover content when clicking the seen icion of a message in the thread. - - List the members that have received the messages, and the members that have seen it. - - @param {mail.model.Thread} thread - @param {mail.model.Message} message - --> - <t t-name="mail.widget.Thread.Message.SeenIconPopoverContent"> - <div class="o_mail_thread_message_seen_icon_content"> - <t t-if="thread.hasEveryoneSeen(message)"> - <p>Seen by Everyone</p> - </t> - <t t-else=""> - <t t-if="thread.hasSomeoneSeen(message)"> - <t t-set="seen_members" t-value="thread.getSeenMembers(message)"/> - Seen by: - <ul> - <li t-foreach="seen_members" t-as="member"> - <t t-esc="member.name"/> (<t t-esc="member.email"/>) - </li> - </ul> - </t> - <t t-if="thread.hasSomeoneFetched(message)"> - <t t-if="thread.hasEveryoneFetched(message)"> - <p>Received by Everyone</p> - </t> - <t t-else=""> - <t t-set="fetched_members" t-value="thread.getFetchedNotSeenMembers(message)"/> - <t t-if="!_.isEmpty(fetched_members)"> - Received by: - <ul> - <li t-foreach="fetched_members" t-as="member"> - <t t-esc="member.name"/> (<t t-esc="member.email"/>) - </li> - </ul> - </t> - </t> - </t> - </t> - </div> - </t> - - <!-- - @param {Object[]} notifications: list of notifications - A notification is an object with at least the following keys: - {notification_status, partner_name} - --> - <t t-name="mail.widget.Thread.Message.MailTooltip"> - <div t-foreach="notifications" t-as="notification"> - <span name="notification_status" class="d-inline-block text-center o_thread_tooltip_icon"> - <i t-if="notification.notification_status === 'sent'" class='fa fa-check' title="Sent" role="img" aria-label="Sent"/> - <i t-if="notification.notification_status === 'bounce'" class='fa fa-exclamation text-danger' title="Bounced" role="img" aria-label="Bounced"/> - <i t-if="notification.notification_status === 'exception'" class='fa fa-exclamation text-danger' title="Error" role="img" aria-label="Error"/> - <i t-if="notification.notification_status === 'ready'" class='fa fa-send-o' title="Ready" role="img" aria-label="Ready"/> - <i t-if="notification.notification_status === 'canceled'" class='fa fa-trash-o' title="Canceled" role="img" aria-label="Canceled"/> - </span> - <span name="partner_name" t-esc="notification.partner_name"/> - </div> - </t> - <!-- - @param {mail.model.Message} message - @param {Object} options - @param {boolean} [options.displayModerationCommands] - --> - <t t-name="mail.MessageModeration"> - <i t-if="message.isMyselfAuthor()" class="text-danger" title="Your message is pending moderation"><b>Pending moderation</b></i> - <t t-elif="message.needsModerationByUser()"> - <t t-if="options.displayModerationCommands" t-call="mail.MessageModerationDecision"/> - <i t-else="" class="text-danger" title="Open Channel in Discuss to moderate"><b>Pending moderation</b></i> - </t> - </t> - - <!-- - @param {mail.model.Message} message - --> - <t t-name="mail.MessageModerationDecision"> - <i class="o_thread_icon ml4 o_thread_message_moderation text-success" - t-att-data-message-id="message.getID()" data-decision="accept" title="Accept"><b>Accept |</b></i> - <i class="o_thread_icon ml4 o_thread_message_moderation text-danger" - t-att-data-message-id="message.getID()" data-decision="reject" title="Remove message with explanation"><b>Reject |</b></i> - <i class="o_thread_icon ml4 o_thread_message_moderation text-danger" - t-att-data-message-id="message.getID()" data-decision="discard" title="Remove message without explanation"><b>Discard |</b></i> - <i class="o_thread_icon ml4 o_thread_message_moderation text-success" - t-att-data-message-id="message.getID()" data-decision="allow" title="Add this email address to white list of people"><b>Always Allow |</b></i> - <i class="o_thread_icon ml4 o_thread_message_moderation text-danger" - t-att-data-message-id="message.getID()" data-decision="ban" title="Ban this email address"><b>Ban</b></i> - </t> - - <t t-name="mail.MessagesSeparator"> - <div class="o_thread_new_messages_separator"> - <span class="o_thread_separator_label">New messages</span> - </div> - </t> - - <!-- - @param {mail.model.Message} message - --> - <t t-name="mail.widget.Thread.MessageTracking"> - <ul class="o_mail_thread_message_tracking"> - <t t-foreach='message.getTrackingValues()' t-as='value'> - <li> - <t t-esc="value.changed_field"/>: - <t t-if="value.old_value"> - <span> <t t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> </span> - <span t-if="value.old_value !== value.new_value" class="fa fa-long-arrow-right" role="img" aria-label="Changed" title="Changed"/> - </t> - <span t-if="value.old_value !== value.new_value"> - <t t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/> - </span> - </li> - </t> - </ul> - </t> - - <!-- - @param {Array} attachments - --> - <t t-name="mail.composer.Attachments"> - <div t-if="attachments.length > 0" class="o_attachments o_attachments_list"> - <t t-foreach="attachments" t-as='attachment'> - <t t-call="mail.Attachment"> - <t t-set="editable" t-value="true"/> - </t> - </t> - </div> - </t> - - <!-- - @param {Object} attachment - @param {integer} attachment.id - @param {string} attachment.name - @param {string} attachment.url - @param {boolean} [isDeletable=false] - --> - <t t-name="mail.AttachmentPreview"> - <div class="o_attachment" t-att-title="attachment.name"> - <div class="o_attachment_wrap"> - <div class="o_image_box"> - <div class="o_attachment_image" t-attf-style="background-image:url('/web/image/#{attachment.id}/160x160/?crop=true')"/> - <div t-attf-class="o_image_overlay o_attachment_view" t-att-data-id="attachment.id"> - <span t-if="isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/> - <span class="o_attachment_title text-white"><t t-esc="attachment.name"/></span> - <a class="o_attachment_download" t-att-href='attachment.url'> - <i t-attf-class="fa fa-download text-white" t-att-title="'Download ' + attachment.name" role="img" aria-label="Download"></i> - </a> - </div> - </div> - </div> - </div> - </t> - - <!-- - @param {Object} attachment - @param {string} attachment.filename - @param {integer} attachment.id - @param {string} [attachment.mimetype] - @param {string} attachment.name - @param {boolean} attachment.upload - @param {string} attachment.url - @param {boolean} [editable=false] if set, it means the attachment is rendered in the composer. - Some changes are required in that case, such as "delete" button is not visible (pretty unlink is used instead). - @param {boolean} [isDeletable=false] - --> - <t t-name="mail.Attachment"> - <t t-set="type" t-value="attachment.mimetype and attachment.mimetype.split('/').shift()"/> - <div t-attf-class="o_attachment #{ editable ? 'o_attachment_editable' : '' } #{attachment.upload ? 'o_attachment_uploading' : ''}" t-att-title="attachment.name"> - <div class="o_attachment_wrap"> - <span t-if="!editable and isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/> - <t t-set="has_preview" t-value="type == 'image' or type == 'video' or attachment.mimetype == 'application/pdf'"/> - <t t-set="ext" t-value="attachment.filename.split('.').pop()"/> - - <div t-attf-class="o_image_box float-left #{has_preview ? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"> - <div t-if="has_preview" - class="o_image o_hover" - t-att-style="type == 'image' ? 'background-image:url(/web/image/' + attachment.id + '/38x38/?crop=true' : '' " - t-att-data-mimetype="attachment.mimetype"> - </div> - <a t-elif="!editable" t-att-href='attachment.url' t-att-title="'Download ' + attachment.name" aria-label="Download"> - <span class="o_image o_hover" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext"/> - </a> - <span t-else="" class="o_image" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext" role="img" aria-label="Document not downloadable"/> - </div> - - <div class="caption"> - <span t-if="has_preview or editable" t-attf-class="ml4 #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc='attachment.name'/></span> - <a t-else="" class="ml4" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><t t-esc='attachment.name'/></a> - </div> - <div t-if="editable" class="caption small"> - <b t-attf-class="ml4 small text-uppercase #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc="ext"/></b> - <div class="progress o_attachment_progress_bar"> - <div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div> - </div> - </div> - <div t-if="!editable" class="caption small"> - <b t-if="has_preview" class="ml4 small text-uppercase o_attachment_view" t-att-data-id="attachment.id"><t t-esc="ext"/></b> - <a t-else="" class="ml4 small text-uppercase" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><b><t t-esc='ext'/></b></a> - <a class="ml4 o_attachment_download float-right" t-att-title="'Download ' + attachment.name" t-att-href='attachment.url'><i t-attf-class="fa fa-download" role="img" aria-label="Download"/></a> - </div> - <div t-if="editable" class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div> - <div t-if="editable" class="o_attachment_delete" t-att-data-id="attachment.id"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div> - </div> - </div> - </t> - - <!-- - @param {Object} options - @param {boolean} [options.loadMoreOnScroll] - --> - <t t-name="mail.widget.Thread.LoadMore"> - <div class="o_thread_show_more"> - <t t-if="options.loadMoreOnScroll"> - <span><i class="fa fa-spinner fa-spin" role="img" aria-label="Please wait" title="Please wait"/> Loading older messages... </span> - </t> - <t t-else=""> - <button class="btn btn-link">-------- Show older messages --------</button> - </t> - </div> - </t> - - <!-- - @param {mail.model.Thread} thread with typing feature - --> - <t t-name="mail.ThreadTypingIcon"> - <span class="o_mail_thread_typing_icon" t-att-title="thread.getTypingMembersToText()"> - <span class="o_mail_thread_typing_icon_dot"/> - <span class="o_mail_thread_typing_icon_dot"/> - <span class="o_mail_thread_typing_icon_dot"/> - </span> - </t> - </templates> diff --git a/addons/mail/static/src/xml/thread_window.xml b/addons/mail/static/src/xml/thread_window.xml deleted file mode 100644 index 1e8d3e97c2e..00000000000 --- a/addons/mail/static/src/xml/thread_window.xml +++ /dev/null @@ -1,32 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<templates xml:space="preserve"> - - <!-- - @param {mail.ThreadWindow} widget - --> - <t t-name="mail.ThreadWindow" t-extend="mail.AbstractThreadWindow"> - <t t-jquery=".o_thread_window_header" t-operation="after"> - <t t-if="widget.hasThread()"> - <t t-if="widget.getThread().hasOutOfOffice()"> - <t t-call="mail.thread.OutOfOffice"> - <t t-set="thread" t-value="widget.getThread()"/> - </t> - </t> - </t> - <div t-else="" class="o_thread_search_input"> - <span> To: </span> - <input type="text" placeholder="User name"/> - </div> - </t> - </t> - - <!-- - @param {string} expandTitle text to display on mouse hover (title attribute) - --> - <t t-extend="mail.AbstractThreadWindow.HeaderContent"> - <t t-jquery=".o_thread_window_buttons" t-operation="prepend"> - <a href="#" class="o_thread_window_expand fa fa-expand" t-att-title="expandTitle" t-att-aria-label="expandTitle"/> - </t> - </t> - -</templates> |
