import classNames from 'classnames'; import React from 'react'; import ReactDOM from 'react-dom'; import { localized, Utils, Actions, MessageStore, Message, Thread, SearchableComponentStore, SearchableComponentMaker, } from 'mailspring-exports'; import { Spinner, RetinaImg, MailLabelSet, ScrollRegion, ScrollPosition, MailImportantIcon, KeyCommandsRegion, InjectedComponentSet, } from 'mailspring-component-kit'; import FindInThread from './find-in-thread'; import MessageItemContainer from './message-item-container'; import { MessageListScrollTooltip } from './message-list-scroll-tooltip'; import { SubjectLineIcons } from './subject-line-icons'; interface MessageListState { messages: Message[]; messagesExpandedState: { [messageId: string]: 'explicit' | 'default'; }; canCollapse: boolean; hasCollapsedItems: boolean; currentThread: Thread | null; loading: boolean; minified: boolean; } const { Menu, MenuItem } = require('@electron/remote'); const PREF_REPLY_TYPE = 'core.sending.defaultReplyType'; const PREF_RESTRICT_WIDTH = 'core.reading.restrictMaxWidth'; const PREF_DESCENDING_ORDER = 'core.reading.descendingOrderMessageList'; class MessageList extends React.Component, MessageListState> { static displayName = 'MessageList'; static containerStyles = { minWidth: 480, maxWidth: 999999, }; _unsubscribers = []; _messageWrapEl: ScrollRegion; _draftScrollInProgress = false; MINIFY_THRESHOLD = 3; constructor(props) { super(props); this.state = Object.assign(this._getStateFromStores(), { minified: true }); } componentDidMount() { this._unsubscribers = []; this._unsubscribers.push(MessageStore.listen(this._onChange)); } shouldComponentUpdate(nextProps, nextState) { return !Utils.isEqualReact(nextProps, this.props) || !Utils.isEqualReact(nextState, this.state); } componentDidUpdate() { // cannot remove } componentWillUnmount() { for (const unsubscribe of this._unsubscribers) { unsubscribe(); } } _globalKeymapHandlers() { const handlers = { 'core:reply': () => Actions.composeReply({ thread: this.state.currentThread, message: this._lastMessage(), type: 'reply', behavior: 'prefer-existing', }), 'core:reply-all': () => Actions.composeReply({ thread: this.state.currentThread, message: this._lastMessage(), type: 'reply-all', behavior: 'prefer-existing', }), 'core:forward': () => this._onForward(), 'core:print-thread': () => this._onPrintThread(), 'core:messages-page-up': () => this._onScrollByPage(-1), 'core:messages-page-down': () => this._onScrollByPage(1), }; if (this.state.canCollapse) { handlers['message-list:toggle-expanded'] = () => this._onToggleAllMessagesExpanded(); } return handlers; } _getMessageContainer(headerMessageId) { return this.refs[`message-container-${headerMessageId}`]; } _onForward = () => { if (!this.state.currentThread) { return; } Actions.composeForward({ thread: this.state.currentThread, message: this._lastMessage(), }); }; _lastMessage() { return (this.state.messages || []).filter(m => !m.draft).pop(); } // Returns either "reply" or "reply-all" _replyType() { const defaultReplyType = AppEnv.config.get(PREF_REPLY_TYPE); const lastMessage = this._lastMessage(); if (!lastMessage) { return 'reply'; } if (lastMessage.canReplyAll()) { return defaultReplyType === 'reply-all' ? 'reply-all' : 'reply'; } return 'reply'; } _onToggleAllMessagesExpanded = () => { Actions.toggleAllMessagesExpanded(); }; _onPrintThread = () => { const node = ReactDOM.findDOMNode(this) as HTMLElement; Actions.printThread(this.state.currentThread, node.innerHTML); }; _onPopThreadIn = () => { if (!this.state.currentThread) { return; } Actions.focusThreadMainWindow(this.state.currentThread); AppEnv.close(); }; _onPopoutThread = () => { if (!this.state.currentThread) { return; } Actions.popoutThread(this.state.currentThread); // This returns the single-pane view to the inbox, and does nothing for // double-pane view because we're at the root sheet. Actions.popSheet(); }; _onClickReplyArea = () => { if (!this.state.currentThread) { return; } Actions.composeReply({ thread: this.state.currentThread, message: this._lastMessage(), type: this._replyType(), behavior: 'prefer-existing-if-pristine', }); }; _messageElements() { const { messagesExpandedState, currentThread } = this.state; const elements = []; let messages = this._messagesWithMinification(this.state.messages); const mostRecentMessage = messages[messages.length - 1]; const hasReplyArea = mostRecentMessage && !mostRecentMessage.draft; // Invert the message list if the descending option is set if (AppEnv.config.get(PREF_DESCENDING_ORDER)) { messages = [...messages].reverse(); } messages.forEach(message => { if (message.type === 'minifiedBundle') { elements.push(this._renderMinifiedBundle(message)); return; } const collapsed = !messagesExpandedState[message.id]; const isMostRecent = message === mostRecentMessage; const isBeforeReplyArea = isMostRecent && hasReplyArea; elements.push( ); if (isBeforeReplyArea) { elements.push( ); } }); return elements; } _messagesWithMinification(allMessages = []) { if (!this.state.minified) { return allMessages; } const messages = [...allMessages]; const minifyRanges = []; let consecutiveCollapsed = 0; messages.forEach((message, idx) => { // Never minify the 1st message if (idx === 0) { return; } const expandState = this.state.messagesExpandedState[message.id]; if (!expandState) { consecutiveCollapsed += 1; } else { // We add a +1 because we don't minify the last collapsed message, // but the MINIFY_THRESHOLD refers to the smallest N that can be in // the "N older messages" minified block. const minifyOffset = expandState === 'default' ? 1 : 0; if (consecutiveCollapsed >= this.MINIFY_THRESHOLD + minifyOffset) { minifyRanges.push({ start: idx - consecutiveCollapsed, length: consecutiveCollapsed - minifyOffset, }); } consecutiveCollapsed = 0; } }); let indexOffset = 0; for (const range of minifyRanges) { const start = range.start - indexOffset; const minified = { type: 'minifiedBundle', messages: messages.slice(start, start + range.length), }; messages.splice(start, range.length, minified); // While we removed `range.length` items, we also added 1 back in. indexOffset += range.length - 1; } return messages; } // Some child components (like the composer) might request that we scroll // to a given location. If `selectionTop` is defined that means we should // scroll to that absolute position. // // If messageId and location are defined, that means we want to scroll // smoothly to the top of a particular message. _scrollTo = ({ headerMessageId, rect, position, }: { headerMessageId?: string; rect?: ClientRect; position?: ScrollPosition; } = {}) => { if (this._draftScrollInProgress) { return; } if (headerMessageId) { const messageElement = this._getMessageContainer(headerMessageId); if (!messageElement) return; this._messageWrapEl.scrollTo(messageElement, { position: position !== undefined ? position : ScrollPosition.Visible, }); } else if (rect) { this._messageWrapEl.scrollToRect(rect, { position: ScrollPosition.CenterIfInvisible, }); } else { throw new Error('onChildScrollRequest: expected id or rect'); } }; _onScrollByPage = direction => { const height = (ReactDOM.findDOMNode(this._messageWrapEl) as HTMLElement).clientHeight; this._messageWrapEl.scrollTop += height * direction; }; _onChange = () => { const newState: any = this._getStateFromStores(); const threadId = this.state.currentThread && this.state.currentThread.id; const nextThreadId = newState.currentThread && newState.currentThread.id; if (threadId !== nextThreadId) { newState.minified = true; } this.setState(newState); }; _getStateFromStores() { return { messages: MessageStore.items() || [], messagesExpandedState: MessageStore.itemsExpandedState(), canCollapse: MessageStore.items().length > 1, hasCollapsedItems: MessageStore.hasCollapsedItems(), currentThread: MessageStore.thread(), loading: MessageStore.itemsLoading(), }; } _renderSubject() { let subject = this.state.currentThread.subject; if (!subject || subject.length === 0) { subject = localized('(No Subject)'); } return (
_onSubjectContextMenu()}> {subject}
); function _onSubjectContextMenu() { if (window.getSelection()?.type == 'Range') { const menu = new Menu(); menu.append(new MenuItem({ role: 'copy' })); menu.popup({}); } } } _renderMinifiedBundle(bundle) { const BUNDLE_HEIGHT = 36; const lines = bundle.messages.slice(0, 10); const h = Math.round(BUNDLE_HEIGHT / lines.length); return (
this.setState({ minified: false })} key={Utils.generateTempId()} >
{`${bundle.messages.length} ${localized( 'older messages' )}`}
{lines.map((msg, i) => (
))}
); } render() { if (!this.state.currentThread) { return ; } const wrapClass = classNames({ 'messages-wrap': true, ready: !this.state.loading, }); return (
{ this._messageWrapEl = el; }} > {this._renderSubject()}
{this._messageElements()}
); } } class MessageListReplyArea extends React.Component<{ onClick: () => void; replyType: string }> { state = { forcePlaintext: AppEnv.keymaps.getIsAltKeyDown(), }; componentDidMount() { document.addEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this.onAltStateChange); } componentWillUnmount() { document.removeEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this.onAltStateChange); } onAltStateChange = () => { this.setState({ forcePlaintext: AppEnv.keymaps.getIsAltKeyDown() }); }; render() { return (
{this.state.forcePlaintext ? localized('Write a plain text reply…') : localized('Write a reply…')}
); } } export default SearchableComponentMaker.extend(MessageList);