diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/chat_window | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/chat_window')
3 files changed, 510 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 00000000..f9271523 --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.js @@ -0,0 +1,363 @@ +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'), + ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), +}; +const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.js'); +const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); +const { isEventHandled } = require('mail/static/src/utils/utils.js'); + +const patchMixin = require('web.patchMixin'); + +const { Component } = owl; +const { useRef } = owl.hooks; + +class ChatWindow extends Component { + + /** + * @override + */ + constructor(...args) { + super(...args); + useShouldUpdateBasedOnProps(); + useStore(props => { + const chatWindow = this.env.models['mail.chat_window'].get(props.chatWindowLocalId); + const thread = chatWindow ? chatWindow.thread : undefined; + return { + chatWindow, + chatWindowHasNewMessageForm: chatWindow && chatWindow.hasNewMessageForm, + chatWindowIsDoFocus: chatWindow && chatWindow.isDoFocus, + chatWindowIsFocused: chatWindow && chatWindow.isFocused, + chatWindowIsFolded: chatWindow && chatWindow.isFolded, + chatWindowThreadView: chatWindow && chatWindow.threadView, + chatWindowVisibleIndex: chatWindow && chatWindow.visibleIndex, + chatWindowVisibleOffset: chatWindow && chatWindow.visibleOffset, + isDeviceMobile: this.env.messaging.device.isMobile, + localeTextDirection: this.env.messaging.locale.textDirection, + thread, + threadMassMailing: thread && thread.mass_mailing, + threadModel: thread && thread.model, + }; + }); + useUpdate({ func: () => this._update() }); + /** + * 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'); + this._onWillHideHomeMenu = this._onWillHideHomeMenu.bind(this); + this._onWillShowHomeMenu = this._onWillShowHomeMenu.bind(this); + // the following are passed as props to children + this._onAutocompleteSelect = this._onAutocompleteSelect.bind(this); + this._onAutocompleteSource = this._onAutocompleteSource.bind(this); + this._constructor(...args); + } + + /** + * Allows patching constructor. + */ + _constructor() {} + + mounted() { + this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu); + this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu); + } + + willUnmount() { + this.env.messagingBus.off('will_hide_home_menu', this, this._onWillHideHomeMenu); + this.env.messagingBus.off('will_show_home_menu', this, this._onWillShowHomeMenu); + } + + //-------------------------------------------------------------------------- + // 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._inputRef.comp) { + this._inputRef.comp.focus(); + } + if (this._threadRef.comp) { + 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() { + if ( + !this._threadRef.comp || + !this.chatWindow.threadViewer || + !this.chatWindow.threadViewer.threadView + ) { + return; + } + if (this.chatWindow.threadViewer.threadView.componentHintList.length > 0) { + // the current scroll position is likely incorrect due to the + // presence of hints to adjust it + return; + } + this.chatWindow.threadViewer.saveThreadCacheScrollHeightAsInitial( + this._threadRef.comp.getScrollHeight() + ); + this.chatWindow.threadViewer.saveThreadCacheScrollPositionsAsInitial( + 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 + */ + async _onAutocompleteSelect(ev, ui) { + const chat = await this.env.messaging.getChat({ partnerId: ui.item.id }); + if (!chat) { + return; + } + this.env.messaging.chatWindowManager.openThread(chat, { + makeActive: true, + replaceNewMessage: true, + }); + } + + /** + * 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, + }); + } + + /** + * 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._saveThreadScrollTop(); + this.chatWindow.fold(); + } + } + + /** + * Called when an element in the thread becomes focused. + * + * @private + * @param {FocusEvent} ev + */ + _onFocusinThread(ev) { + ev.stopPropagation(); + if (!this.chatWindow) { + // prevent crash on destroy + return; + } + this.chatWindow.update({ isFocused: true }); + } + + /** + * Focus out the chat window. + * + * @private + */ + _onFocusout() { + if (!this.chatWindow) { + // ignore focus out due to record being deleted + return; + } + this.chatWindow.update({ isFocused: false }); + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if (!this.chatWindow) { + // prevent crash during delete + return; + } + switch (ev.key) { + case 'Tab': + ev.preventDefault(); + if (ev.shiftKey) { + this.chatWindow.focusPreviousVisibleUnfoldedChatWindow(); + } else { + this.chatWindow.focusNextVisibleUnfoldedChatWindow(); + } + break; + case 'Escape': + if (isEventHandled(ev, 'ComposerTextInput.closeSuggestions')) { + break; + } + if (isEventHandled(ev, 'Composer.closeEmojisPopover')) { + break; + } + ev.preventDefault(); + this.chatWindow.focusNextVisibleUnfoldedChatWindow(); + 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() { + 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() { + 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 patchMixin(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 00000000..7b61cd3b --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.scss @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------ +// 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_ThreadView_messageList { + font-size: 1rem; +} 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 00000000..ad4a1096 --- /dev/null +++ b/addons/mail/static/src/components/chat_window/chat_window.xml @@ -0,0 +1,54 @@ +<?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-data-visible-index="chatWindow ? chatWindow.visibleIndex : undefined" + 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-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.threadView"> + <ThreadView + class="o_ChatWindow_thread" + composerAttachmentsDetailsMode="'card'" + hasComposer="chatWindow.thread.model !== 'mail.box' and (!chatWindow.thread.mass_mailing or env.messaging.device.isMobile)" + hasComposerCurrentPartnerAvatar="false" + hasComposerSendButton="env.messaging.device.isMobile" + hasSquashCloseMessages="chatWindow.thread.model !== 'mail.box'" + threadViewLocalId="chatWindow.threadView.localId" + t-on-focusin="_onFocusinThread" + t-ref="thread" + /> + </t> + <t t-if="chatWindow.hasNewMessageForm"> + <div class="o_ChatWindow_newMessageForm"> + <span class="o_ChatWindow_newMessageFormLabel"> + To: + </span> + <AutocompleteInput + class="o_ChatWindow_newMessageFormInput" + placeholder="newMessageFormInputPlaceholder" + select="_onAutocompleteSelect" + source="_onAutocompleteSource" + t-ref="input" + /> + </div> + </t> + </t> + </div> + </t> + +</templates> |
