summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/chat_window
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
committerSébastien Theys <seb@odoo.com>2020-06-15 18:12:21 +0000
commit3fea5b2136627c1d7e83b2a5e111952fe3eb10a1 (patch)
tree5eefad07972a87ce4689b83260f981bc87aadad4 /addons/mail/static/src/components/chat_window
parent0a82848606151ef60eb6523e9c4c161171f214e7 (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/components/chat_window')
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.js352
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.scss101
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.xml56
3 files changed, 509 insertions, 0 deletions
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>