mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-02-24 08:04:11 +08:00
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
import { remote } from 'electron';
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import {
|
|
localized,
|
|
PropTypes,
|
|
Utils,
|
|
Actions,
|
|
DraftStore,
|
|
DraftEditingSession,
|
|
MessageWithEditorState,
|
|
} from 'mailspring-exports';
|
|
import {
|
|
DropZone,
|
|
RetinaImg,
|
|
ScrollRegion,
|
|
TabGroupRegion,
|
|
KeyCommandsRegion,
|
|
InjectedComponentSet,
|
|
ComposerEditor,
|
|
ComposerSupport,
|
|
} from 'mailspring-component-kit';
|
|
import { ComposerHeader } from './composer-header';
|
|
import { SendActionButton } from './send-action-button';
|
|
import { ActionBarPlugins } from './action-bar-plugins';
|
|
import { AttachmentsArea } from './attachments-area';
|
|
import { QuotedTextControl } from './quoted-text-control';
|
|
import Fields from './fields';
|
|
|
|
const {
|
|
hasBlockquote,
|
|
hasNonTrailingBlockquote,
|
|
hideQuotedTextByDefault,
|
|
} = ComposerSupport.BaseBlockPlugins;
|
|
|
|
interface ComposerViewProps {
|
|
draft: MessageWithEditorState;
|
|
session: DraftEditingSession;
|
|
className?: string;
|
|
}
|
|
interface ComposerViewState {
|
|
quotedTextHidden: boolean;
|
|
quotedTextPresent: boolean;
|
|
isDropping: boolean;
|
|
}
|
|
// The ComposerView is a unique React component because it (currently) is a
|
|
// singleton. Normally, the React way to do things would be to re-render the
|
|
// Composer with new props.
|
|
export default class ComposerView extends React.Component<ComposerViewProps, ComposerViewState> {
|
|
static displayName = 'ComposerView';
|
|
|
|
static propTypes = {
|
|
session: PropTypes.object.isRequired,
|
|
draft: PropTypes.object.isRequired,
|
|
className: PropTypes.string,
|
|
};
|
|
|
|
_mounted: boolean = false;
|
|
_mouseDownTarget: HTMLElement = null;
|
|
|
|
dropzone = React.createRef<DropZone>();
|
|
sendButton = React.createRef<SendActionButton>();
|
|
focusContainer = React.createRef<KeyCommandsRegion & HTMLDivElement>();
|
|
editor = React.createRef<ComposerEditor>();
|
|
header = React.createRef<ComposerHeader>();
|
|
|
|
_keymapHandlers = {
|
|
'composer:send-message': () => this.sendButton.current.primarySend(),
|
|
'composer:delete-empty-draft': () => this.props.draft.pristine && this._onDestroyDraft(),
|
|
'composer:show-and-focus-bcc': () => this.header.current.showAndFocusField(Fields.Bcc),
|
|
'composer:show-and-focus-cc': () => this.header.current.showAndFocusField(Fields.Cc),
|
|
'composer:focus-to': () => this.header.current.showAndFocusField(Fields.To),
|
|
'composer:show-and-focus-from': () => {},
|
|
'composer:select-attachment': () => this._onSelectAttachment(),
|
|
};
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
const draft = props.session.draft();
|
|
|
|
this.state = {
|
|
isDropping: false,
|
|
quotedTextPresent: hasBlockquote(draft.bodyEditorState),
|
|
quotedTextHidden: hideQuotedTextByDefault(draft),
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
const { date, files } = this.props.draft;
|
|
|
|
this._mounted = true;
|
|
|
|
files.forEach(file => {
|
|
if (Utils.shouldDisplayAsImage(file)) {
|
|
Actions.fetchFile(file);
|
|
}
|
|
});
|
|
|
|
// Note: the way this is implemented, `date` updates each time the draft is saved,
|
|
// so this will autoselect the draft if it has been edited or created in the last 3s.
|
|
const isBrandNew = Date.now() - (date instanceof Date ? date.getTime() : Number(date)) < 3000;
|
|
if (isBrandNew) {
|
|
(ReactDOM.findDOMNode(this) as HTMLElement).scrollIntoView(false);
|
|
window.requestAnimationFrame(() => this.focus());
|
|
}
|
|
}
|
|
|
|
componentDidUpdate() {
|
|
const { draft } = this.props;
|
|
|
|
// If the user has added an inline blockquote, show all the quoted text
|
|
// note: this is necessary because it's hidden with CSS that can't be
|
|
// made more specific.
|
|
if (this.state.quotedTextHidden && hasNonTrailingBlockquote(draft.bodyEditorState)) {
|
|
this.setState({ quotedTextHidden: false });
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this._mounted = false;
|
|
}
|
|
|
|
focus() {
|
|
if (!this._mounted || !this.header.current || !this.editor.current) return;
|
|
|
|
// If something within us already has focus, don't change it. Never, ever
|
|
// want to pull the cursor out from under the user while typing
|
|
const node = ReactDOM.findDOMNode(this.focusContainer.current);
|
|
if (node.contains(document.activeElement)) {
|
|
return;
|
|
}
|
|
|
|
if (this.props.draft.to.length === 0 || this.props.draft.subject.length === 0) {
|
|
this.header.current.focus();
|
|
} else {
|
|
this.editor.current.focus();
|
|
}
|
|
}
|
|
|
|
_renderContent() {
|
|
const restrictWidth = AppEnv.config.get('core.reading.restrictMaxWidth');
|
|
const { quotedTextHidden, quotedTextPresent } = this.state;
|
|
const { draft, session } = this.props;
|
|
|
|
return (
|
|
<div className={`composer-centered ${restrictWidth && 'restrict-width'}`}>
|
|
<ComposerHeader ref={this.header} draft={draft} session={session} />
|
|
<div
|
|
className="compose-body"
|
|
onMouseUp={this._onMouseUpComposerBody}
|
|
onMouseDown={this._onMouseDownComposerBody}
|
|
>
|
|
<div className="composer-body-wrap">
|
|
<ComposerEditor
|
|
ref={this.editor}
|
|
value={draft.bodyEditorState}
|
|
className={quotedTextHidden && 'hiding-quoted-text'}
|
|
propsForPlugins={{ draft, session }}
|
|
onFileReceived={this._onFileReceived}
|
|
onUpdatedSlateEditor={editor => session.setMountedEditor(editor)}
|
|
onDrop={e => this.dropzone.current._onDrop(e)}
|
|
onChange={change => {
|
|
// We minimize thrashing and support editors in multiple windows by ensuring
|
|
// non-value changes (eg focus) to the editorState don't trigger database saves
|
|
const skipSaving =
|
|
change.operations.size &&
|
|
change.operations.every(
|
|
op =>
|
|
op.type === 'set_selection' ||
|
|
(op.type === 'set_value' &&
|
|
Object.keys(op.properties).every(k => k === 'decorations'))
|
|
);
|
|
session.changes.add({ bodyEditorState: change.value }, { skipSaving });
|
|
}}
|
|
/>
|
|
<QuotedTextControl
|
|
quotedTextHidden={quotedTextHidden}
|
|
quotedTextPresent={quotedTextPresent}
|
|
onUnhide={() => this.setState({ quotedTextHidden: false })}
|
|
onRemove={() => {
|
|
this.setState({ quotedTextHidden: false }, () =>
|
|
this.editor.current.removeQuotedText()
|
|
);
|
|
}}
|
|
/>
|
|
<AttachmentsArea draft={draft} />
|
|
</div>
|
|
<div className="composer-footer-region">
|
|
<InjectedComponentSet
|
|
deferred
|
|
direction="column"
|
|
matching={{ role: 'Composer:Footer' }}
|
|
exposedProps={{
|
|
draft: draft,
|
|
threadId: draft.threadId,
|
|
headerMessageId: draft.headerMessageId,
|
|
session: session,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// This lets us click outside of the `contenteditable`'s `contentBody`
|
|
// and simulate what happens when you click beneath the text *in* the
|
|
// contentEditable.
|
|
|
|
// Unfortunately, we need to manually keep track of the "click" in
|
|
// separate mouseDown, mouseUp events because we need to ensure that the
|
|
// start and end target are both not in the contenteditable. This ensures
|
|
// that this behavior doesn't interfear with a click and drag selection.
|
|
_onMouseDownComposerBody = event => {
|
|
if (ReactDOM.findDOMNode(this.editor.current).contains(event.target)) {
|
|
this._mouseDownTarget = null;
|
|
} else {
|
|
this._mouseDownTarget = event.target;
|
|
}
|
|
};
|
|
|
|
_inFooterRegion(el) {
|
|
return el.closest && el.closest('.composer-footer-region');
|
|
}
|
|
|
|
_onMouseUpComposerBody = event => {
|
|
if (event.target === this._mouseDownTarget && !this._inFooterRegion(event.target)) {
|
|
// We don't set state directly here because we want the native
|
|
// contenteditable focus behavior. When the contenteditable gets focused
|
|
const bodyRect = (ReactDOM.findDOMNode(
|
|
this.editor.current
|
|
) as HTMLElement).getBoundingClientRect();
|
|
|
|
if (event.pageY < bodyRect.top) {
|
|
this.editor.current.focus();
|
|
} else {
|
|
if (this.state.quotedTextHidden) {
|
|
this.editor.current.focusEndReplyText();
|
|
} else {
|
|
this.editor.current.focusEndAbsolute();
|
|
}
|
|
}
|
|
}
|
|
this._mouseDownTarget = null;
|
|
};
|
|
|
|
_shouldAcceptDrop = event => {
|
|
// If the drag was initiated within Slate, let Slate handle it by falling through
|
|
// and not preventing default. Slate can drag-drop image (void) nodes on it's own.
|
|
if (event.dataTransfer.types.includes('application/x-slate-fragment')) {
|
|
return false;
|
|
}
|
|
|
|
// Ensure that you can't pick up a file and drop it on the same draft
|
|
const nonNativeFilePath = this._nonNativeFilePathForDrop(event);
|
|
const hasNativeFile = event.dataTransfer.types.includes('Files');
|
|
const hasNonNativeFilePath = nonNativeFilePath !== null;
|
|
|
|
return hasNativeFile || hasNonNativeFilePath;
|
|
};
|
|
|
|
_nonNativeFilePathForDrop = event => {
|
|
if (event.dataTransfer.types.includes('text/mailspring-file-url')) {
|
|
const downloadURL = event.dataTransfer.getData('text/mailspring-file-url');
|
|
const downloadFilePath = downloadURL.split('file://')[1];
|
|
if (downloadFilePath) {
|
|
return downloadFilePath;
|
|
}
|
|
}
|
|
|
|
// Accept drops of images from within the app
|
|
if (event.dataTransfer.types.includes('text/uri-list')) {
|
|
const uri = event.dataTransfer.getData('text/uri-list');
|
|
if (uri.indexOf('file://') === 0) {
|
|
return decodeURI(uri.split('file://')[1]);
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
_onDrop = event => {
|
|
// Accept drops of real files from other applications
|
|
for (const file of Array.from(event.dataTransfer.files)) {
|
|
this._onFileReceived((file as any).path);
|
|
event.preventDefault();
|
|
}
|
|
|
|
// Accept drops from attachment components / images within the app
|
|
const uri = this._nonNativeFilePathForDrop(event);
|
|
if (uri) {
|
|
this._onFileReceived(uri);
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
_onFileReceived = filePath => {
|
|
// called from onDrop and onFilePaste - assume images should be inline
|
|
Actions.addAttachment({
|
|
filePath: filePath,
|
|
headerMessageId: this.props.draft.headerMessageId,
|
|
onCreated: file => {
|
|
if (!this._mounted) return;
|
|
if (Utils.shouldDisplayAsImage(file)) {
|
|
const { draft, session } = this.props;
|
|
const match = draft.files.find(f => f.id === file.id);
|
|
if (!match) {
|
|
return;
|
|
}
|
|
match.contentId = Utils.generateContentId();
|
|
session.changes.add({
|
|
files: [...draft.files],
|
|
});
|
|
|
|
this.editor.current.insertInlineAttachment(file);
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
_isValidDraft = (options: { force?: boolean } = {}) => {
|
|
// We need to check the `DraftStore` because the `DraftStore` is
|
|
// immediately and synchronously updated as soon as this function
|
|
// fires. Since `setState` is asynchronous, if we used that as our only
|
|
// check, then we might get a false reading.
|
|
if (DraftStore.isSendingDraft(this.props.draft.headerMessageId)) {
|
|
return false;
|
|
}
|
|
|
|
const dialog = remote.dialog;
|
|
const { session } = this.props;
|
|
const { errors, warnings } = session.validateDraftForSending();
|
|
|
|
if (errors.length > 0) {
|
|
dialog.showMessageBox({
|
|
type: 'warning',
|
|
buttons: [localized('Edit Message'), localized('Cancel')],
|
|
message: localized('Cannot send message'),
|
|
detail: errors[0],
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (warnings.length > 0 && !options.force) {
|
|
const response = dialog.showMessageBox({
|
|
type: 'warning',
|
|
buttons: [localized('Send Anyway'), localized('Cancel')],
|
|
message: localized('Are you sure?'),
|
|
detail: warnings.join('. '),
|
|
});
|
|
if (response === 0) {
|
|
// response is button array index
|
|
return this._isValidDraft({ force: true });
|
|
}
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
_onDestroyDraft = () => {
|
|
Actions.destroyDraft(this.props.draft);
|
|
};
|
|
|
|
_onSelectAttachment = () => {
|
|
Actions.selectAttachment({ headerMessageId: this.props.draft.headerMessageId });
|
|
};
|
|
|
|
render() {
|
|
return (
|
|
<div className={this.props.className}>
|
|
<KeyCommandsRegion
|
|
localHandlers={this._keymapHandlers}
|
|
className={'message-item-white-wrap composer-outer-wrap'}
|
|
ref={this.focusContainer}
|
|
tabIndex={-1}
|
|
>
|
|
<TabGroupRegion className="composer-inner-wrap">
|
|
<DropZone
|
|
ref={this.dropzone}
|
|
className="composer-inner-wrap"
|
|
shouldAcceptDrop={this._shouldAcceptDrop}
|
|
onDragStateChange={({ isDropping }) => this.setState({ isDropping })}
|
|
onDrop={this._onDrop}
|
|
>
|
|
<DropToAttachCover visible={this.state.isDropping} />
|
|
|
|
<div className="composer-content-wrap">
|
|
{AppEnv.isComposerWindow() ? (
|
|
<ScrollRegion className="compose-body-scroll">
|
|
{this._renderContent()}
|
|
</ScrollRegion>
|
|
) : (
|
|
this._renderContent()
|
|
)}
|
|
</div>
|
|
|
|
<div className="composer-action-bar-workspace-wrap">
|
|
<InjectedComponentSet
|
|
deferred
|
|
matching={{ role: 'Composer:ActionBarWorkspace' }}
|
|
exposedProps={{
|
|
draft: this.props.draft,
|
|
threadId: this.props.draft.threadId,
|
|
headerMessageId: this.props.draft.headerMessageId,
|
|
session: this.props.session,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="composer-action-bar-wrap" data-tooltips-anchor>
|
|
<div className="tooltips-container" />
|
|
<div className="composer-action-bar-content">
|
|
<ActionBarPlugins
|
|
draft={this.props.draft}
|
|
session={this.props.session}
|
|
isValidDraft={this._isValidDraft}
|
|
/>
|
|
<DeleteButton onClick={this._onDestroyDraft} />
|
|
<AttachFileButton onClick={this._onSelectAttachment} />
|
|
|
|
<div style={{ order: 0, flex: 1 }} />
|
|
|
|
<SendActionButton
|
|
tabIndex={-1}
|
|
ref={this.sendButton}
|
|
style={{ order: -100 }}
|
|
draft={this.props.draft}
|
|
isValidDraft={this._isValidDraft}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DropZone>
|
|
</TabGroupRegion>
|
|
</KeyCommandsRegion>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
const DropToAttachCover = (props: { visible: boolean }) => (
|
|
<div className="composer-drop-cover" style={{ display: props.visible ? 'block' : 'none' }}>
|
|
<div className="centered">
|
|
<RetinaImg name="composer-drop-to-attach.png" mode={RetinaImg.Mode.ContentIsMask} />
|
|
{localized(`Drop to Attach`)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const AttachFileButton = (props: { onClick: () => void }) => (
|
|
<button
|
|
tabIndex={-1}
|
|
className="btn btn-toolbar btn-attach"
|
|
style={{ order: 0 }}
|
|
title={localized('Attach File')}
|
|
onClick={props.onClick}
|
|
>
|
|
<RetinaImg name="icon-composer-attachment.png" mode={RetinaImg.Mode.ContentIsMask} />
|
|
</button>
|
|
);
|
|
|
|
const DeleteButton = (props: { onClick: () => void }) => (
|
|
<button
|
|
tabIndex={-1}
|
|
className="btn btn-toolbar btn-trash"
|
|
style={{ order: 100 }}
|
|
title={localized('Delete Draft')}
|
|
onClick={props.onClick}
|
|
>
|
|
<RetinaImg name="icon-composer-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
|
|
</button>
|
|
);
|