Add plaintext mail display and composition via new setting / Alt key #52

Most draft features are disabled in plaintext mode because I don’t think it’s worth trying to make them work unless this gains traction. Mostly doing this so we can add GPG / PGP / Keybase in the future.
This commit is contained in:
Ben Gotow 2020-02-19 23:25:02 -06:00
parent 6d78a90102
commit ea37c75596
45 changed files with 781 additions and 255 deletions

View file

@ -130,6 +130,9 @@ export default class SignatureComposerDropdown extends React.Component<
}
render() {
if (this.props.draft.plaintext) {
return <span />;
}
return (
<div className="signature-button-dropdown">
<ButtonDropdown

View file

@ -3,8 +3,12 @@ import { applySignature } from './signature-utils';
export default class SignatureComposerExtension extends ComposerExtension {
static prepareNewDraft = ({ draft }) => {
const signatureObj =
draft.from && draft.from[0] ? SignatureStore.signatureForEmail(draft.from[0].email) : null;
if (draft.plaintext) {
return;
}
const from = draft.from && draft.from[0];
const signatureObj = from ? SignatureStore.signatureForEmail(from.email) : null;
if (!signatureObj) {
return;
}

View file

@ -67,7 +67,7 @@
overflow: hidden;
min-height: 200px;
width: 100%;
&.empty {
opacity: 0.5;
pointer-events: none;
@ -91,12 +91,12 @@
.section-header {
margin-top: 10px;
&:first-child {
margin-top:0;
margin-top: 0;
}
}
textarea.raw-html {
font-family: monospace;
font-family: @font-family-monospace;
font-size: 0.9em;
width: 100%;
height: 240px;
@ -129,10 +129,13 @@
justify-content: center;
white-space: initial;
flex-shrink: 0;
top: 0; left: 0; right: 0; bottom: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: absolute;
display: flex;
.preview {
pointer-events: none;
transform: scale(0.25);
@ -162,7 +165,7 @@
flex-direction: row;
flex-wrap: wrap;
padding: @padding-base-horizontal 0;
.field.photo-picker {
display: block;
width: 100%;
@ -178,7 +181,8 @@
display: flex;
align-items: center;
justify-content: center;
&:hover, &.dropping {
&:hover,
&.dropping {
border: 1px solid @accent-primary-dark;
}
}

View file

@ -1,7 +1,7 @@
/* eslint jsx-a11y/tabindex-no-positive: 0 */
import React from 'react';
import ReactDOM from 'react-dom';
import { localized, PropTypes, Actions } from 'mailspring-exports';
import { localized, PropTypes, Actions, Message } from 'mailspring-exports';
import { Menu, RetinaImg } from 'mailspring-component-kit';
import TemplateStore from './template-store';
@ -101,7 +101,10 @@ class TemplatePopover extends React.Component<{ headerMessageId: string }> {
}
}
class TemplatePicker extends React.Component<{ headerMessageId: string }> {
class TemplatePicker extends React.Component<{
headerMessageId: string;
draft: Message;
}> {
static displayName = 'TemplatePicker';
static propTypes = {
@ -117,6 +120,9 @@ class TemplatePicker extends React.Component<{ headerMessageId: string }> {
};
render() {
if (this.props.draft.plaintext) {
return <span />;
}
return (
<button
tabIndex={-1}

View file

@ -95,7 +95,7 @@
}
textarea.raw-html {
font-family: monospace;
font-family: @font-family-monospace;
font-size: 0.9em;
width: 100%;
height: 240px;

View file

@ -18,6 +18,7 @@ import {
KeyCommandsRegion,
InjectedComponentSet,
ComposerEditor,
ComposerEditorPlaintext,
ComposerSupport,
} from 'mailspring-component-kit';
import { ComposerHeader } from './composer-header';
@ -155,38 +156,54 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
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()
);
}}
/>
{draft.plaintext ? (
<ComposerEditorPlaintext
ref={this.editor}
value={draft.body}
propsForPlugins={{ draft, session }}
onFileReceived={this._onFileReceived}
onDrop={e => this.dropzone.current._onDrop(e)}
onChange={body => {
session.changes.add({ body });
}}
/>
) : (
<>
<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">
@ -304,6 +321,8 @@ export default class ComposerView extends React.Component<ComposerViewProps, Com
headerMessageId: this.props.draft.headerMessageId,
onCreated: file => {
if (!this._mounted) return;
if (this.props.draft.plaintext) return;
if (Utils.shouldDisplayAsImage(file)) {
const { draft, session } = this.props;
const match = draft.files.find(f => f.id === file.id);

View file

@ -11,6 +11,23 @@
// Basic Rich Editor
.composer-editor-plaintext {
textarea {
padding: 13px 22px;
box-sizing: border-box;
resize: none;
min-height: @compose-min-height;
color: @text-color;
outline: none;
font-family: @font-family-monospace;
&:focus {
border: none;
box-shadow: none;
outline: none;
}
}
}
.RichEditor-root {
padding: 0 22px 0 22px;
min-height: @compose-min-height;

View file

@ -46,6 +46,10 @@ export default class LinkTrackingButton extends React.Component<{
}
render() {
if (this.props.draft.plaintext) {
return <span />;
}
return (
<MetadataComposerToggleButton
iconName="icon-composer-linktracking.png"

View file

@ -35,10 +35,13 @@ function forEachATagInBody(draftBodyRootNode, callback) {
*/
export default class LinkTrackingComposerExtension extends ComposerExtension {
static needsPerRecipientBodies(draft) {
return !!draft.metadataForPluginId(PLUGIN_ID);
return !draft.plaintext && !!draft.metadataForPluginId(PLUGIN_ID);
}
static applyTransformsForSending({ draftBodyRootNode, draft, recipient }) {
if (draft.plaintext) {
return;
}
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (!metadata) {
return;
@ -52,9 +55,7 @@ export default class LinkTrackingComposerExtension extends ComposerExtension {
return;
}
const encoded = encodeURIComponent(url);
const redirectUrl = `${PLUGIN_URL}/link/${draft.headerMessageId}/${
links.length
}?redirect=${encoded}`;
const redirectUrl = `${PLUGIN_URL}/link/${draft.headerMessageId}/${links.length}?redirect=${encoded}`;
links.push({
url,

View file

@ -65,16 +65,6 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
if (this._unlisten) this._unlisten();
}
_emailContent = () => {
// When showing quoted text, always return the pure content
if (this.props.showQuotedText) {
return this.props.content;
}
return QuotedHTMLTransformer.removeQuotedHTML(this.props.content, {
keepIfWholeBodyIsQuote: true,
});
};
_writeContent = () => {
if (this._iframeDocObserver) this._iframeDocObserver.disconnect();
@ -87,16 +77,35 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
// message bodies. This is particularly felt with <table> elements use
// the `border-collapse: collapse` css property while setting a
// `padding`.
const { message, showQuotedText } = this.props;
const styles = EmailFrameStylesStore.styles();
const restrictWidth = AppEnv.config.get('core.reading.restrictMaxWidth');
let content = this.props.content;
if (!showQuotedText && !message.plaintext) {
content = QuotedHTMLTransformer.removeQuotedHTML(content, {
keepIfWholeBodyIsQuote: true,
});
}
doc.open();
doc.write(
`<!DOCTYPE html>` +
(styles ? `<style>${styles}</style>` : '') +
`<div id='inbox-html-wrapper' class="${process.platform}">${this._emailContent()}</div>`
);
doc.close();
if (message.plaintext) {
doc.write(
`<!DOCTYPE html>` +
(styles ? `<style>${styles}</style>` : '') +
`<div id='inbox-plain-wrapper' class="${process.platform}"></div>`
);
doc.close();
doc.getElementById('inbox-plain-wrapper').innerText = content;
} else {
doc.write(
`<!DOCTYPE html>` +
(styles ? `<style>${styles}</style>` : '') +
`<div id='inbox-html-wrapper' class="${process.platform}">${content}</div>`
);
doc.close();
}
if (doc.body && restrictWidth) {
doc.body.classList.add('restrict-width');
@ -119,7 +128,7 @@ export default class EmailFrame extends React.Component<EmailFrameProps> {
try {
extension.renderedMessageBodyIntoDocument({
document: doc,
message: this.props.message,
message: message,
iframe: iframeEl,
});
} catch (e) {

View file

@ -134,29 +134,31 @@ export default class MessageItemBody extends React.Component<
let merged = body;
// Replace cid: references with the paths to downloaded files
this.props.message.files.filter(f => f.contentId).forEach(file => {
const download = this.props.downloads[file.id];
const safeContentId = Utils.escapeRegExp(file.contentId);
this.props.message.files
.filter(f => f.contentId)
.forEach(file => {
const download = this.props.downloads[file.id];
const safeContentId = Utils.escapeRegExp(file.contentId);
// Note: I don't like doing this with RegExp before the body is inserted into
// the DOM, but we want to avoid "could not load cid://" in the console.
// Note: I don't like doing this with RegExp before the body is inserted into
// the DOM, but we want to avoid "could not load cid://" in the console.
if (download && download.state !== 'finished') {
const inlineImgRegexp = new RegExp(
`<\\s*img.*src=['"]cid:${safeContentId}['"][^>]*>`,
'gi'
);
// Render a spinner
merged = merged.replace(
inlineImgRegexp,
() =>
'<img alt="spinner.gif" src="mailspring://message-list/assets/spinner.gif" style="-webkit-user-drag: none;">'
);
} else {
const cidRegexp = new RegExp(`cid:${safeContentId}(@[^'"]+)?`, 'gi');
merged = merged.replace(cidRegexp, `file://${AttachmentStore.pathForFile(file)}`);
}
});
if (download && download.state !== 'finished') {
const inlineImgRegexp = new RegExp(
`<\\s*img.*src=['"]cid:${safeContentId}['"][^>]*>`,
'gi'
);
// Render a spinner
merged = merged.replace(
inlineImgRegexp,
() =>
'<img alt="spinner.gif" src="mailspring://message-list/assets/spinner.gif" style="-webkit-user-drag: none;">'
);
} else {
const cidRegexp = new RegExp(`cid:${safeContentId}(@[^'"]+)?`, 'gi');
merged = merged.replace(cidRegexp, `file://${AttachmentStore.pathForFile(file)}`);
}
});
// Replace remaining cid: references - we will not display them since they'll
// throw "unknown ERR_UNKNOWN_URL_SCHEME". Show a transparent pixel so that there's

View file

@ -218,7 +218,13 @@ class MessageList extends React.Component<{}, MessageListState> {
);
if (isBeforeReplyArea) {
elements.push(this._renderReplyArea());
elements.push(
<MessageListReplyArea
key="reply-area"
onClick={this._onClickReplyArea}
replyType={this._replyType()}
/>
);
}
});
@ -365,17 +371,6 @@ class MessageList extends React.Component<{}, MessageListState> {
);
}
_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">{localized('Write a reply…')}</span>
</div>
</div>
);
}
_renderMinifiedBundle(bundle) {
const BUNDLE_HEIGHT = 36;
const lines = bundle.messages.slice(0, 10);
@ -450,4 +445,38 @@ class MessageList extends React.Component<{}, MessageListState> {
}
}
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);

View file

@ -35,8 +35,8 @@ window, but it uses a hot window which makes that difficult now. */
@text-color-heading: #434648;
@font-family-sans-serif: 'Nylas-Pro', 'Helvetica', sans-serif;
@font-family-serif: Georgia, "Times New Roman", Times, serif;
@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
@font-family-serif: Georgia, 'Times New Roman', Times, serif;
@font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
@font-family: @font-family-sans-serif;
@font-family-heading: @font-family-sans-serif;

View file

@ -46,6 +46,9 @@ export default class OpenTrackingButton extends React.Component<{
}
render() {
if (this.props.draft.plaintext) {
return <span />;
}
const enabledValue = {
open_count: 0,
open_data: [],

View file

@ -6,7 +6,7 @@ import { OpenTrackingMetadata } from './types';
export default class OpenTrackingComposerExtension extends ComposerExtension {
static needsPerRecipientBodies(draft) {
return !!draft.metadataForPluginId(PLUGIN_ID);
return !draft.plaintext && !!draft.metadataForPluginId(PLUGIN_ID);
}
static applyTransformsForSending({
@ -18,6 +18,10 @@ export default class OpenTrackingComposerExtension extends ComposerExtension {
draft: Message;
recipient?: Contact;
}) {
if (draft.plaintext) {
return;
}
// grab message metadata, if any
const messageUid = draft.headerMessageId;
const metadata = draft.metadataForPluginId(PLUGIN_ID) as OpenTrackingMetadata;

View file

@ -58,12 +58,13 @@ class PreferencesGeneral extends React.Component<{
render() {
return (
<div className="container-general">
<div style={{ display: 'flex' }}>
<div style={{ width: '50%', paddingRight: 15 }}>
<div className="two-columns-flexbox">
<div style={{ flex: 1 }}>
<WorkspaceSection config={this.props.config} configSchema={this.props.configSchema} />
<LanguageSection config={this.props.config} configSchema={this.props.configSchema} />
</div>
<div style={{ width: '50%', paddingLeft: 15 }}>
<div style={{ width: 30 }} />
<div style={{ flex: 1 }}>
<ConfigSchemaItem
configSchema={this.props.configSchema.properties.reading}
keyName={localized('Reading')}
@ -72,12 +73,12 @@ class PreferencesGeneral extends React.Component<{
/>
</div>
</div>
<div style={{ display: 'flex', paddingTop: 30 }}>
<div style={{ width: '50%', paddingRight: 15 }}>
<div className="two-columns-flexbox" style={{ paddingTop: 30 }}>
<div style={{ flex: 1 }}>
<SendingSection config={this.props.config} configSchema={this.props.configSchema} />
</div>
<div style={{ width: '50%', paddingLeft: 15 }}>
<div style={{ width: 30 }} />
<div style={{ flex: 1 }}>
<ConfigSchemaItem
configSchema={this.props.configSchema.properties.composing}
keyName={localized('Composing')}
@ -87,22 +88,22 @@ class PreferencesGeneral extends React.Component<{
</div>
</div>
<div style={{ display: 'flex', paddingTop: 30 }}>
<div style={{ width: '50%', paddingRight: 15 }}>
<div className="two-columns-flexbox" style={{ paddingTop: 30 }}>
<div style={{ flex: 1 }}>
<ConfigSchemaItem
configSchema={this.props.configSchema.properties.notifications}
keyName={localized('Notifications')}
keyPath="core.notifications"
config={this.props.config}
/>
<div className="platform-note platform-linux-only">
{localized(
'Mailspring desktop notifications on Linux require Zenity. You may need to install it with your package manager.'
)}
</div>
</div>
<div style={{ width: '50%', paddingLeft: 15 }}>
<div style={{ width: 30 }} />
<div style={{ flex: 1 }}>
<ConfigSchemaItem
configSchema={this.props.configSchema.properties.attachments}
keyName={localized('Attachments')}

View file

@ -137,7 +137,7 @@ export default class PreferencesKeymaps extends React.Component<
'You can choose a shortcut set to use keyboard shortcuts of familiar email clients. To edit a shortcut, click it in the list below and enter a replacement on the keyboard.'
)}
</p>
<div className="keymaps-columns-flexbox">
<div className="two-columns-flexbox">
<div style={{ flex: 1 }}>
{displayedKeybindings.slice(0, 3).map(this._renderBindingsSection)}
</div>

View file

@ -192,6 +192,13 @@
}
}
.container-general,
.container-keymaps {
.two-columns-flexbox {
display: flex;
}
}
.container-keymaps {
width: 100%;
max-width: 800px;
@ -211,10 +218,6 @@
}
}
.keymaps-columns-flexbox {
display: flex;
}
.shortcut-section-title {
border-bottom: 1px solid @border-color-divider;
margin: @padding-large-vertical * 1.5 0;
@ -224,7 +227,7 @@
padding: 3px 0;
color: @text-color-very-subtle;
.values {
font-family: monospace;
font-family: @font-family-monospace;
font-weight: 600;
color: @text-color;
display: inline-block;
@ -305,8 +308,9 @@ body.platform-linux {
@media (max-width: 680px) {
.preferences-wrap {
.container-keymaps {
.keymaps-columns-flexbox {
.container-keymaps,
.container-general {
.two-columns-flexbox {
flex-direction: column;
}
}

View file

@ -114,7 +114,8 @@ h1,
height: 60px;
}
#inbox-html-wrapper {
#inbox-html-wrapper,
#inbox-plain-wrapper {
margin-top: 10px;
}
@ -124,7 +125,8 @@ h1,
.message-item-wrap,
.message-item-white-wrap,
.message-item-area,
#inbox-html-wrapper {
#inbox-html-wrapper,
#inbox-plain-wrapper {
display: block !important;
width: auto !important;
height: auto !important;

View file

@ -91,6 +91,9 @@ export class TranslateComposerButton extends React.Component<{
// image from our package. `RetinaImg` will automatically chose the best image
// format for our display.
render() {
if (this.props.draft.plaintext) {
return <span />;
}
return (
<button
tabIndex={-1}

View file

@ -21,8 +21,8 @@
"core:next-item": "command+]",
"core:select-down": "shift+command+]",
"core:reply": "mod+r",
"core:reply-all": "mod+shift+r",
"core:reply": ["mod+r", "mod+alt+r"],
"core:reply-all": ["mod+shift+r", "mod+alt+shift+r"],
"core:forward": "mod+shift+f",
"core:report-as-spam": "mod+shift+j",
"core:mark-as-unread": "mod+shift+u",

View file

@ -49,9 +49,9 @@
"core:report-as-spam": "!",
"core:delete-item": "#",
"core:reply": ["r", "mod+r"],
"core:reply": ["r", "mod+r", "mod+alt+r"],
"core:reply-new-window": "shift+r",
"core:reply-all": ["a", "mod+shift+r"],
"core:reply-all": ["a", "mod+shift+r", "mod+alt+shift+r"],
"core:reply-all-new-window": "shift+a",
"core:forward": ["f", "mod+shift+f"],
"core:forward-new-window": "shift+f",

View file

@ -27,11 +27,11 @@
"core:report-as-spam": "!",
"core:delete-item": "#",
"core:reply": ["r", "mod+r"],
"core:reply": ["r", "mod+r", "mod+alt+r"],
"core:reply-new-window": "shift+r",
"core:reply-all": ["a", "mod+shift+r"],
"core:reply-all": ["a", "mod+shift+r", "mod+alt+shift+r"],
"core:reply-all-new-window": "shift+a",
"core:forward": ["f", "mod+shift+f"],
"core:forward": ["f", "mod+shift+f", "mod+alt+shift+f"],
"core:forward-new-window": "shift+f",
"core:remove-and-previous": ["}", "]"],

View file

@ -5,8 +5,8 @@
"core:delete-item": "mod+d",
"core:undo": "alt+backspace",
"composer:send-message": "alt+s",
"core:reply": "mod+r",
"core:reply-all": "mod+shift+r",
"core:reply": ["mod+r", "mod+alt+r"],
"core:reply-all": ["mod+shift+r", "mod+alt+shift+r"],
"application:new-message": ["mod+n", "mod+shift+m"],
"core:find-in-thread": ["mod+shift+f", "f4"],

View file

@ -60,6 +60,8 @@ function toggleBlockTypeWithBreakout(editor: Editor, type) {
}
}
export const BLOCKQUOTE_TYPE = 'blockquote';
export const BLOCK_CONFIG: {
[key: string]: IEditorToolbarConfigItem;
} = {
@ -91,7 +93,7 @@ export const BLOCK_CONFIG: {
},
},
blockquote: {
type: 'blockquote',
type: BLOCKQUOTE_TYPE,
tagNames: ['blockquote'],
render: props => <blockquote {...props.attributes}>{props.children}</blockquote>,
button: {
@ -240,7 +242,7 @@ function renderNode(props, editor: Editor = null, next = () => {}) {
const rules = [
{
deserialize(el, next) {
deserialize(el: HTMLElement, next) {
const tagName = el.tagName.toLowerCase();
let config = Object.values(BLOCK_CONFIG).find(c => c.tagNames.includes(tagName));
@ -248,6 +250,7 @@ const rules = [
// block elements with monospace font are translated to <code> blocks
if (
['div', 'blockquote'].includes(tagName) &&
!el.classList.contains('gmail_default') &&
(el.style.fontFamily || el.style.font || '').includes('monospace')
) {
config = BLOCK_CONFIG.code;
@ -327,7 +330,7 @@ export function allNodesInBFSOrder(value: Value) {
export function isQuoteNode(n: Node) {
return (
n.object === 'block' &&
(n.type === 'blockquote' ||
(n.type === BLOCKQUOTE_TYPE ||
(n.data && n.data.get('className') && n.data.get('className').includes('gmail_quote')))
);
}

View file

@ -0,0 +1,152 @@
import React from 'react';
import { wrapPlaintextWithSelection } from './plaintext';
import { handleFilePasted } from './composer-editor';
interface ComposerEditorPlaintextProps {
value: string;
propsForPlugins: any;
onChange: (value: string) => void;
className?: string;
onBlur?: (e: React.FocusEvent) => void;
onDrop?: (e: React.DragEvent) => void;
onFileReceived?: (path: string) => void;
}
export class ComposerEditorPlaintext extends React.Component<ComposerEditorPlaintextProps> {
_el: React.RefObject<HTMLTextAreaElement> = React.createRef<HTMLTextAreaElement>();
_wrapEl: React.RefObject<HTMLDivElement> = React.createRef<HTMLDivElement>();
constructor(props) {
super(props);
}
componentDidMount() {
this.updateHeight();
}
updateHeight = () => {
const el = this._el.current;
const wrapEl = this._wrapEl.current;
if (!el || !wrapEl) return;
wrapEl.style.height = el.style.height;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight + (el.offsetHeight - el.clientHeight)}px`;
wrapEl.style.height = el.style.height;
};
// Public methods required for compatibility with the rich text ComposerEditor.
focus = () => {
this.focusEndReplyText();
};
focusEndReplyText = () => {
if (!this._el.current) return;
this._el.current.focus();
const value = this._el.current.value;
const quoteStart = /(On [A-Za-z0-9]+|\n> |-------)/g.exec(value);
let pos = value.length;
if (quoteStart) {
pos = quoteStart.index;
while (value[pos - 1] === '\n') {
pos -= 1;
}
}
this._el.current.setSelectionRange(pos, pos, 'forward');
};
focusEndAbsolute = () => {
if (!this._el.current) return;
this._el.current.focus();
const pos = this._el.current.value.length;
this._el.current.setSelectionRange(pos, pos, 'forward');
};
removeQuotedText = () => {};
insertInlineAttachment = file => {};
onFocusIfBlurred = event => {
this._el.current.focus();
};
// When we receive focus, the default browser behavior is to move the insertion point
// to the end of text. If you tabbed in from the Subject field and there's quoted or
// forwarded text this is not great. For now, let's aggressively shift selection to
// the end of the user content.
onFocus = event => {
if (!this._el.current) return;
if (this._el.current.value.length === this._el.current.selectionStart) {
this.focusEndReplyText();
}
};
// When you backspace at the beginning of a line of quoted text that has content
// after the insertion point, our fancy wrapPlaintext method won't let you delete
// the `> ` because it'll keep re-wrapping the content following the insertion
// point onto a new line and helpfully inserting the `> `. To fix this, this hack
// deletes the entire `> ` in this scenario (as opposed to just the one ` ` char).
onKeyUp = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Backspace' && event.target instanceof HTMLTextAreaElement) {
const { value, selectionStart, selectionEnd, selectionDirection } = event.target;
const preceding = value.substr(0, selectionStart);
const precedingQuotePrefix = />+ $/.exec(preceding);
if (precedingQuotePrefix && selectionStart === selectionEnd) {
event.target.value =
value.substr(0, precedingQuotePrefix.index) + value.substr(selectionStart);
event.target.setSelectionRange(
precedingQuotePrefix.index,
precedingQuotePrefix.index,
selectionDirection as any
);
event.preventDefault();
}
}
};
onPaste = (event: React.ClipboardEvent<any>) => {
const { onFileReceived } = this.props;
if (onFileReceived && handleFilePasted(event.nativeEvent, onFileReceived)) {
event.preventDefault();
}
};
onChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
const wrapped = wrapPlaintextWithSelection(event.target);
this.props.onChange(wrapped.value);
if (value !== wrapped.value) {
event.target.value = wrapped.value;
event.target.setSelectionRange(wrapped.selectionStart, wrapped.selectionEnd, event.target
.selectionDirection as any);
}
this.updateHeight();
};
// Event Handlers
render() {
const { className, onDrop } = this.props;
return (
<div
ref={this._wrapEl}
className={`composer-editor-plaintext ${className || ''}`}
onDrop={onDrop}
>
<textarea
value={this.props.value}
cols={72}
wrap="hard"
onFocus={this.onFocus}
onChange={this.onChange}
onPaste={this.onPaste}
onKeyUp={this.onKeyUp}
ref={this._el}
/>
</div>
);
}
}

View file

@ -170,45 +170,8 @@ export class ComposerEditor extends React.Component<ComposerEditorProps> {
if (onFileReceived && event.clipboardData.items.length > 0) {
event.preventDefault();
const item = event.clipboardData.items[0];
// If the pasteboard has a file on it, stream it to a temporary
// file and fire our `onFilePaste` event.
if (item.kind === 'file') {
const temp = require('temp');
const blob = item.getAsFile();
const ext =
{
'image/png': '.png',
'image/jpg': '.jpg',
'image/tiff': '.tiff',
}[item.type] || '';
const reader = new FileReader();
reader.addEventListener('loadend', () => {
const buffer = Buffer.from(new Uint8Array(reader.result as any));
const tmpFolder = temp.path('-mailspring-attachment');
const tmpPath = path.join(tmpFolder, `Pasted File${ext}`);
fs.mkdir(tmpFolder, () => {
fs.writeFile(tmpPath, buffer, () => {
onFileReceived(tmpPath);
});
});
});
reader.readAsArrayBuffer(blob);
if (handleFilePasted(event, onFileReceived)) {
return;
} else {
const macCopiedFile = decodeURI(
ElectronClipboard.read('public.file-url').replace('file://', '')
);
const winCopiedFile = ElectronClipboard.read('FileNameW').replace(
new RegExp(String.fromCharCode(0), 'g'),
''
);
if (macCopiedFile.length || winCopiedFile.length) {
onFileReceived(macCopiedFile || winCopiedFile);
return;
}
}
}
@ -307,3 +270,51 @@ export class ComposerEditor extends React.Component<ComposerEditorProps> {
);
}
}
// Helpers
export function handleFilePasted(event: ClipboardEvent, onFileReceived: (path: string) => void) {
if (event.clipboardData.items.length === 0) {
return false;
}
const item = event.clipboardData.items[0];
// If the pasteboard has a file on it, stream it to a temporary
// file and fire our `onFilePaste` event.
if (item.kind === 'file') {
const temp = require('temp');
const blob = item.getAsFile();
const ext =
{
'image/png': '.png',
'image/jpg': '.jpg',
'image/tiff': '.tiff',
}[item.type] || '';
const reader = new FileReader();
reader.addEventListener('loadend', () => {
const buffer = Buffer.from(new Uint8Array(reader.result as any));
const tmpFolder = temp.path('-mailspring-attachment');
const tmpPath = path.join(tmpFolder, `Pasted File${ext}`);
fs.mkdir(tmpFolder, () => {
fs.writeFile(tmpPath, buffer, () => {
onFileReceived(tmpPath);
});
});
});
reader.readAsArrayBuffer(blob);
return true;
}
const macCopiedFile = decodeURI(ElectronClipboard.read('public.file-url').replace('file://', ''));
const winCopiedFile = ElectronClipboard.read('FileNameW').replace(
new RegExp(String.fromCharCode(0), 'g'),
''
);
if (macCopiedFile.length || winCopiedFile.length) {
onFileReceived(macCopiedFile || winCopiedFile);
return true;
}
return false;
}

View file

@ -17,7 +17,7 @@ import BaseMarkPlugins from './base-mark-plugins';
import TemplatePlugins, { VARIABLE_TYPE } from './template-plugins';
import SpellcheckPlugins from './spellcheck-plugins';
import UneditablePlugins, { UNEDITABLE_TYPE } from './uneditable-plugins';
import BaseBlockPlugins, { BLOCK_CONFIG } from './base-block-plugins';
import BaseBlockPlugins, { BLOCK_CONFIG, isQuoteNode } from './base-block-plugins';
import InlineAttachmentPlugins, { IMAGE_TYPE } from './inline-attachment-plugins';
import MarkdownPlugins from './markdown-plugins';
import LinkPlugins from './link-plugins';
@ -25,6 +25,7 @@ import EmojiPlugins, { EMOJI_TYPE } from './emoji-plugins';
import { Rule, ComposerEditorPlugin } from './types';
import './patch-chrome-ime';
import { deepenPlaintextQuote } from './plaintext';
export const schema = {
blocks: {
@ -399,10 +400,28 @@ export function convertToPlainText(value: Value) {
const serializeNode = (node: SlateNode) => {
if (node.object === 'block' && node.type === UNEDITABLE_TYPE) {
const html = node.data.get('html');
let html = node.data.get('html');
// On detatched DOM nodes (where the content is not actually rendered onscreen),
// innerText and textContent are the same and neither take into account CSS styles
// of the various elements. To make the conversion from HTML to text decently well,
// we insert newlines on </p> and <br> tags so their textContent contains them:
html = html.replace(/<\/p ?>/g, '\n\n</p>');
html = html.replace(/<br ?\/?>/g, '\n');
html = html.replace(/<\/div ?>/g, '\n</div>');
const div = document.createElement('div');
div.innerHTML = html;
return div.innerText;
// This creates a ton of extra newlines, so anywhere this more than two empty spaces
// we collapse them back down to two.
let text = div.textContent;
text = text.replace(/\n\n\n+/g, '\n\n').trim();
return text;
}
if (isQuoteNode(node)) {
const content = node.nodes.map(serializeNode).join('\n');
return deepenPlaintextQuote(content);
}
if (node.object === 'document' || (node.object === 'block' && Block.isBlockList(node.nodes))) {
return node.nodes.map(serializeNode).join('\n');

View file

@ -0,0 +1,112 @@
export const ZERO_WIDTH_SPACE = '\u200B';
export const SOFT_NEWLINE = '\n\u200B';
export const DEFAULT_LINE_WIDTH = 72;
// Given a string with the `> `, `>> ` quote syntax, prepends each
// line with another `>`, adding a trailing space only after the last `>`
export function deepenPlaintextQuote(text: string) {
return `\n${text}`.replace(/(\n>*) ?/g, '$1> ');
}
// Forcibly wrap lines to 72 characters, inserting special "soft" newlines that
// subsequent calls to `wrapPlaintext` can remove and re-wrap.
export function wrapPlaintext(text: string, lineWidth: number = DEFAULT_LINE_WIDTH) {
return wrapPlaintextWithSelection({ value: text, selectionStart: 0, selectionEnd: 0 }, lineWidth)
.value;
}
// Our editor uses zero-width spaces to differentiate between soft and hard newlines
// when you're editing. The newlines it automatically inserts when you hit the line
// width are followed by a ZWS, and it knows that it can subsequently treat these as
// spaces and not newlines when it re-runs the wrap algorithm.
export function wrapPlaintextWithSelection(
{
selectionStart,
selectionEnd,
value,
}: { selectionStart: number; selectionEnd: number; value: string },
lineWidth: number = DEFAULT_LINE_WIDTH
) {
let result = '';
let resultSelectionStart = undefined;
let resultSelectionEnd = undefined;
let resultLineLength = 0;
let word = '';
let valueOffset = 0;
const flushWord = () => {
if (resultLineLength + word.length > lineWidth) {
const line = result.substr(result.length - resultLineLength);
const lineQuotePrefixMatch = !word.startsWith('>') && /^>+ /.exec(line);
const lineQuotePrefix = lineQuotePrefixMatch ? lineQuotePrefixMatch[0] : '';
const newLine = lineQuotePrefix + word.trim();
if (
resultSelectionStart > result.length &&
resultSelectionStart <= result.length + word.length
) {
resultSelectionStart += SOFT_NEWLINE.length + newLine.length - word.length;
}
if (resultSelectionEnd > result.length && resultSelectionEnd <= result.length + word.length) {
resultSelectionEnd += SOFT_NEWLINE.length + newLine.length - word.length;
}
result += SOFT_NEWLINE + newLine;
resultLineLength = newLine.length;
} else {
result += word;
resultLineLength += word.length;
}
word = '';
};
const setMarksIfPastSelection = () => {
if (resultSelectionStart === undefined && selectionStart <= valueOffset) {
resultSelectionStart = result.length + word.length;
}
if (resultSelectionEnd === undefined && selectionEnd <= valueOffset) {
resultSelectionEnd = result.length + word.length;
}
};
while (valueOffset < value.length) {
let char = value[valueOffset];
setMarksIfPastSelection();
if (char === '\n' && value[valueOffset + 1] === ZERO_WIDTH_SPACE) {
char = ' ';
}
if (char === ZERO_WIDTH_SPACE) {
while (value[valueOffset + 1] === '>') {
valueOffset += 1;
}
if (value[valueOffset + 1] === ' ') {
valueOffset += 1;
}
// no-op
} else if (char === '\n') {
result += word;
result += '\n';
resultLineLength = 0;
word = '';
} else if (char === ' ') {
flushWord();
word += char;
} else {
word += char;
}
valueOffset += 1;
}
flushWord();
setMarksIfPastSelection();
return {
value: result,
selectionStart: resultSelectionStart,
selectionEnd: resultSelectionEnd,
};
}

View file

@ -144,6 +144,15 @@ export default {
composing: {
type: 'object',
properties: {
html: {
type: 'boolean',
default: true,
title: localized('Enable rich text and advanced editor features'),
note: localized(
'Many features are unavailable in plain-text mode. To create a single ' +
'plain-text draft, hold Alt or Option while clicking Compose or Reply.'
),
},
spellcheck: {
type: 'boolean',
default: true,

View file

@ -118,7 +118,7 @@ export class ComposerExtension {
*/
static applyTransformsForSending(args: {
draft: Message;
draftBodyRootNode: HTMLElement;
draftBodyRootNode?: HTMLElement; // Note: Only present for HTML messages
recipient?: Contact;
}) {}
}

View file

@ -151,6 +151,11 @@ export class Message extends ModelWithMetadata {
queryable: false,
}),
plaintext: Attributes.Boolean({
modelKey: 'plaintext',
queryable: false,
}),
version: Attributes.Number({
jsonKey: 'v',
modelKey: 'version',
@ -194,6 +199,8 @@ export class Message extends ModelWithMetadata {
public forwardedHeaderMessageId: string;
public folder: Folder;
/** indicates that "body" is plain text, not HTML */
public plaintext: boolean;
public body?: string;
static naturalSortOrder() {

View file

@ -45,7 +45,10 @@ export function extractTextFromHtml(html, param: { maxLength?: number } = {}) {
if (maxLength && html.length > maxLength) {
html = html.slice(0, maxLength);
}
return new DOMParser().parseFromString(html, 'text/html').body.innerText;
const body = new DOMParser().parseFromString(html, 'text/html').body;
body.querySelectorAll('style').forEach(el => el.remove());
body.querySelectorAll('script').forEach(el => el.remove());
return body.textContent.trim();
}
export function modelTypesReviver(k, v) {

View file

@ -15,29 +15,44 @@ import InlineStyleTransformer from '../../services/inline-style-transformer';
import SanitizeTransformer from '../../services/sanitize-transformer';
import DOMUtils from '../../dom-utils';
import { Thread } from '../models/Thread';
import { convertToPlainText, convertFromHTML } from '../../components/composer-editor/conversion';
import { wrapPlaintext, deepenPlaintextQuote } from '../../components/composer-editor/plaintext';
let DraftStore: typeof import('./draft-store').default = null;
export type ReplyType = 'reply' | 'reply-all';
export type ReplyBehavior = 'prefer-existing' | 'prefer-existing-if-pristine';
async function prepareBodyForQuoting(body) {
// TODO: Fix inline images
const cidRE = MessageUtils.cidRegexString;
// Be sure to match over multiple lines with [\s\S]*
// Regex explanation here: https://regex101.com/r/vO6eN2/1
let transformed = (body || '').replace(new RegExp(`<img.*${cidRE}[\\s\\S]*?>`, 'igm'), '');
transformed = await SanitizeTransformer.run(transformed, SanitizeTransformer.Preset.UnsafeOnly);
transformed = await InlineStyleTransformer.run(transformed);
return transformed;
}
class DraftFactory {
useHTML() {
const forcePlaintext = AppEnv.keymaps.getIsAltKeyDown();
return AppEnv.config.get('core.composing.html') && !forcePlaintext;
}
async prepareBodyForQuoting(message: Message) {
if (!this.useHTML()) {
return deepenPlaintextQuote(
message.plaintext ? message.body : convertToPlainText(convertFromHTML(message.body)).trim()
);
}
// TODO: Fix inline images
const cidRE = MessageUtils.cidRegexString;
const cidRegexp = new RegExp(`<img.*${cidRE}[\\s\\S]*?>`, 'igm');
// Be sure to match over multiple lines with [\s\S]*
// Regex explanation here: https://regex101.com/r/vO6eN2/1
let transformed = (message.body || '').replace(cidRegexp, '');
transformed = await SanitizeTransformer.run(transformed, SanitizeTransformer.Preset.UnsafeOnly);
transformed = await InlineStyleTransformer.run(transformed);
return transformed;
}
async createDraft(fields = {}) {
const account = this._accountForNewDraft();
const rich = this.useHTML();
const defaults = {
body: '<br/>',
body: rich ? '<br/>' : '',
subject: '',
version: 0,
unread: false,
@ -47,6 +62,7 @@ class DraftFactory {
date: new Date(),
draft: true,
pristine: true,
plaintext: !rich,
accountId: account.id,
cc: [],
bcc: [],
@ -61,6 +77,9 @@ class DraftFactory {
if (account.autoaddress.type === 'bcc') {
merged.bcc = (merged.bcc || []).concat(autoContacts);
}
if (merged.plaintext) {
merged.body = wrapPlaintext(merged.body);
}
return new Message(merged);
}
@ -131,7 +150,7 @@ class DraftFactory {
}
}
if (query.body) {
if (query.body && this.useHTML()) {
query.body = query.body.replace(/[\n\r]/g, '<br/>');
}
@ -161,7 +180,7 @@ class DraftFactory {
}
async createDraftForReply({ message, thread, type }) {
const prevBody = await prepareBodyForQuoting(message.body);
const prevBody = await this.prepareBodyForQuoting(message);
let participants = { to: [], cc: [] };
if (type === 'reply') {
participants = message.participantsForReply();
@ -177,7 +196,8 @@ class DraftFactory {
threadId: thread.id,
accountId: message.accountId,
replyToHeaderMessageId: message.headerMessageId,
body: `
body: this.useHTML()
? `
<br/>
<br/>
<div class="gmail_quote_attribution">${DOMUtils.escapeHTMLCharacters(
@ -188,23 +208,28 @@ class DraftFactory {
${prevBody}
<br/>
</blockquote>
`,
`
: `\n\n${message.replyAttributionLine()}\n${prevBody}`,
});
}
async createDraftForForward({ thread, message }) {
// Start downloading the attachments, if they haven't been already
message.files.forEach(f => Actions.fetchFile(f));
message.files.forEach((f: File) => Actions.fetchFile(f));
const formatContact = (cs: Contact[]) => {
const text = cs.map(c => c.toString()).join(', ');
return this.useHTML() ? DOMUtils.escapeHTMLCharacters(text) : text;
};
const contactsAsHtml = cs => DOMUtils.escapeHTMLCharacters(_.invoke(cs, 'toString').join(', '));
const fields = [];
if (message.from.length > 0) fields.push(`From: ${contactsAsHtml(message.from)}`);
if (message.from.length > 0) fields.push(`From: ${formatContact(message.from)}`);
fields.push(`${localized('Subject')}: ${message.subject}`);
fields.push(`${localized('Date')}: ${message.formattedDate()}`);
if (message.to.length > 0) fields.push(`${localized('To')}: ${contactsAsHtml(message.to)}`);
if (message.cc.length > 0) fields.push(`${localized('Cc')}: ${contactsAsHtml(message.cc)}`);
if (message.to.length > 0) fields.push(`${localized('To')}: ${formatContact(message.to)}`);
if (message.cc.length > 0) fields.push(`${localized('Cc')}: ${formatContact(message.cc)}`);
const body = await prepareBodyForQuoting(message.body);
const body = await this.prepareBodyForQuoting(message);
return this.createDraft({
subject: Utils.subjectWithPrefix(message.subject, 'Fwd:'),
@ -213,7 +238,8 @@ class DraftFactory {
threadId: thread.id,
accountId: message.accountId,
forwardedHeaderMessageId: message.headerMessageId,
body: `
body: this.useHTML()
? `
<br/>
<div class="gmail_quote">
<br>
@ -224,7 +250,10 @@ class DraftFactory {
${body}
<br/>
</div>
`,
`
: `\n\n---------- ${localized('Forwarded Message')} ---------\n\n${fields.join(
'\n'
)}\n\n${body}`,
});
}

View file

@ -54,7 +54,7 @@ class MessageBodyProcessor {
});
}
updateCacheForMessage = async changedMessage => {
updateCacheForMessage = async (changedMessage: Message) => {
// reprocess any subscription using the new message data. Note that
// changedMessage may not have a loaded body if it wasn't changed. In
// that case, we use the previous body. Note: metadata changes, etc.
@ -96,7 +96,7 @@ class MessageBodyProcessor {
return this._version;
}
subscribe(message, sendInitial, callback) {
subscribe(message: Message, sendInitial: boolean, callback) {
const sub = { message, callback };
if (sendInitial) {
@ -117,7 +117,7 @@ class MessageBodyProcessor {
};
}
async retrieve(message) {
async retrieve(message: Message) {
const key = this._key(message);
if (this._recentlyProcessedD[key]) {
return this._recentlyProcessedD[key];
@ -128,7 +128,7 @@ class MessageBodyProcessor {
return output;
}
retrieveCached(message) {
retrieveCached(message: Message) {
const key = this._key(message);
if (this._recentlyProcessedD[key]) {
return this._recentlyProcessedD[key];
@ -138,13 +138,13 @@ class MessageBodyProcessor {
// Private Methods
_key(message) {
_key(message: Message) {
// It's safe to key off of the message ID alone because we invalidate the
// cache whenever the message is persisted to the database.
return message.id;
}
async _process(message) {
async _process(message: Message) {
if (typeof message.body !== 'string') {
return { body: '', clipped: false };
}
@ -158,6 +158,13 @@ class MessageBodyProcessor {
clipped = true;
}
// We do NOT try to sanitize plaintext message bodies as if they were HTML
// because it destroys them. But this means that we need to be careful not to
// "splat" them directly into the DOM! (Instead use innerText to assign).
if (message.plaintext) {
return { body, clipped };
}
// Sanitizing <script> tags, etc. isn't necessary because we use CORS rules
// to prevent their execution and sandbox content in the iFrame, but we still
// want to remove contenteditable attributes and other strange things.

View file

@ -9,21 +9,43 @@ import { Composer as ComposerExtensionRegistry } from '../../registries/extensio
import { LocalizedErrorStrings } from '../../mailsync-process';
import { localized } from '../../intl';
import { AttributeValues } from '../models/model';
import { Contact } from '../models/contact';
import { ZERO_WIDTH_SPACE } from '../../components/composer-editor/plaintext';
function applyExtensionTransforms(draft, recipient) {
const extensions = ComposerExtensionRegistry.extensions();
const fragment = document.createDocumentFragment();
const draftBodyRootNode = document.createElement('root');
fragment.appendChild(draftBodyRootNode);
draftBodyRootNode.innerHTML = draft.body;
function applyExtensionTransforms(draft: Message, recipient: Contact) {
// Note / todo: This code assumes that:
// - any changes made to the draft (eg: metadata) should be saved.
// - any changes made to a HTML body will be made to draftBodyRootNode NOT to the draft.body
// - any changes made to a plaintext body will be made to draft.body.
for (const ext of extensions) {
const extApply = ext.applyTransformsForSending;
if (extApply) {
extApply({ draft, draftBodyRootNode, recipient });
const before = draft.body;
const extensions = ComposerExtensionRegistry.extensions().filter(
ext => !!ext.applyTransformsForSending
);
if (draft.plaintext) {
for (const ext of extensions) {
ext.applyTransformsForSending({ draft, recipient });
}
const after = draft.body;
draft.body = before;
return after;
} else {
const fragment = document.createDocumentFragment();
const draftBodyRootNode = document.createElement('root');
fragment.appendChild(draftBodyRootNode);
draftBodyRootNode.innerHTML = draft.body;
for (const ext of extensions) {
ext.applyTransformsForSending({ draft, draftBodyRootNode, recipient });
if (draft.body !== before) {
throw new Error(
'applyTransformsForSending should modify the HTML body DOM (draftBodyRootNode) not the draft.body.'
);
}
}
return draftBodyRootNode.innerHTML;
}
return draftBodyRootNode.innerHTML;
}
export class SendDraftTask extends Task {
@ -37,6 +59,12 @@ export class SendDraftTask extends Task {
ext => ext.needsPerRecipientBodies && ext.needsPerRecipientBodies(task.draft)
);
if (task.draft.plaintext) {
// Our editor uses zero-width spaces to differentiate between soft and hard newlines
// when you're editing, and we don't want to send these characters.
task.draft.body = task.draft.body.replace(new RegExp(ZERO_WIDTH_SPACE, 'g'), '');
}
if (separateBodies) {
task.perRecipientBodies = {
self: task.draft.body,

View file

@ -57,6 +57,7 @@ export * from '../components/attachment-items';
export const CodeSnippet: typeof import('../components/code-snippet').default;
export * from '../components/composer-editor/composer-editor';
export * from '../components/composer-editor/composer-editor-plaintext';
export const ComposerSupport: typeof import('../components/composer-editor/composer-support');

View file

@ -117,6 +117,7 @@ lazyLoadFrom('ImageAttachmentItem', 'attachment-items');
lazyLoad('CodeSnippet', 'code-snippet');
lazyLoad('ComposerEditor', 'composer-editor/composer-editor');
lazyLoad('ComposerEditorPlaintext', 'composer-editor/composer-editor-plaintext');
lazyLoad('ComposerSupport', 'composer-editor/composer-support');
lazyLoad('ScrollRegion', 'scroll-region');

View file

@ -107,10 +107,26 @@ export default class KeymapManager {
_removeTemplate?: Disposable;
_bindingsCache: {};
_commandsCache: {};
_altKeyDown: boolean = false;
EVENT_ALT_KEY_STATE_CHANGE = 'alt-key-state-change';
constructor({ configDirPath, resourcePath }) {
this.configDirPath = configDirPath;
this.resourcePath = resourcePath;
window.addEventListener('keydown', this.onCheckModifierState);
window.addEventListener('keyup', this.onCheckModifierState);
}
onCheckModifierState = (e: KeyboardEvent) => {
if (this._altKeyDown !== e.altKey) {
this._altKeyDown = e.altKey;
document.dispatchEvent(new CustomEvent(this.EVENT_ALT_KEY_STATE_CHANGE));
}
};
getIsAltKeyDown() {
return this._altKeyDown;
}
getUserKeymapPath() {

View file

@ -111,27 +111,23 @@ class ToolbarWindowControls extends React.Component<{}, { alt: boolean }> {
constructor(props) {
super(props);
this.state = { alt: false };
this.state = { alt: AppEnv.keymaps.getIsAltKeyDown() };
}
componentDidMount() {
if (process.platform === 'darwin') {
window.addEventListener('keydown', this._onAlt);
window.addEventListener('keyup', this._onAlt);
document.addEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this._onAlt);
}
}
componentWillUnmount() {
if (process.platform === 'darwin') {
window.removeEventListener('keydown', this._onAlt);
window.removeEventListener('keyup', this._onAlt);
document.removeEventListener(AppEnv.keymaps.EVENT_ALT_KEY_STATE_CHANGE, this._onAlt);
}
}
_onAlt = event => {
if (this.state.alt !== event.altKey) {
this.setState({ alt: event.altKey });
}
_onAlt = () => {
this.setState({ alt: AppEnv.keymaps.getIsAltKeyDown() });
};
_onMaximize = event => {

View file

@ -81,7 +81,7 @@
@font-family-sans-serif: 'Nylas-Pro', 'Helvetica', sans-serif;
@font-family-serif: Georgia, 'Times New Roman', Times, serif;
@font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
@font-family-monospace: 'DejaVu Sans Mono', Menlo, Monaco, Consolas, 'Courier New', monospace;
@font-family: @font-family-sans-serif;
@font-family-heading: @font-family-sans-serif;

View file

@ -76,6 +76,12 @@
font-family: system-ui;
}
#inbox-plain-wrapper {
font-family: @font-family-monospace;
font-size: 14px;
white-space: pre-line;
}
strong,
b,
.bold {

@ -1 +1 @@
Subproject commit e44852a479a3f9013e6fd51b3d36c27126c142e5
Subproject commit c4ed6069e4636265dd083739257025c534e3b6e2

59
package-lock.json generated
View file

@ -2375,10 +2375,21 @@
}
},
"eslint-utils": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz",
"integrity": "sha1-moUbqJ7nxGA0b5fPiTnHKYgn5RI=",
"dev": true
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz",
"integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==",
"dev": true,
"requires": {
"eslint-visitor-keys": "^1.1.0"
},
"dependencies": {
"eslint-visitor-keys": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz",
"integrity": "sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==",
"dev": true
}
}
},
"eslint-visitor-keys": {
"version": "1.0.0",
@ -3743,9 +3754,9 @@
}
},
"handlebars": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz",
"integrity": "sha1-trN8HO0DBrIh4JT8eso+wjsTG2c=",
"version": "4.7.3",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.3.tgz",
"integrity": "sha512-SRGwSYuNfx8DwHD/6InAPzD6RgeruWLT+B8e8a7gGs8FWgHzlExpTFMEq2IA6QpAfOClpKHy6+8IqTjeBCu6Kg==",
"dev": true,
"requires": {
"neo-async": "^2.6.0",
@ -3923,12 +3934,12 @@
}
},
"https-proxy-agent": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz",
"integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==",
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz",
"integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==",
"dev": true,
"requires": {
"agent-base": "^4.1.0",
"agent-base": "^4.3.0",
"debug": "^3.1.0"
}
},
@ -4598,9 +4609,9 @@
}
},
"lodash": {
"version": "4.17.11",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
"integrity": "sha1-s56mIp72B+zYniyN8SU2iRysm40=",
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"dev": true
},
"lodash._reinterpolate": {
@ -4658,9 +4669,9 @@
"dev": true
},
"lodash.merge": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz",
"integrity": "sha1-rcJdnLmbk5HFliTzefu6YNcRHVQ=",
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"lodash.pick": {
@ -10803,20 +10814,20 @@
"dev": true
},
"uglify-js": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz",
"integrity": "sha1-cEaBNFxTqLIHn7bOwpSwXq0kL/U=",
"version": "3.7.7",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.7.tgz",
"integrity": "sha512-FeSU+hi7ULYy6mn8PKio/tXsdSXN35lm4KgV2asx00kzrLU9Pi3oAslcJT70Jdj7PHX29gGUPOT6+lXGBbemhA==",
"dev": true,
"optional": true,
"requires": {
"commander": "~2.20.0",
"commander": "~2.20.3",
"source-map": "~0.6.1"
},
"dependencies": {
"commander": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz",
"integrity": "sha1-1YuytcHuj4ew00ACfp6U4iLFpCI=",
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"optional": true
}

View file

@ -77,7 +77,7 @@
"grunt-contrib-csslint": "0.5.x",
"grunt-contrib-less": "0.8.x",
"grunt-lesslint": "0.13.x",
"handlebars": "^4.1.1",
"handlebars": "^4.7.3",
"jasmine": "2.x.x",
"joanna": "0.0.9",
"license-extractor": "^1.0.4",