mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-10-09 20:58:12 +08:00
* Some UX improvements (plus .gitignore tweak)
Made a few UX improvements:
- The send later delay can be skipped by clicking the new `Send now instead` button (localization required)
- If there is a range selection in the subject line then a context menu pops up on right click (for those that are allergic to keyboard shortcuts 😂)
- Message participant tweaks:
- you can now open the context menu on the name or email, not just the email
- you don't need to expand the participants to open the context menu
- name is shown over the email for `Email "Name else Email"` context menu item
- added `Copy "Email"` to context menu when nothing is selected/highlighted and just `Copy` when something is
- **for devs:** pretty console messages now consider your device theme and use an appropriate colour
* Prettier adjustments
* Revert `.gitignore` change
* Fix & actual prettier-ing
- Fix email context menu (now email and name can be alt-clicked)
- Sorted auto format (sorry didn't realise it was off)
* Use localised date/time format
Use a localised date format rather than forcing the Americanised format. This does loose the ordinal in `fullTimeString(Date)`
* Use weekday(Mon-Sun), for recent emails
- Changed recent emails to:
- display weekday and time (e.g Mon, 10:15)
- the recent emails to be 5 days, rather than 2
- Removed dead code
- Removed unnecessary import
* Remove unnecessary log
* Add key to prevent console error
Added a key to prevent a `Each child in an array or iterator should have a unique "key" prop` error in the console
* Add a key to "more" span
Co-authored-by: Ben Gotow <ben@foundry376.com>
493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
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<Record<string, unknown>, 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(
|
|
<MessageItemContainer
|
|
key={message.id}
|
|
ref={`message-container-${message.headerMessageId}`}
|
|
thread={currentThread}
|
|
message={message}
|
|
messages={messages}
|
|
collapsed={collapsed}
|
|
isMostRecent={isMostRecent}
|
|
isBeforeReplyArea={isBeforeReplyArea}
|
|
scrollTo={this._scrollTo}
|
|
/>
|
|
);
|
|
|
|
if (isBeforeReplyArea) {
|
|
elements.push(
|
|
<MessageListReplyArea
|
|
key="reply-area"
|
|
onClick={this._onClickReplyArea}
|
|
replyType={this._replyType()}
|
|
/>
|
|
);
|
|
}
|
|
});
|
|
|
|
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 (
|
|
<div className="message-subject-wrap">
|
|
<MailImportantIcon thread={this.state.currentThread} />
|
|
<div style={{ flex: 1 }}>
|
|
<span className="message-subject" onContextMenu={() => _onSubjectContextMenu()}>
|
|
{subject}
|
|
</span>
|
|
<MailLabelSet
|
|
removable
|
|
includeCurrentCategories
|
|
messages={this.state.messages}
|
|
thread={this.state.currentThread}
|
|
/>
|
|
</div>
|
|
<SubjectLineIcons
|
|
canCollapse={this.state.canCollapse}
|
|
hasCollapsedItems={this.state.hasCollapsedItems}
|
|
onPrint={this._onPrintThread}
|
|
onPopIn={this._onPopThreadIn}
|
|
onPopOut={this._onPopoutThread}
|
|
onToggleAllExpanded={this._onToggleAllMessagesExpanded}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
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 (
|
|
<div
|
|
className="minified-bundle"
|
|
onClick={() => this.setState({ minified: false })}
|
|
key={Utils.generateTempId()}
|
|
>
|
|
<div className="num-messages">{`${bundle.messages.length} ${localized(
|
|
'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,
|
|
});
|
|
|
|
return (
|
|
<KeyCommandsRegion globalHandlers={this._globalKeymapHandlers()}>
|
|
<FindInThread />
|
|
<div
|
|
id="message-list"
|
|
className={classNames({
|
|
'message-list': true,
|
|
'restrict-width': AppEnv.config.get(PREF_RESTRICT_WIDTH),
|
|
'height-fix': SearchableComponentStore.searchTerm !== null,
|
|
})}
|
|
>
|
|
<ScrollRegion
|
|
tabIndex={-1}
|
|
className={wrapClass}
|
|
scrollbarTickProvider={SearchableComponentStore}
|
|
scrollTooltipComponent={MessageListScrollTooltip}
|
|
ref={el => {
|
|
this._messageWrapEl = el;
|
|
}}
|
|
>
|
|
{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>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<div className="footer-reply-area-wrap" onClick={this.props.onClick}>
|
|
<div className="footer-reply-area">
|
|
<RetinaImg
|
|
name={`${this.props.replyType}-footer.png`}
|
|
mode={RetinaImg.Mode.ContentIsMask}
|
|
/>
|
|
<span className="reply-text">
|
|
{this.state.forcePlaintext
|
|
? localized('Write a plain text reply…')
|
|
: localized('Write a reply…')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
export default SearchableComponentMaker.extend(MessageList);
|