mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-22 08:16:09 +08:00
9859e594f2
Summary: When you're typing at the end of the composer, then the composer should look like it's scrolling along with you. When you're typing in the middle of the composer, if the footer inforamtion scrolls past the end of the page, then that's fine. When the selection changes, the contenteditable component now caches whether or not the selection is at the end of the content. It has to do it at this point, otherwise the cloned, cached DOM nodes of the saved selection are not the same nodes that will show up in the future. Whether the cursor is at the end is queried by the composer on a body change. If it is, then the composer expects a callback from its parent (the message list), to call to update the scroll position. The message list immediately updates the scroll position to move the bottom of the composer to the bottom of the message-wrap window. Test Plan: edgehill --test Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://review.inboxapp.com/D1366
439 lines
14 KiB
CoffeeScript
439 lines
14 KiB
CoffeeScript
React = require 'react'
|
|
_ = require 'underscore-plus'
|
|
|
|
{Utils,
|
|
Actions,
|
|
UndoManager,
|
|
DraftStore,
|
|
FileUploadStore,
|
|
ComponentRegistry} = require 'inbox-exports'
|
|
|
|
{ResizableRegion, RetinaImg} = require 'ui-components'
|
|
|
|
FileUploads = require './file-uploads'
|
|
ContenteditableComponent = require './contenteditable-component'
|
|
ParticipantsTextField = require './participants-text-field'
|
|
|
|
# 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. As an alternative, we can call `setProps` to
|
|
# simulate the effect of the parent re-rendering us
|
|
module.exports =
|
|
ComposerView = React.createClass
|
|
displayName: 'ComposerView'
|
|
|
|
getInitialState: ->
|
|
state = @getComponentRegistryState()
|
|
_.extend state,
|
|
populated: false
|
|
to: []
|
|
cc: []
|
|
bcc: []
|
|
body: ""
|
|
subject: ""
|
|
showcc: false
|
|
showbcc: false
|
|
showsubject: false
|
|
showQuotedText: false
|
|
isSending: DraftStore.sendingState(@props.localId)
|
|
state
|
|
|
|
getComponentRegistryState: ->
|
|
AttachmentComponent: ComponentRegistry.findViewByName 'AttachmentComponent'
|
|
ActionButtonComponents: ComponentRegistry.findAllByRole 'Composer:ActionButton'
|
|
FooterComponents: ComponentRegistry.findAllByRole 'Composer:Footer'
|
|
|
|
componentWillMount: ->
|
|
@_prepareForDraft(@props.localId)
|
|
|
|
componentDidMount: ->
|
|
@_draftStoreUnlisten = DraftStore.listen @_onSendingStateChanged
|
|
@keymap_unsubscriber = atom.commands.add '.composer-outer-wrap', {
|
|
'composer:show-and-focus-bcc': @_showAndFocusBcc
|
|
'composer:show-and-focus-cc': @_showAndFocusCc
|
|
'composer:focus-to': => @focus "textFieldTo"
|
|
'composer:send-message': => @_sendDraft()
|
|
'composer:delete-empty-draft': => @_deleteEmptyDraft()
|
|
"core:undo": @undo
|
|
"core:redo": @redo
|
|
}
|
|
if @props.mode is "fullwindow"
|
|
# Need to delay so the component can be fully painted. Focus doesn't
|
|
# work unless the element is on the page.
|
|
@focus "textFieldTo"
|
|
|
|
componentWillUnmount: ->
|
|
@_teardownForDraft()
|
|
@_draftStoreUnlisten() if @_draftStoreUnlisten
|
|
@keymap_unsubscriber.dispose()
|
|
|
|
componentDidUpdate: ->
|
|
# We want to use a temporary variable instead of putting this into the
|
|
# state. This is because the selection is a transient property that
|
|
# only needs to be applied once. It's not a long-living property of
|
|
# the state. We could call `setState` here, but this saves us from a
|
|
# re-rendering.
|
|
@_recoveredSelection = null if @_recoveredSelection?
|
|
|
|
componentWillReceiveProps: (newProps) ->
|
|
if newProps.localId isnt @props.localId
|
|
# When we're given a new draft localId, we have to stop listening to our
|
|
# current DraftStoreProxy, create a new one and listen to that. The simplest
|
|
# way to do this is to just re-call registerListeners.
|
|
@_teardownForDraft()
|
|
@_prepareForDraft(newProps.localId)
|
|
|
|
_prepareForDraft: (localId) ->
|
|
@unlisteners = []
|
|
@unlisteners.push ComponentRegistry.listen (event) =>
|
|
@setState(@getComponentRegistryState())
|
|
|
|
return unless localId
|
|
|
|
# UndoManager must be ready before we call _onDraftChanged for the first time
|
|
@undoManager = new UndoManager
|
|
@_proxy = DraftStore.sessionForLocalId(localId)
|
|
@unlisteners.push @_proxy.listen(@_onDraftChanged)
|
|
if @_proxy.draft()
|
|
@_onDraftChanged()
|
|
|
|
_teardownForDraft: ->
|
|
unlisten() for unlisten in @unlisteners
|
|
if @_proxy
|
|
@_proxy.changes.commit()
|
|
|
|
render: ->
|
|
if @props.mode is "inline"
|
|
<div className={@_wrapClasses()}>
|
|
<ResizableRegion handle={ResizableRegion.Handle.Bottom}>
|
|
{@_renderComposer()}
|
|
</ResizableRegion>
|
|
</div>
|
|
else
|
|
<div className={@_wrapClasses()}>
|
|
{@_renderComposer()}
|
|
</div>
|
|
|
|
_wrapClasses: ->
|
|
"composer-outer-wrap #{@props.className ? ""}"
|
|
|
|
_renderComposer: ->
|
|
<div className="composer-inner-wrap" onDragOver={@_onDragNoop} onDragLeave={@_onDragNoop} onDragEnd={@_onDragNoop} onDrop={@_onDrop}>
|
|
|
|
<div className="composer-cover"
|
|
style={display: (if @state.isSending then "block" else "none")}>
|
|
</div>
|
|
|
|
<div className="composer-content-wrap">
|
|
|
|
<div className="composer-participant-actions">
|
|
<span className="header-action"
|
|
style={display: @state.showcc and 'none' or 'inline'}
|
|
onClick={=> @_showAndFocusCc()}>Cc</span>
|
|
|
|
<span className="header-action"
|
|
style={display: @state.showbcc and 'none' or 'inline'}
|
|
onClick={=> @_showAndFocusBcc()}>Bcc</span>
|
|
|
|
<span className="header-action"
|
|
style={display: @state.showsubject and 'none' or 'initial'}
|
|
onClick={=> @setState {showsubject: true}}>Subject</span>
|
|
|
|
<span className="header-action"
|
|
data-tooltip="Popout composer"
|
|
style={{display: ((@props.mode is "fullwindow") and 'none' or 'initial'), paddingLeft: "1.5em"}}
|
|
onClick={@_popoutComposer}><RetinaImg name="composer-popout.png" style={{position: "relative", top: "-2px"}}/></span>
|
|
|
|
</div>
|
|
|
|
<ParticipantsTextField
|
|
ref="textFieldTo"
|
|
field='to'
|
|
change={@_onChangeParticipants}
|
|
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
|
|
tabIndex='102'/>
|
|
|
|
<ParticipantsTextField
|
|
ref="textFieldCc"
|
|
field='cc'
|
|
visible={@state.showcc}
|
|
change={@_onChangeParticipants}
|
|
onRemove={=> @setState showcc: false}
|
|
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
|
|
tabIndex='103'/>
|
|
|
|
<ParticipantsTextField
|
|
ref="textFieldBcc"
|
|
field='bcc'
|
|
visible={@state.showbcc}
|
|
change={@_onChangeParticipants}
|
|
onRemove={=> @setState showbcc: false}
|
|
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
|
|
tabIndex='104'/>
|
|
|
|
<div className="compose-subject-wrap"
|
|
style={display: @state.showsubject and 'initial' or 'none'}>
|
|
<input type="text"
|
|
key="subject"
|
|
name="subject"
|
|
tabIndex="108"
|
|
placeholder="Subject:"
|
|
disabled={not @state.showsubject}
|
|
className="compose-field compose-subject"
|
|
value={@state.subject}
|
|
onChange={@_onChangeSubject}/>
|
|
</div>
|
|
|
|
<div className="compose-body">
|
|
<ContenteditableComponent ref="contentBody"
|
|
html={@state.body}
|
|
onChange={@_onChangeBody}
|
|
style={@_precalcComposerCss}
|
|
initialSelectionSnapshot={@_recoveredSelection}
|
|
mode={{showQuotedText: @state.showQuotedText}}
|
|
onChangeMode={@_onChangeEditableMode}
|
|
tabIndex="109" />
|
|
</div>
|
|
|
|
<div className="attachments-area" >
|
|
{@_fileComponents()}
|
|
<FileUploads localId={@props.localId} />
|
|
</div>
|
|
|
|
{@_footerComponents()}
|
|
</div>
|
|
|
|
<div className="composer-action-bar-wrap">
|
|
<div className="composer-action-bar-content">
|
|
<button className="btn btn-toolbar pull-right btn-trash"
|
|
data-tooltip="Delete draft"
|
|
onClick={@_destroyDraft}><RetinaImg name="toolbar-trash.png" /></button>
|
|
|
|
<button className="btn btn-toolbar pull-right btn-attach"
|
|
data-tooltip="Attach file"
|
|
onClick={@_attachFile}><RetinaImg name="toolbar-attach.png"/></button>
|
|
|
|
<button className="btn btn-toolbar btn-emphasis btn-send"
|
|
data-tooltip="Send message"
|
|
ref="sendButton"
|
|
onClick={@_sendDraft}><RetinaImg name="toolbar-send.png" /> Send</button>
|
|
{@_actionButtonComponents()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
# Focus the composer view. Chooses the appropriate field to start
|
|
# focused depending on the draft type, or you can pass a field as
|
|
# the first parameter.
|
|
focus: (field = null) ->
|
|
return unless @isMounted()
|
|
|
|
if component?.isForwardedMessage()
|
|
field ?= "textFieldTo"
|
|
else
|
|
field ?= "contentBody"
|
|
|
|
_.delay =>
|
|
return unless @isMounted()
|
|
@refs[field]?.focus?()
|
|
, 150
|
|
|
|
isForwardedMessage: ->
|
|
draft = @_proxy.draft()
|
|
Utils.isForwardedMessage(draft)
|
|
|
|
_actionButtonComponents: ->
|
|
return [] unless @props.localId
|
|
(@state.ActionButtonComponents ? []).map ({view, name}) =>
|
|
<view key={name} draftLocalId={@props.localId} />
|
|
|
|
_footerComponents: ->
|
|
return [] unless @props.localId
|
|
(@state.FooterComponents ? []).map ({view, name}) =>
|
|
<view key={name} draftLocalId={@props.localId} />
|
|
|
|
_fileComponents: ->
|
|
AttachmentComponent = @state.AttachmentComponent
|
|
(@state.files ? []).map (file) =>
|
|
<AttachmentComponent file={file}
|
|
key={file.filename}
|
|
removable={true}
|
|
messageLocalId={@props.localId} />
|
|
|
|
_onDraftChanged: ->
|
|
draft = @_proxy.draft()
|
|
if not @_initialHistorySave
|
|
@_saveToHistory()
|
|
@_initialHistorySave = true
|
|
state =
|
|
to: draft.to
|
|
cc: draft.cc
|
|
bcc: draft.bcc
|
|
files: draft.files
|
|
subject: draft.subject
|
|
body: draft.body
|
|
|
|
if !@state.populated
|
|
_.extend state,
|
|
showcc: not _.isEmpty(draft.cc)
|
|
showbcc: not _.isEmpty(draft.bcc)
|
|
showsubject: @_shouldShowSubject()
|
|
showQuotedText: @isForwardedMessage()
|
|
populated: true
|
|
|
|
@setState(state)
|
|
|
|
_shouldShowSubject: ->
|
|
draft = @_proxy.draft()
|
|
if _.isEmpty(draft.subject ? "") then return true
|
|
else if @isForwardedMessage() then return true
|
|
else return false
|
|
|
|
_onDragNoop: (e) ->
|
|
e.preventDefault()
|
|
|
|
_onDrop: (e) ->
|
|
e.preventDefault()
|
|
for file in e.dataTransfer.files
|
|
Actions.attachFilePath({path: file.path, messageLocalId: @props.localId})
|
|
true
|
|
|
|
_onChangeParticipants: (changes={}) -> @_addToProxy(changes)
|
|
_onChangeSubject: (event) -> @_addToProxy(subject: event.target.value)
|
|
|
|
_onChangeBody: (event) ->
|
|
if @_getSelections().currentSelection?.atEndOfContent
|
|
@props.onRequestScrollTo?(messageId: @_proxy.draft().id, location: "bottom")
|
|
@_addToProxy(body: event.target.value)
|
|
|
|
_onChangeEditableMode: ({showQuotedText}) ->
|
|
@setState showQuotedText: showQuotedText
|
|
|
|
_addToProxy: (changes={}, source={}) ->
|
|
return unless @_proxy
|
|
|
|
selections = @_getSelections()
|
|
|
|
oldDraft = @_proxy.draft()
|
|
return if _.all changes, (change, key) -> _.isEqual(change, oldDraft[key])
|
|
@_proxy.changes.add(changes)
|
|
|
|
@_saveToHistory(selections) unless source.fromUndoManager
|
|
|
|
_popoutComposer: ->
|
|
@_proxy.changes.commit()
|
|
Actions.composePopoutDraft @props.localId
|
|
|
|
_sendDraft: (options = {}) ->
|
|
return if @state.isSending
|
|
draft = @_proxy.draft()
|
|
remote = require('remote')
|
|
dialog = remote.require('dialog')
|
|
|
|
if [].concat(draft.to, draft.cc, draft.bcc).length is 0
|
|
dialog.showMessageBox(remote.getCurrentWindow(), {
|
|
type: 'warning',
|
|
buttons: ['Edit Message'],
|
|
message: 'Cannot Send',
|
|
detail: 'You need to provide one or more recipients before sending the message.'
|
|
})
|
|
return
|
|
|
|
warnings = []
|
|
if draft.subject.length is 0
|
|
warnings.push('without a subject line')
|
|
if (draft.files ? []).length is 0 and @_hasAttachment(draft.body)
|
|
warnings.push('without an attachment')
|
|
|
|
# Check third party warnings added via DraftStore extensions
|
|
for extension in DraftStore.extensions()
|
|
continue unless extension.warningsForSending
|
|
warnings = warnings.concat(extension.warningsForSending(draft))
|
|
|
|
if warnings.length > 0 and not options.force
|
|
dialog.showMessageBox remote.getCurrentWindow(), {
|
|
type: 'warning',
|
|
buttons: ['Cancel', 'Send Anyway'],
|
|
message: 'Are you sure?',
|
|
detail: "Send #{warnings.join(' and ')}?"
|
|
}, (response) =>
|
|
if response is 1 # button array index 1
|
|
@_sendDraft({force: true})
|
|
return
|
|
|
|
Actions.sendDraft(@props.localId)
|
|
|
|
_hasAttachment: (body) ->
|
|
body = body.toLowerCase().trim()
|
|
attachIndex = body.indexOf("attach")
|
|
if attachIndex >= 0
|
|
quotedTextIndex = Utils.quotedTextIndex(body)
|
|
if quotedTextIndex >= 0
|
|
return (attachIndex < quotedTextIndex)
|
|
else return true
|
|
else return false
|
|
|
|
_destroyDraft: ->
|
|
Actions.destroyDraft(@props.localId)
|
|
|
|
_attachFile: ->
|
|
Actions.attachFile({messageLocalId: @props.localId})
|
|
|
|
_showAndFocusBcc: ->
|
|
@setState {showbcc: true}
|
|
@focus "textFieldBcc"
|
|
|
|
_showAndFocusCc: ->
|
|
@setState {showcc: true}
|
|
@focus "textFieldCc"
|
|
|
|
_onSendingStateChanged: ->
|
|
@setState isSending: DraftStore.sendingState(@props.localId)
|
|
|
|
|
|
undo: (event) ->
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
historyItem = @undoManager.undo() ? {}
|
|
return unless historyItem.state?
|
|
|
|
@_recoveredSelection = historyItem.currentSelection
|
|
@_addToProxy historyItem.state, fromUndoManager: true
|
|
|
|
redo: (event) ->
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
historyItem = @undoManager.redo() ? {}
|
|
return unless historyItem.state?
|
|
|
|
@_recoveredSelection = historyItem.currentSelection
|
|
@_addToProxy historyItem.state, fromUndoManager: true
|
|
|
|
_getSelections: ->
|
|
currentSelection: @refs.contentBody?.getCurrentSelection?()
|
|
previousSelection: @refs.contentBody?.getPreviousSelection?()
|
|
|
|
_saveToHistory: (selections) ->
|
|
selections ?= @_getSelections()
|
|
|
|
newDraft = @_proxy.draft()
|
|
|
|
historyItem =
|
|
previousSelection: selections.previousSelection
|
|
currentSelection: selections.currentSelection
|
|
state:
|
|
body: _.clone newDraft.body
|
|
subject: _.clone newDraft.subject
|
|
to: _.clone newDraft.to
|
|
cc: _.clone newDraft.cc
|
|
bcc: _.clone newDraft.bcc
|
|
|
|
lastState = @undoManager.current()
|
|
if lastState?
|
|
lastState.currentSelection = historyItem.previousSelection
|
|
|
|
@undoManager.saveToHistory(historyItem)
|
|
|
|
_deleteEmptyDraft: ->
|
|
if @_proxy.draft().pristine then Actions.destroyDraft(@props.localId)
|