Mailspring/app/internal_packages/message-list/lib/message-list.jsx

544 lines
15 KiB
React
Raw Normal View History

2017-09-27 02:33:08 +08:00
import classNames from 'classnames';
2017-07-12 23:46:59 +08:00
import {
2017-09-27 02:33:08 +08:00
React,
ReactDOM,
PropTypes,
2017-07-12 23:46:59 +08:00
Utils,
Actions,
MessageStore,
SearchableComponentStore,
SearchableComponentMaker,
} from 'mailspring-exports';
2017-07-12 23:46:59 +08:00
import {
Spinner,
RetinaImg,
MailLabelSet,
ScrollRegion,
MailImportantIcon,
KeyCommandsRegion,
InjectedComponentSet,
2017-09-27 02:46:00 +08:00
} from 'mailspring-component-kit';
2017-07-12 23:46:59 +08:00
2017-09-27 02:33:08 +08:00
import FindInThread from './find-in-thread';
import MessageItemContainer from './message-item-container';
2017-07-12 23:46:59 +08:00
class MessageListScrollTooltip extends React.Component {
static displayName = 'MessageListScrollTooltip';
static propTypes = {
2017-09-27 02:33:08 +08:00
viewportCenter: PropTypes.number.isRequired,
totalHeight: PropTypes.number.isRequired,
2017-07-12 23:46:59 +08:00
};
componentWillMount() {
this.setupForProps(this.props);
}
componentWillReceiveProps(newProps) {
this.setupForProps(newProps);
}
shouldComponentUpdate(newProps, newState) {
return !Utils.isEqualReact(this.state, newState);
}
setupForProps(props) {
// Technically, we could have MessageList provide the currently visible
// item index, but the DOM approach is simple and self-contained.
//
2017-09-27 02:33:08 +08:00
const els = document.querySelectorAll('.message-item-wrap');
let idx = Array.from(els).findIndex(el => el.offsetTop > props.viewportCenter);
2017-07-12 23:46:59 +08:00
if (idx === -1) {
idx = els.length;
}
this.setState({
idx: idx,
count: els.length,
});
}
render() {
return (
<div className="scroll-tooltip">
{this.state.idx} of {this.state.count}
</div>
);
}
}
class MessageList extends React.Component {
static displayName = 'MessageList';
static containerRequired = false;
static containerStyles = {
minWidth: 500,
maxWidth: 999999,
};
constructor(props) {
super(props);
this.state = this._getStateFromStores();
this.state.minified = true;
this._draftScrollInProgress = false;
this.MINIFY_THRESHOLD = 3;
}
componentDidMount() {
this._unsubscribers = [];
this._unsubscribers.push(MessageStore.listen(this._onChange));
2017-09-27 02:33:08 +08:00
this._unsubscribers.push(
Actions.focusDraft.listen(async ({ headerMessageId }) => {
Utils.waitFor(() => this._getMessageContainer(headerMessageId) !== undefined)
.then(() => this._focusDraft(this._getMessageContainer(headerMessageId)))
.catch(() => {
// may have been a popout composer
});
})
2017-09-27 02:33:08 +08:00
);
2017-07-12 23:46:59 +08:00
}
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();
}
}
_globalMenuItems() {
2017-09-27 02:33:08 +08:00
const toggleExpandedLabel = this.state.hasCollapsedItems ? 'Expand' : 'Collapse';
2017-07-12 23:46:59 +08:00
return [
{
2017-09-27 02:33:08 +08:00
label: 'Thread',
submenu: [
{
label: `${toggleExpandedLabel} conversation`,
command: 'message-list:toggle-expanded',
position: 'endof=view-actions',
},
],
2017-07-12 23:46:59 +08:00
},
];
}
_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}`];
}
_focusDraft(draftElement) {
// Note: We don't want the contenteditable view competing for scroll offset,
// so we block incoming childScrollRequests while we scroll to the new draft.
this._draftScrollInProgress = true;
draftElement.focus();
this._messageWrapEl.scrollTo(draftElement, {
2017-07-12 23:46:59 +08:00
position: ScrollRegion.ScrollPosition.Top,
settle: true,
done: () => {
2017-09-27 02:33:08 +08:00
this._draftScrollInProgress = false;
2017-07-12 23:46:59 +08:00
},
});
}
_onForward = () => {
if (!this.state.currentThread) {
return;
}
2017-09-27 02:33:08 +08:00
Actions.composeForward({ thread: this.state.currentThread });
};
2017-07-12 23:46:59 +08:00
_lastMessage() {
return (this.state.messages || []).filter(m => !m.draft).pop();
}
// Returns either "reply" or "reply-all"
_replyType() {
2017-09-27 02:36:58 +08:00
const defaultReplyType = AppEnv.config.get('core.sending.defaultReplyType');
2017-07-12 23:46:59 +08:00
const lastMessage = this._lastMessage();
if (!lastMessage) {
return 'reply';
}
if (lastMessage.canReplyAll()) {
return defaultReplyType === 'reply-all' ? 'reply-all' : 'reply';
}
return 'reply';
}
_onToggleAllMessagesExpanded = () => {
Actions.toggleAllMessagesExpanded();
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
_onPrintThread = () => {
2017-09-27 02:33:08 +08:00
const node = ReactDOM.findDOMNode(this);
Actions.printThread(this.state.currentThread, node.innerHTML);
};
2017-07-12 23:46:59 +08:00
_onPopThreadIn = () => {
if (!this.state.currentThread) {
return;
}
2017-09-27 02:33:08 +08:00
Actions.focusThreadMainWindow(this.state.currentThread);
2017-09-27 02:36:58 +08:00
AppEnv.close();
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
_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();
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
_onClickReplyArea = () => {
if (!this.state.currentThread) {
return;
}
Actions.composeReply({
thread: this.state.currentThread,
message: this._lastMessage(),
type: this._replyType(),
behavior: 'prefer-existing-if-pristine',
});
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
_messageElements() {
2017-09-27 02:33:08 +08:00
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
2017-09-27 02:36:58 +08:00
if (AppEnv.config.get('core.reading.descendingOrderMessageList')) {
messages = messages.reverse();
}
2017-07-12 23:46:59 +08:00
2017-09-27 02:33:08 +08:00
messages.forEach(message => {
if (message.type === 'minifiedBundle') {
elements.push(this._renderMinifiedBundle(message));
2017-07-12 23:46:59 +08:00
return;
}
const collapsed = !messagesExpandedState[message.id];
const isMostRecent = message === mostRecentMessage;
const isBeforeReplyArea = isMostRecent && hasReplyArea;
2017-07-12 23:46:59 +08:00
elements.push(
<MessageItemContainer
key={message.id}
ref={`message-container-${message.headerMessageId}`}
thread={currentThread}
message={message}
messages={messages}
collapsed={collapsed}
isMostRecent={isMostRecent}
2017-07-12 23:46:59 +08:00
isBeforeReplyArea={isBeforeReplyArea}
scrollTo={this._scrollTo}
/>
);
if (isBeforeReplyArea) {
elements.push(this._renderReplyArea());
}
2017-07-12 23:46:59 +08:00
});
2017-07-12 23:46:59 +08:00
return elements;
}
_messagesWithMinification(allMessages = []) {
if (!this.state.minified) {
return allMessages;
}
const messages = [].concat(allMessages);
2017-09-27 02:33:08 +08:00
const minifyRanges = [];
let consecutiveCollapsed = 0;
2017-07-12 23:46:59 +08:00
messages.forEach((message, idx) => {
// Never minify the 1st message
if (idx === 0) {
return;
}
const expandState = this.state.messagesExpandedState[message.id];
if (!expandState) {
2017-09-27 02:33:08 +08:00
consecutiveCollapsed += 1;
2017-07-12 23:46:59 +08:00
} 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.
2017-09-27 02:33:08 +08:00
const minifyOffset = expandState === 'default' ? 1 : 0;
2017-07-12 23:46:59 +08:00
if (consecutiveCollapsed >= this.MINIFY_THRESHOLD + minifyOffset) {
minifyRanges.push({
start: idx - consecutiveCollapsed,
2017-09-27 02:33:08 +08:00
length: consecutiveCollapsed - minifyOffset,
2017-07-12 23:46:59 +08:00
});
}
consecutiveCollapsed = 0;
}
});
let indexOffset = 0;
for (const range of minifyRanges) {
2017-09-27 02:33:08 +08:00
const start = range.start - indexOffset;
2017-07-12 23:46:59 +08:00
const minified = {
2017-09-27 02:33:08 +08:00
type: 'minifiedBundle',
2017-07-12 23:46:59 +08:00
messages: messages.slice(start, start + range.length),
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
messages.splice(start, range.length, minified);
// While we removed `range.length` items, we also added 1 back in.
2017-09-27 02:33:08 +08:00
indexOffset += range.length - 1;
2017-07-12 23:46:59 +08:00
}
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.
2017-09-27 02:33:08 +08:00
_scrollTo = ({ id, rect, position } = {}) => {
2017-07-12 23:46:59 +08:00
if (this._draftScrollInProgress) {
return;
}
if (id) {
const messageElement = this._getMessageContainer(id);
if (!messageElement) {
return;
}
this._messageWrapEl.scrollTo(messageElement, {
2017-07-12 23:46:59 +08:00
position: position !== undefined ? position : ScrollRegion.ScrollPosition.Visible,
});
} else if (rect) {
this._messageWrapEl.scrollToRect(rect, {
2017-07-12 23:46:59 +08:00
position: ScrollRegion.ScrollPosition.CenterIfInvisible,
});
} else {
2017-09-27 02:33:08 +08:00
throw new Error('onChildScrollRequest: expected id or rect');
2017-07-12 23:46:59 +08:00
}
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
2017-09-27 02:33:08 +08:00
_onScrollByPage = direction => {
const height = ReactDOM.findDOMNode(this._messageWrapEl).clientHeight;
this._messageWrapEl.scrollTop += height * direction;
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
_onChange = () => {
2017-09-27 02:33:08 +08:00
const newState = this._getStateFromStores();
2017-07-12 23:46:59 +08:00
if ((this.state.currentThread || {}).id !== (newState.currentThread || {}).id) {
newState.minified = true;
}
this.setState(newState);
2017-09-27 02:33:08 +08:00
};
2017-07-12 23:46:59 +08:00
_getStateFromStores() {
return {
2017-09-27 02:33:08 +08:00
messages: MessageStore.items() || [],
2017-07-12 23:46:59 +08:00
messagesExpandedState: MessageStore.itemsExpandedState(),
canCollapse: MessageStore.items().length > 1,
hasCollapsedItems: MessageStore.hasCollapsedItems(),
currentThread: MessageStore.thread(),
loading: MessageStore.itemsLoading(),
};
}
_renderSubject() {
2017-09-27 02:33:08 +08:00
let subject = this.state.currentThread.subject;
2017-07-12 23:46:59 +08:00
if (!subject || subject.length === 0) {
2017-09-27 02:33:08 +08:00
subject = '(No Subject)';
2017-07-12 23:46:59 +08:00
}
return (
<div className="message-subject-wrap">
<MailImportantIcon thread={this.state.currentThread} />
2017-09-27 02:33:08 +08:00
<div style={{ flex: 1 }}>
2017-07-12 23:46:59 +08:00
<span className="message-subject">{subject}</span>
<MailLabelSet
removable
includeCurrentCategories
messages={this.state.messages}
thread={this.state.currentThread}
/>
</div>
{this._renderIcons()}
</div>
);
}
_renderIcons() {
return (
<div className="message-icons-wrap">
{this._renderExpandToggle()}
<div onClick={this._onPrintThread}>
<RetinaImg name="print.png" title="Print Thread" mode={RetinaImg.Mode.ContentIsMask} />
</div>
{this._renderPopoutToggle()}
</div>
);
}
_renderExpandToggle() {
if (!this.state.canCollapse) {
2017-09-27 02:33:08 +08:00
return <span />;
2017-07-12 23:46:59 +08:00
}
return (
<div onClick={this._onToggleAllMessagesExpanded}>
<RetinaImg
2017-09-27 02:33:08 +08:00
name={this.state.hasCollapsedItems ? 'expand.png' : 'collapse.png'}
title={this.state.hasCollapsedItems ? 'Expand All' : 'Collapse All'}
2017-07-12 23:46:59 +08:00
mode={RetinaImg.Mode.ContentIsMask}
/>
</div>
);
}
_renderPopoutToggle() {
2017-09-27 02:36:58 +08:00
if (AppEnv.isThreadWindow()) {
2017-07-12 23:46:59 +08:00
return (
<div onClick={this._onPopThreadIn}>
2017-09-27 02:33:08 +08:00
<RetinaImg
name="thread-popin.png"
title="Pop thread in"
mode={RetinaImg.Mode.ContentIsMask}
/>
2017-07-12 23:46:59 +08:00
</div>
);
}
return (
<div onClick={this._onPopoutThread}>
2017-09-27 02:33:08 +08:00
<RetinaImg
name="thread-popout.png"
title="Popout thread"
mode={RetinaImg.Mode.ContentIsMask}
/>
2017-07-12 23:46:59 +08:00
</div>
);
}
_renderReplyArea() {
return (
<div className="footer-reply-area-wrap" onClick={this._onClickReplyArea} key="reply-area">
<div className="footer-reply-area">
<RetinaImg name={`${this._replyType()}-footer.png`} mode={RetinaImg.Mode.ContentIsMask} />
<span className="reply-text">Write a reply</span>
</div>
</div>
);
}
_renderMinifiedBundle(bundle) {
const BUNDLE_HEIGHT = 36;
const lines = bundle.messages.slice(0, 10);
const h = Math.round(BUNDLE_HEIGHT / lines.length);
return (
<div
className="minified-bundle"
2017-09-27 02:33:08 +08:00
onClick={() => this.setState({ minified: false })}
2017-07-12 23:46:59 +08:00
key={Utils.generateTempId()}
>
<div className="num-messages">{bundle.messages.length} older messages</div>
2017-09-27 02:33:08 +08:00
<div className="msg-lines" style={{ height: h * lines.length }}>
{lines.map((msg, i) => (
<div key={msg.id} style={{ height: h * 2, top: -h * i }} className="msg-line" />
))}
2017-07-12 23:46:59 +08:00
</div>
</div>
);
}
render() {
if (!this.state.currentThread) {
2017-09-27 02:33:08 +08:00
return <span />;
2017-07-12 23:46:59 +08:00
}
const wrapClass = classNames({
2017-09-27 02:33:08 +08:00
'messages-wrap': true,
ready: !this.state.loading,
});
2017-07-12 23:46:59 +08:00
const messageListClass = classNames({
2017-09-27 02:33:08 +08:00
'message-list': true,
'height-fix': SearchableComponentStore.searchTerm !== null,
2017-07-12 23:46:59 +08:00
});
return (
<KeyCommandsRegion
globalHandlers={this._globalKeymapHandlers()}
globalMenuItems={this._globalMenuItems()}
>
<FindInThread />
2017-07-12 23:46:59 +08:00
<div className={messageListClass} id="message-list">
<ScrollRegion
tabIndex="-1"
className={wrapClass}
scrollbarTickProvider={SearchableComponentStore}
scrollTooltipComponent={MessageListScrollTooltip}
2017-09-27 02:33:08 +08:00
ref={el => {
this._messageWrapEl = el;
}}
2017-07-12 23:46:59 +08:00
>
{this._renderSubject()}
2017-09-27 02:33:08 +08:00
<div className="headers" style={{ position: 'relative' }}>
2017-07-12 23:46:59 +08:00
<InjectedComponentSet
className="message-list-headers"
2017-09-27 02:33:08 +08:00
matching={{ role: 'MessageListHeaders' }}
exposedProps={{ thread: this.state.currentThread, messages: this.state.messages }}
2017-07-12 23:46:59 +08:00
direction="column"
/>
</div>
{this._messageElements()}
</ScrollRegion>
<Spinner visible={this.state.loading} />
</div>
</KeyCommandsRegion>
);
}
}
export default SearchableComponentMaker.extend(MessageList);