React = require 'react' _ = require 'underscore' {Utils, Actions, UndoManager, DraftStore, FileUploadStore, FileDownloadStore} = require 'nylas-exports' {ResizableRegion, InjectedComponentSet, InjectedComponent, RetinaImg} = require 'nylas-component-kit' FileUpload = require './file-upload' ImageFileUpload = require './image-file-upload' 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. class ComposerView extends React.Component @displayName: 'ComposerView' @containerRequired: false @propTypes: localId: React.PropTypes.string.isRequired # Either "inline" or "fullwindow" mode: React.PropTypes.string # If this composer is part of an existing thread (like inline # composers) the threadId will be handed down threadId: React.PropTypes.string # Sometimes when changes in the composer happens it's desirable to # have the parent scroll to a certain location. A parent component can # pass a callback that gets called when this composer wants to be # scrolled to. onRequestScrollTo: React.PropTypes.func constructor: (@props) -> @state = populated: false to: [] cc: [] bcc: [] body: "" files: [] subject: "" showcc: false showbcc: false showsubject: false showQuotedText: false isSending: DraftStore.isSendingDraft(@props.localId) uploads: FileUploadStore.uploadsForMessage(@props.localId) ? [] componentWillMount: => @_prepareForDraft(@props.localId) componentDidMount: => @_draftStoreUnlisten = DraftStore.listen @_onSendingStateChanged @_uploadUnlisten = FileUploadStore.listen @_onFileUploadStoreChange @_keymapUnlisten = 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: => @_unmounted = true # rarf @_teardownForDraft() @_uploadUnlisten() if @_uploadUnlisten @_draftStoreUnlisten() if @_draftStoreUnlisten @_keymapUnlisten.dispose() if @_keymapUnlisten 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 = [] return unless localId # UndoManager must be ready before we call _onDraftChanged for the first time @undoManager = new UndoManager DraftStore.sessionForLocalId(localId).then(@_setupSession) _setupSession: (proxy) => return if @_unmounted return unless proxy.draftLocalId is @props.localId @_proxy = proxy @_preloadImages(@_proxy.draft()?.files) @unlisteners.push @_proxy.listen(@_onDraftChanged) @_onDraftChanged() _preloadImages: (files=[]) -> files.forEach (file) -> uploadData = FileUploadStore.linkedUpload(file) if not uploadData? and Utils.looksLikeImage(file) Actions.fetchFile(file) _teardownForDraft: => unlisten() for unlisten in @unlisteners if @_proxy @_proxy.changes.commit() render: => if @props.mode is "inline"
{@_renderComposer()}
else
{@_renderComposer()}
_wrapClasses: => "composer-outer-wrap #{@props.className ? ""}" _renderComposer: =>
@_showAndFocusCc()}>Cc @_showAndFocusBcc()}>Bcc @setState {showsubject: true}}>Subject
{@_renderFields()}
{@_renderFooterRegions()}
{@_renderActionsRegion()}
_renderFields: => # Note: We need to physically add and remove these elements, not just hide them. # If they're hidden, shift-tab between fields breaks. fields = [] fields.push( ) if @state.showcc fields.push( ) if @state.showbcc fields.push( ) if @state.showsubject fields.push(
) fields _renderFooterRegions: => return
unless @props.localId
{@_renderNonImageAttachmentsAndUploads()} {@_renderImageAttachmentsAndUploads()}
_renderNonImageAttachmentsAndUploads: -> @_nonImages().map (fileOrUpload) => if fileOrUpload.object is "file" @_attachmentComponent(fileOrUpload) else _renderImageAttachmentsAndUploads: -> @_images().map (fileOrUpload) => if fileOrUpload.object is "file" @_attachmentComponent(fileOrUpload, "Attachment:Image") else _attachmentComponent: (file, role="Attachment") => targetPath = FileUploadStore.linkedUpload(file)?.filePath if not targetPath targetPath = FileDownloadStore.pathForFile(file) props = file: file removable: true targetPath: targetPath messageLocalId: @props.localId if role is "Attachment" then className = "non-image-attachment attachment-file-wrap" else className = "image-attachment-file-wrap" _fileSort: (fileOrUpload) -> if fileOrUpload.object is "file" # There will only be an entry in the `linkedUpload` if the file had # finished uploading in this session. We may well have files that # already existed on a draft that don't have any uploadData # associated with them. uploadData = FileUploadStore.linkedUpload(fileOrUpload) else uploadData = fileOrUpload if not uploadData sortOrder = 0 else sortOrder = uploadData.startedUploadingAt + (1 / +uploadData.uploadId) return sortOrder _images: -> _.sortBy _.filter(@_uploadsAndFiles(), Utils.looksLikeImage), @_fileSort _nonImages: -> _.sortBy _.reject(@_uploadsAndFiles(), Utils.looksLikeImage), @_fileSort _uploadsAndFiles: -> _.compact(@state.uploads.concat(@state.files)) _onFileUploadStoreChange: => @setState uploads: FileUploadStore.uploadsForMessage(@props.localId) _renderActionsRegion: => return
unless @props.localId
# 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) => if @isForwardedMessage() field ?= "textFieldTo" else field ?= "contentBody" @refs[field]?.focus?() isForwardedMessage: => return false if not @_proxy draft = @_proxy.draft() Utils.isForwardedMessage(draft) _onDraftChanged: => return unless @_proxy 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: => return false unless @_proxy 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 _onFilePaste: (path) => Actions.attachFilePath({path: path, messageLocalId: @props.localId}) _onChangeParticipants: (changes={}) => @_addToProxy(changes) _onChangeSubject: (event) => @_addToProxy(subject: event.target.value) _onChangeBody: (event) => return unless @_proxy 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: => Actions.composePopoutDraft @props.localId _sendDraft: (options = {}) => return unless @_proxy 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') # Warn if the user tries to send a message with no body, or with a body that # is only quoted text and not a forward. body = draft.body.toLowerCase().trim() forwarded = Utils.isForwardedMessage(draft) quotedTextIndex = Utils.quotedTextIndex(body) if body.length is 0 or (0 <= quotedTextIndex <= 10 and not forwarded) warnings.push('without a body') # 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 # There can be a delay between when the send request gets initiated # by a user and when the draft is prepared on on the TaskQueue, which # is how we detect that the draft is sending. @setState isSending: true 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.isSendingDraft(@props.localId) _onEmptyCc: => @setState showcc: false @focus "textFieldTo" _onEmptyBcc: => @setState showbcc: false if @state.showcc @focus "textFieldCc" else @focus "textFieldTo" undo: (event) => event.preventDefault() event.stopPropagation() historyItem = @undoManager.undo() ? {} return unless historyItem.state? @_recoveredSelection = historyItem.currentSelection @_addToProxy historyItem.state, fromUndoManager: true @_recoveredSelection = null redo: (event) => event.preventDefault() event.stopPropagation() historyItem = @undoManager.redo() ? {} return unless historyItem.state? @_recoveredSelection = historyItem.currentSelection @_addToProxy historyItem.state, fromUndoManager: true @_recoveredSelection = null _getSelections: => currentSelection: @refs.contentBody?.getCurrentSelection?() previousSelection: @refs.contentBody?.getPreviousSelection?() _saveToHistory: (selections) => return unless @_proxy 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: => return unless @_proxy if @_proxy.draft().pristine then Actions.destroyDraft(@props.localId) module.exports = ComposerView