2017-07-12 23:46:59 +08:00
|
|
|
import React from 'react'
|
|
|
|
import ReactDOM from 'react-dom'
|
|
|
|
import classNames from 'classnames'
|
|
|
|
|
|
|
|
import {
|
|
|
|
Utils,
|
|
|
|
Actions,
|
|
|
|
MessageStore,
|
|
|
|
SearchableComponentStore,
|
|
|
|
SearchableComponentMaker,
|
|
|
|
} from "nylas-exports"
|
|
|
|
|
|
|
|
import {
|
|
|
|
Spinner,
|
|
|
|
RetinaImg,
|
|
|
|
MailLabelSet,
|
|
|
|
ScrollRegion,
|
|
|
|
MailImportantIcon,
|
|
|
|
KeyCommandsRegion,
|
|
|
|
InjectedComponentSet,
|
|
|
|
} from 'nylas-component-kit'
|
|
|
|
|
|
|
|
import FindInThread from './find-in-thread'
|
|
|
|
import MessageItemContainer from './message-item-container'
|
|
|
|
|
|
|
|
class MessageListScrollTooltip extends React.Component {
|
|
|
|
static displayName = 'MessageListScrollTooltip';
|
|
|
|
static propTypes = {
|
|
|
|
viewportCenter: React.PropTypes.number.isRequired,
|
|
|
|
totalHeight: React.PropTypes.number.isRequired,
|
|
|
|
};
|
|
|
|
|
|
|
|
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.
|
|
|
|
//
|
|
|
|
const els = document.querySelectorAll('.message-item-wrap')
|
|
|
|
let idx = Array.from(els).findIndex((el) => el.offsetTop > props.viewportCenter);
|
|
|
|
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));
|
|
|
|
this._unsubscribers.push(Actions.focusDraft.listen(async ({headerMessageId}) => {
|
|
|
|
await Utils.waitFor(() => this._getMessageContainer(headerMessageId) !== undefined)
|
|
|
|
this._focusDraft(this._getMessageContainer(headerMessageId))
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
|
|
|
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() {
|
|
|
|
const toggleExpandedLabel = this.state.hasCollapsedItems ? "Expand" : "Collapse";
|
|
|
|
return [
|
|
|
|
{
|
|
|
|
label: "Thread",
|
|
|
|
submenu: [{
|
|
|
|
label: `${toggleExpandedLabel} conversation`,
|
|
|
|
command: "message-list:toggle-expanded",
|
|
|
|
position: "endof=view-actions",
|
|
|
|
}],
|
|
|
|
},
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
_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();
|
2017-07-24 07:19:39 +08:00
|
|
|
this._messageWrapEl.scrollTo(draftElement, {
|
2017-07-12 23:46:59 +08:00
|
|
|
position: ScrollRegion.ScrollPosition.Top,
|
|
|
|
settle: true,
|
|
|
|
done: () => {
|
|
|
|
this._draftScrollInProgress = false
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
_onForward = () => {
|
|
|
|
if (!this.state.currentThread) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
Actions.composeForward({thread: this.state.currentThread})
|
|
|
|
}
|
|
|
|
|
|
|
|
_lastMessage() {
|
|
|
|
return (this.state.messages || []).filter(m => !m.draft).pop();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns either "reply" or "reply-all"
|
|
|
|
_replyType() {
|
|
|
|
const defaultReplyType = NylasEnv.config.get('core.sending.defaultReplyType');
|
|
|
|
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)
|
|
|
|
Actions.printThread(this.state.currentThread, node.innerHTML)
|
|
|
|
}
|
|
|
|
|
|
|
|
_onPopThreadIn = () => {
|
|
|
|
if (!this.state.currentThread) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
Actions.focusThreadMainWindow(this.state.currentThread)
|
|
|
|
NylasEnv.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 = []
|
|
|
|
|
|
|
|
const lastMessage = this.state.messages[this.state.messages.length - 1];
|
|
|
|
const hasReplyArea = lastMessage && !lastMessage.draft;
|
|
|
|
const messages = this._messagesWithMinification(this.state.messages)
|
|
|
|
|
|
|
|
messages.forEach((message, idx) => {
|
|
|
|
if (message.type === "minifiedBundle") {
|
|
|
|
elements.push(this._renderMinifiedBundle(message))
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const collapsed = !messagesExpandedState[message.id];
|
|
|
|
const isLastMsg = (messages.length - 1 === idx);
|
|
|
|
const isBeforeReplyArea = isLastMsg && hasReplyArea;
|
|
|
|
|
|
|
|
elements.push(
|
|
|
|
<MessageItemContainer
|
|
|
|
key={message.id}
|
|
|
|
ref={`message-container-${message.headerMessageId}`}
|
|
|
|
thread={currentThread}
|
|
|
|
message={message}
|
|
|
|
messages={messages}
|
|
|
|
collapsed={collapsed}
|
|
|
|
isLastMsg={isLastMsg}
|
|
|
|
isBeforeReplyArea={isBeforeReplyArea}
|
|
|
|
scrollTo={this._scrollTo}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
});
|
|
|
|
if (hasReplyArea) {
|
|
|
|
elements.push(this._renderReplyArea());
|
|
|
|
}
|
|
|
|
return elements;
|
|
|
|
}
|
|
|
|
|
|
|
|
_messagesWithMinification(allMessages = []) {
|
|
|
|
if (!this.state.minified) {
|
|
|
|
return allMessages;
|
|
|
|
}
|
|
|
|
|
|
|
|
const messages = [].concat(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 = ({id, rect, position} = {}) => {
|
|
|
|
if (this._draftScrollInProgress) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (id) {
|
|
|
|
const messageElement = this._getMessageContainer(id);
|
|
|
|
if (!messageElement) {
|
|
|
|
return;
|
|
|
|
}
|
2017-07-24 07:19:39 +08:00
|
|
|
this._messageWrapEl.scrollTo(messageElement, {
|
2017-07-12 23:46:59 +08:00
|
|
|
position: position !== undefined ? position : ScrollRegion.ScrollPosition.Visible,
|
|
|
|
});
|
|
|
|
} else if (rect) {
|
2017-07-24 07:19:39 +08:00
|
|
|
this._messageWrapEl.scrollToRect(rect, {
|
2017-07-12 23:46:59 +08:00
|
|
|
position: ScrollRegion.ScrollPosition.CenterIfInvisible,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
throw new Error("onChildScrollRequest: expected id or rect")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_onScrollByPage = (direction) => {
|
2017-07-24 07:19:39 +08:00
|
|
|
const height = ReactDOM.findDOMNode(this._messageWrapEl).clientHeight;
|
|
|
|
this._messageWrapEl.scrollTop += height * direction;
|
2017-07-12 23:46:59 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
_onChange = () => {
|
|
|
|
const newState = this._getStateFromStores()
|
|
|
|
if ((this.state.currentThread || {}).id !== (newState.currentThread || {}).id) {
|
|
|
|
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 = "(No Subject)";
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="message-subject-wrap">
|
|
|
|
<MailImportantIcon thread={this.state.currentThread} />
|
|
|
|
<div style={{flex: 1}}>
|
|
|
|
<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) {
|
|
|
|
return (<span />);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div onClick={this._onToggleAllMessagesExpanded}>
|
|
|
|
<RetinaImg
|
|
|
|
name={this.state.hasCollapsedItems ? "expand.png" : "collapse.png"}
|
|
|
|
title={this.state.hasCollapsedItems ? "Expand All" : "Collapse All"}
|
|
|
|
mode={RetinaImg.Mode.ContentIsMask}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
_renderPopoutToggle() {
|
|
|
|
if (NylasEnv.isThreadWindow()) {
|
|
|
|
return (
|
|
|
|
<div onClick={this._onPopThreadIn}>
|
|
|
|
<RetinaImg name="thread-popin.png" title="Pop thread in" mode={RetinaImg.Mode.ContentIsMask} />
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return (
|
|
|
|
<div onClick={this._onPopoutThread}>
|
|
|
|
<RetinaImg name="thread-popout.png" title="Popout thread" mode={RetinaImg.Mode.ContentIsMask} />
|
|
|
|
</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"
|
|
|
|
onClick={() => this.setState({minified: false})}
|
|
|
|
key={Utils.generateTempId()}
|
|
|
|
>
|
|
|
|
<div className="num-messages">{bundle.messages.length} older messages</div>
|
|
|
|
<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" />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
render() {
|
|
|
|
if (!this.state.currentThread) {
|
|
|
|
return (<span />);
|
|
|
|
}
|
|
|
|
|
|
|
|
const wrapClass = classNames({
|
|
|
|
"messages-wrap": true,
|
|
|
|
"ready": !this.state.loading,
|
|
|
|
})
|
|
|
|
|
|
|
|
const messageListClass = classNames({
|
|
|
|
"message-list": true,
|
|
|
|
"height-fix": SearchableComponentStore.searchTerm !== null,
|
|
|
|
});
|
|
|
|
|
|
|
|
return (
|
|
|
|
<KeyCommandsRegion
|
|
|
|
globalHandlers={this._globalKeymapHandlers()}
|
|
|
|
globalMenuItems={this._globalMenuItems()}
|
|
|
|
>
|
2017-07-24 07:19:39 +08:00
|
|
|
<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-07-24 07:19:39 +08:00
|
|
|
ref={(el) => { this._messageWrapEl = el }}
|
2017-07-12 23:46:59 +08:00
|
|
|
>
|
|
|
|
{this._renderSubject()}
|
|
|
|
<div className="headers" style={{position: 'relative'}}>
|
|
|
|
<InjectedComponentSet
|
|
|
|
className="message-list-headers"
|
|
|
|
matching={{role: "MessageListHeaders"}}
|
|
|
|
exposedProps={{thread: this.state.currentThread, messages: this.state.messages}}
|
|
|
|
direction="column"
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
{this._messageElements()}
|
|
|
|
</ScrollRegion>
|
|
|
|
<Spinner visible={this.state.loading} />
|
|
|
|
</div>
|
|
|
|
</KeyCommandsRegion>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export default SearchableComponentMaker.extend(MessageList);
|