fix(composer): Stop parsing quoted text on each keystroke to prevent composer lag

Summary: We used to parse the quoted text on each keystroke in the composer for a reply so that we could continue to determine what was quoted text. However, that resulted in dramatically slow typing for replies to complex HTML emails. Now, the quoted text isn't a part of the reply until `prepareDraftForSyncback`, after all of the extensions have run their transformations—we use a marker to determine whether quoted text should be appended or not. The quoted text control is now a one-way operation—you can't hide the quoted text after showing it (Gmail-style).

Test Plan: Tested locally (but didn't run unit tests because they won't run on my machine...)

Reviewers: bengotow, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D3106
This commit is contained in:
Jackie Luo 2016-07-21 00:14:30 -07:00
parent e984a0c222
commit 87dba382a8
6 changed files with 85 additions and 56 deletions

View file

@ -1,6 +1,6 @@
import _ from 'underscore';
import React from 'react';
import ReactDOM from 'react-dom';
import _ from 'underscore'
import React from 'react'
import ReactDOM from 'react-dom'
import {remote} from 'electron'
import {
@ -9,8 +9,7 @@ import {
DraftStore,
DraftHelpers,
FileDownloadStore,
QuotedHTMLTransformer,
} from 'nylas-exports';
} from 'nylas-exports'
import {
DropZone,
@ -21,17 +20,17 @@ import {
KeyCommandsRegion,
OverlaidComponents,
InjectedComponentSet,
} from 'nylas-component-kit';
} from 'nylas-component-kit'
import FileUpload from './file-upload';
import ImageFileUpload from './image-file-upload';
import FileUpload from './file-upload'
import ImageFileUpload from './image-file-upload'
import ComposerEditor from './composer-editor';
import ComposerHeader from './composer-header';
import SendActionButton from './send-action-button';
import ComposerEditor from './composer-editor'
import ComposerHeader from './composer-header'
import SendActionButton from './send-action-button'
import ActionBarPlugins from './action-bar-plugins'
import Fields from './fields';
import Fields from './fields'
// 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
@ -55,6 +54,7 @@ export default class ComposerView extends React.Component {
super(props)
this.state = {
showQuotedText: DraftHelpers.isForwardedMessage(props.draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(props.draft),
}
}
@ -69,9 +69,11 @@ export default class ComposerView extends React.Component {
this._teardownForProps();
this._setupForProps(nextProps);
}
if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft)) {
if (DraftHelpers.isForwardedMessage(this.props.draft) !== DraftHelpers.isForwardedMessage(nextProps.draft) ||
DraftHelpers.shouldAppendQuotedText(this.props.draft) !== DraftHelpers.shouldAppendQuotedText(nextProps.draft)) {
this.setState({
showQuotedText: DraftHelpers.isForwardedMessage(nextProps.draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(nextProps.draft),
});
}
}
@ -118,6 +120,7 @@ export default class ComposerView extends React.Component {
_setupForProps({draft, session}) {
this.setState({
showQuotedText: DraftHelpers.isForwardedMessage(draft),
showQuotedTextControl: DraftHelpers.shouldAppendQuotedText(draft),
});
// TODO: This is a dirty hack to save selection state into the undo/redo
@ -203,7 +206,7 @@ export default class ComposerView extends React.Component {
_renderEditor() {
const exposedProps = {
body: this._removeQuotedText(this.props.draft.body),
body: this.props.draft.body,
draftClientId: this.props.draft.clientId,
parentActions: {
getComposerBoundingRect: this._getComposerBoundingRect,
@ -239,20 +242,10 @@ export default class ComposerView extends React.Component {
return ReactDOM.findDOMNode(this.refs.composerWrap).getBoundingClientRect()
}
_removeQuotedText = (html) => {
const {showQuotedText} = this.state;
return showQuotedText ? html : QuotedHTMLTransformer.removeQuotedHTML(html);
}
_showQuotedText = (html) => {
const {showQuotedText} = this.state;
return showQuotedText ? html : QuotedHTMLTransformer.appendQuotedHTML(html, this.props.draft.body);
}
_renderQuotedTextControl() {
if (QuotedHTMLTransformer.hasQuotedHTML(this.props.draft.body)) {
if (this.state.showQuotedTextControl) {
return (
<a className="quoted-text-control" onClick={this._onToggleQuotedText}>
<a className="quoted-text-control" onClick={this._onExpandQuotedText}>
<span className="dots">&bull;&bull;&bull;</span>
<span className="remove-quoted-text" onClick={this._onRemoveQuotedText}>
<RetinaImg
@ -267,17 +260,30 @@ export default class ComposerView extends React.Component {
return false;
}
_onToggleQuotedText = () => {
this.setState({showQuotedText: !this.state.showQuotedText});
_onExpandQuotedText = () => {
this.setState({
showQuotedText: true,
showQuotedTextControl: false,
}, () => {
DraftHelpers.appendQuotedTextToDraft(this.props.draft)
.then((draftWithQuotedText) => {
this.props.session.changes.add({
body: `${draftWithQuotedText.body}<div id="n1-quoted-text-marker"></div>`,
})
})
})
}
_onRemoveQuotedText = (event) => {
event.stopPropagation()
const {session, draft} = this.props
session.changes.add({
body: QuotedHTMLTransformer.removeQuotedHTML(draft.body),
body: `${draft.body}<div id="n1-quoted-text-marker"></div>`,
})
this.setState({
showQuotedText: false,
showQuotedTextControl: false,
})
this.setState({showQuotedText: false})
}
_renderFooterRegions() {
@ -508,7 +514,7 @@ export default class ComposerView extends React.Component {
}
_onBodyChanged = (event) => {
this.props.session.changes.add({body: this._showQuotedText(event.target.value)});
this.props.session.changes.add({body: event.target.value});
return;
}

View file

@ -43,7 +43,7 @@ class DecryptMessageButton extends React.Component
Actions.openPopover(
<PrivateKeyPopover
addresses={_.pluck(@props.message.to, "email")}
callback={() => @_openPassphrasePopover(popoverTarget, @decryptPopoverDone)}/>,
callback={=> @_openPassphrasePopover(popoverTarget, @decryptPopoverDone)}/>,
{originRect: popoverTarget, direction: 'down'}
)
else
@ -59,7 +59,7 @@ class DecryptMessageButton extends React.Component
Actions.openPopover(
<PrivateKeyPopover
addresses={_.pluck(@props.message.to, "email")}
callback={() => @_openPassphrasePopover(popoverTarget, @decryptAttachmentsPopoverDone)}/>,
callback={=> @_openPassphrasePopover(popoverTarget, @decryptAttachmentsPopoverDone)}/>,
{originRect: popoverTarget, direction: 'down'}
)
else
@ -81,9 +81,9 @@ class DecryptMessageButton extends React.Component
_openPassphrasePopover: (target, callback) =>
Actions.openPopover(
<PassphrasePopover addresses={_.pluck(@props.message.to, "email")} onPopoverDone={callback} />,
{originRect: target, direction: 'down'}
)
<PassphrasePopover addresses={_.pluck(@props.message.to, "email")} onPopoverDone={callback} />,
{originRect: target, direction: 'down'}
)
_noPrivateKeys: =>
numKeys = 0

View file

@ -15,7 +15,7 @@ class PassphrasePopover extends React.Component
mounted: true
}
componentDidMount: ->
componentDidMount: ->
@_mounted = true
componentWillUnmount: ->
@ -42,7 +42,7 @@ class PassphrasePopover extends React.Component
if event.keyCode == 13
@_validatePassphrase()
_validatePassphrase: () =>
_validatePassphrase: =>
passphrase = @state.passphrase
for emailIndex of @props.addresses
email = @props.addresses[emailIndex]

View file

@ -37,7 +37,7 @@ class PrivateKeyPopover extends React.Component
{if (@state.import or @state.paste) and !@state.validKeyBody and @state.keyBody != "" then errorBar}
{if @state.import or @state.paste then keyArea}
<div className="picker-controls">
<div style={{width: 80}}><button className="btn modal-cancel-button" onClick={() => Actions.closePopover()}>Cancel</button></div>
<div style={{width: 80}}><button className="btn modal-cancel-button" onClick={=> Actions.closePopover()}>Cancel</button></div>
<button className="btn modal-prefs-button" onClick={@_onClickAdvanced}>Advanced</button>
<div style={{width: 80}}>{saveButton}</div>
</div>

View file

@ -109,19 +109,9 @@ class DraftFactory
cc: cc,
from: [@_fromContactForReply(message)],
threadId: thread.id,
accountId: message.accountId
accountId: message.accountId,
replyToMessageId: message.id,
body: """
<br><br>
<div class="gmail_quote">
<br>
#{DOMUtils.escapeHTMLCharacters(message.replyAttributionLine())}
<br>
<blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
#{body}
</blockquote>
</div>"""
body: ""
)
createDraftForForward: ({thread, message}) =>
@ -141,7 +131,9 @@ class DraftFactory
threadId: thread.id,
accountId: message.accountId,
body: """
<br><br><div class="gmail_quote">
<br><br>
<div class="gmail_quote">
<br>
---------- Forwarded message ---------
<br><br>
#{fields.join('<br>')}

View file

@ -1,8 +1,10 @@
import _ from 'underscore'
import Actions from '../actions'
import DatabaseStore from './database-store'
import Message from '../models/message'
import * as ExtensionRegistry from '../../extension-registry'
import SyncbackDraftFilesTask from '../tasks/syncback-draft-files-task'
import DOMUtils from '../../dom-utils'
import QuotedHTMLTransformer from '../../services/quoted-html-transformer'
@ -32,6 +34,10 @@ export function isForwardedMessage({body, subject} = {}) {
return bodyForwarded || bodyFwd || subjectFwd
}
export function shouldAppendQuotedText({body = ''} = {}) {
return !body.includes('<div id="n1-quoted-text-marker">')
}
export function messageMentionsAttachment({body} = {}) {
if (body == null) { throw new Error('DraftHelpers::messageMentionsAttachment - Message has no body loaded') }
let cleaned = QuotedHTMLTransformer.removeQuotedHTML(body.toLowerCase().trim());
@ -48,6 +54,25 @@ export function queueDraftFileUploads(draft) {
}
}
export function appendQuotedTextToDraft(draft) {
return DatabaseStore.find(Message, draft.replyToMessageId)
.include(Message.attributes.body)
.then((prevMessage) => {
const quotedText = `
<div class="gmail_quote">
<br>
${DOMUtils.escapeHTMLCharacters(prevMessage.replyAttributionLine())}
<br>
<blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
${prevMessage.body}
</blockquote>
</div>`
draft.body = draft.body + quotedText
return Promise.resolve(draft)
})
}
export function applyExtensionTransformsToDraft(draft) {
let latestTransformed = draft
const extensions = ExtensionRegistry.Composer.extensions()
@ -88,9 +113,15 @@ export function applyExtensionTransformsToDraft(draft) {
export function prepareDraftForSyncback(session) {
return session.ensureCorrectAccount({noSyncback: true})
.then(() => applyExtensionTransformsToDraft(session.draft()))
.then((transformed) => (
DatabaseStore.inTransaction((t) => t.persistModel(transformed))
.then(() => Promise.resolve(queueDraftFileUploads(transformed)))
.thenReturn(transformed)
.then((transformed) => {
if (!shouldAppendQuotedText(transformed)) {
return Promise.resolve(transformed)
}
return appendQuotedTextToDraft(transformed)
})
.then((draft) => (
DatabaseStore.inTransaction((t) => t.persistModel(draft))
.then(() => Promise.resolve(queueDraftFileUploads(draft)))
.thenReturn(draft)
))
}