feat(attachments): new attachments & uploads UI with img support

Summary:
Initial styles on attachments ui

More file uploading UI

fix race condition in draft saving

attachments and uploads interweaved

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D1610
This commit is contained in:
Evan Morikawa 2015-06-11 11:52:49 -07:00
parent c8d62e25b5
commit 449e11cdda
30 changed files with 639 additions and 219 deletions

View file

@ -1,6 +1,8 @@
_ = require 'underscore'
path = require 'path'
React = require 'react'
{Actions} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
{Actions, Utils} = require 'nylas-exports'
# Passed in as props from MessageItem and FileDownloadStore
# This is empty if the attachment isn't downloading.
@ -13,41 +15,48 @@ class AttachmentComponent extends React.Component
@propTypes:
file: React.PropTypes.object.isRequired,
download: React.PropTypes.object
removable: React.PropTypes.boolean
targetPath: React.PropTypes.string
messageLocalId: React.PropTypes.string
constructor: (@props) ->
@state = progressPercent: 0
render: =>
<div className={"attachment-file-wrap " + (@props.download?.state() ? "")}>
<div className={"attachment-inner-wrap #{@props.download?.state() ? ""}"}>
<span className="attachment-download-bar-wrap">
<span className="attachment-bar-bg"></span>
<span className="attachment-download-progress" style={@_downloadProgressStyle()}></span>
</span>
<span className="attachment-file-and-name" onClick={@_onClickView}>
<span className="attachment-file-icon"><i className="fa fa-file-o"></i>&nbsp;</span>
<span className="attachment-file-name">{@props.file.filename}</span>
</span>
<span className="attachment-file-actions">
{@_fileActions()}
</span>
<span className="attachment-file-and-name" onClick={@_onClickView}>
<span className="attachment-file-icon">
<RetinaImg className="file-icon"
fallback="file-fallback.png"
name="file-#{@_extension()}.png"/>
</span>
<span className="attachment-file-name">{@props.file.filename}</span>
</span>
</div>
_fileActions: =>
if @props.removable
<button className="btn btn-icon attachment-icon" onClick={@_onClickRemove}>
<i className="fa fa-remove"></i>
</button>
<div className="attachment-icon" onClick={@_onClickRemove}>
<RetinaImg className="remove-icon" name="remove-attachment.png"/>
</div>
else if @_isDownloading()
<button className="btn btn-icon attachment-icon" onClick={@_onClickAbort}>
<i className="fa fa-remove"></i>
</button>
<div className="attachment-icon" onClick={@_onClickRemove}>
<RetinaImg className="remove-icon" name="remove-attachment.png"/>
</div>
else
<button className="btn btn-icon attachment-icon" onClick={@_onClickDownload}>
<i className="fa fa-download"></i>
</button>
<div className="attachment-icon" onClick={@_onClickDownload}>
<i className="fa fa-download" style={position: "relative", top: "2px"}></i>
</div>
_downloadProgressStyle: =>
width: @props.download?.percent ? 0
@ -67,5 +76,7 @@ class AttachmentComponent extends React.Component
_isDownloading: => @props.download?.state() is "downloading"
_extension: -> @props.file.filename.split('.').pop()
module.exports = AttachmentComponent

View file

@ -0,0 +1,25 @@
path = require 'path'
React = require 'react'
AttachmentComponent = require './attachment-component'
class ImageAttachmentComponent extends AttachmentComponent
@displayName: 'ImageAttachmentComponent'
render: =>
<div className={"attachment-inner-wrap " + @props.download?.state() ? ""}>
<span className="attachment-download-bar-wrap">
<span className="attachment-bar-bg"></span>
<span className="attachment-download-progress" style={@_downloadProgressStyle()}></span>
</span>
<span className="attachment-file-actions">
{@_fileActions()}
</span>
<div className="attachment-preview" onClick={@_onClickView}>
<img src={@props.targetPath} />
</div>
</div>
module.exports = ImageAttachmentComponent

View file

@ -3,11 +3,16 @@
module.exports =
activate: (@state={}) ->
AttachmentComponent = require "./attachment-component"
ImageAttachmentComponent = require "./image-attachment-component"
ComponentRegistry.register AttachmentComponent,
role: 'Attachment'
ComponentRegistry.register ImageAttachmentComponent,
role: 'Attachment:Image'
deactivate: ->
ComponentRegistry.unregister AttachmentComponent
ComponentRegistry.unregister ImageAttachmentComponent
serialize: -> @state

View file

@ -2,62 +2,105 @@
@import "ui-mixins";
.attachment-file-wrap {
cursor: default;
display: inline-block;
position: relative;
font-size: @font-size-small;
margin-bottom: 0.5em;
margin-right: 0.5em;
margin-bottom: @spacing-standard;
margin-right: @spacing-standard;
background: @background-off-primary;
box-shadow: inset 0 0 1px 1px rgba(0,0,0,0.09);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(~"50% - 7.5px");
border-radius: 4px;
&.file-upload {
border-radius: 4px;
padding: 13px @spacing-standard 13px @spacing-standard;
.attachment-file-name {
color: @text-color-very-subtle;
.uploading {
color: @text-color;
}
}
}
.attachment-inner-wrap {
border-radius: 4px;
padding: 13px @spacing-standard 13px @spacing-standard;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:nth-child(even) {
margin-right: 0;
}
&:hover {
.attachment-file-actions {
visibility: visible;
}
cursor: default;
}
.attachment-download-bar-wrap {
display:none;
}
&.pending, &.started, &.progress, &.downloading {
// When downloading is in progress.
color: @text-color-subtle;
.attachment-file-and-name:hover {
cursor: default;
color: @text-color-subtle;
}
.attachment-file-actions {
visibility: visible;
margin-left: 0.4em;
}
.btn.btn-icon.attachment-icon {
font-size: 14px;
padding-bottom: 3px;
}
}
.attachment-file-icon {
margin-right: 7px;
}
.attachment-file-name {
font-weight: @font-weight-medium;
}
.attachment-file-and-name {
position: relative;
z-index: 2;
&:hover {
color: @text-color-link;
cursor: pointer;
}
}
.attachment-file-actions {
position: relative;
z-index: 2;
visibility: hidden;
margin-left: 0.3em;
z-index: 3;
.attachment-icon {
float: right;
position: relative;
top: 2px;
margin-left: 10px;
}
}
}
.image-file-upload, .image-attachment-file-wrap, .attachment-file-wrap,
.attachment-inner-wrap {
.attachment-download-bar-wrap {
display: none;
}
&.downloading, &.started, &.progress {
.attachment-download-bar-wrap {
display: block;
}
}
&.completed, &.success {
.attachment-download-bar-wrap {
display: block;
}
.attachment-upload-progress { background: @background-color-success; }
}
&.aborted, &.failed {
.attachment-download-bar-wrap {
display: block;
}
.attachment-upload-progress { background: @background-color-error; }
}
.attachment-download-progress,
.attachment-upload-progress {
position: absolute;
left: 0;
bottom: -2px;
bottom: 0px;
height: 2px;
width: 0; // Changed by React
z-index: 3;
@ -67,19 +110,51 @@
.attachment-bar-bg {
position: absolute;
left: 0;
bottom: -2px;
bottom: 0px;
height: 2px;
width: 100%;
z-index: 2;
display: block;
background: @progress-bar-background;
}
}
.btn.btn-icon.attachment-icon {
margin: 0;
padding: 0;
line-height: 1;
height: auto;
font-size: 18px;
.image-attachment-file-wrap, .image-file-upload {
position: relative;
margin: @spacing-standard 0;
text-align: center;
.attachment-download-progress,
.attachment-upload-progress {
bottom: -2px;
}
.attachment-bar-bg {
bottom: -2px;
}
&:hover {
.attachment-file-actions {
display: block;
}
}
.attachment-file-actions {
display: none;
}
.attachment-icon {
position: absolute;
z-index: 2;
right: 0;
top: 0;
background: @white;
width: 26px;
border-radius: 0 0 0 3px;
}
.attachment-preview img {
position: relative;
z-index: 1;
max-width: 100%;
}
}

View file

@ -5,14 +5,16 @@ _ = require 'underscore'
Actions,
UndoManager,
DraftStore,
FileUploadStore} = require 'nylas-exports'
FileUploadStore,
FileDownloadStore} = require 'nylas-exports'
{ResizableRegion,
InjectedComponentSet,
InjectedComponent,
RetinaImg} = require 'nylas-component-kit'
FileUploads = require './file-uploads'
FileUpload = require './file-upload'
ImageFileUpload = require './image-file-upload'
ContenteditableComponent = require './contenteditable-component'
ParticipantsTextField = require './participants-text-field'
@ -47,18 +49,21 @@ class ComposerView extends React.Component
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
@ -76,6 +81,7 @@ class ComposerView extends React.Component
componentWillUnmount: =>
@_unmounted = true # rarf
@_teardownForDraft()
@_uploadUnlisten() if @_uploadUnlisten
@_draftStoreUnlisten() if @_draftStoreUnlisten
@_keymapUnlisten.dispose() if @_keymapUnlisten
@ -107,9 +113,16 @@ class ComposerView extends React.Component
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
@ -176,9 +189,10 @@ class ComposerView extends React.Component
onChangeMode={@_onChangeEditableMode}
onRequestScrollTo={@props.onRequestScrollTo}
tabIndex="109" />
</div>
{@_renderFooterRegions()}
{@_renderFooterRegions()}
</div>
</div>
<div className="composer-action-bar-wrap">
@ -193,6 +207,7 @@ class ComposerView extends React.Component
fields.push(
<ParticipantsTextField
ref="textFieldTo"
key="to"
field='to'
change={@_onChangeParticipants}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
@ -203,6 +218,7 @@ class ComposerView extends React.Component
fields.push(
<ParticipantsTextField
ref="textFieldCc"
key="cc"
field='cc'
change={@_onChangeParticipants}
onEmptied={=> @setState showcc: false}
@ -214,6 +230,7 @@ class ComposerView extends React.Component
fields.push(
<ParticipantsTextField
ref="textFieldBcc"
key="bcc"
field='bcc'
change={@_onChangeParticipants}
onEmptied={=> @setState showbcc: false}
@ -223,7 +240,7 @@ class ComposerView extends React.Component
if @state.showsubject
fields.push(
<div className="compose-subject-wrap">
<div key="subject" className="compose-subject-wrap">
<input type="text"
key="subject"
name="subject"
@ -241,20 +258,79 @@ class ComposerView extends React.Component
_renderFooterRegions: =>
return <div></div> unless @props.localId
<span>
<div className="composer-footer-region">
<div className="attachments-area">
{
(@state.files ? []).map (file) =>
<InjectedComponent matching={role:"Attachment"}
exposedProps={file: file, removable: true, messageLocalId: @props.localId}
key={file.id} />
}
<FileUploads localId={@props.localId} />
{@_renderNonImageAttachmentsAndUploads()}
{@_renderImageAttachmentsAndUploads()}
</div>
<InjectedComponentSet
matching={role: "Composer:Footer"}
exposedProps={draftLocalId:@props.localId, threadId: @props.threadId}/>
</span>
</div>
_renderNonImageAttachmentsAndUploads: ->
@_nonImages().map (fileOrUpload) =>
if fileOrUpload.object is "file"
@_attachmentComponent(fileOrUpload)
else
<FileUpload key={fileOrUpload.uploadId}
uploadData={fileOrUpload} />
_renderImageAttachmentsAndUploads: ->
@_images().map (fileOrUpload) =>
if fileOrUpload.object is "file"
@_attachmentComponent(fileOrUpload, "Attachment:Image")
else
<ImageFileUpload key={fileOrUpload.uploadId}
uploadData={fileOrUpload} />
_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"
<InjectedComponent key={file.id}
matching={role: role}
className={className}
exposedProps={props} />
_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 <div></div> unless @props.localId

View file

@ -0,0 +1,48 @@
path = require 'path'
React = require 'react'
{RetinaImg} = require 'nylas-component-kit'
{Utils,
Actions,
FileUploadStore} = require 'nylas-exports'
class FileUpload extends React.Component
@displayName: 'FileUpload'
render: =>
<div className={"file-upload attachment-file-wrap " + @props.uploadData.state}>
<span className="attachment-bar-bg"></span>
<span className="attachment-upload-progress" style={@_uploadProgressStyle()}></span>
<span className="attachment-file-actions">
<div className="attachment-icon" onClick={@_onClickRemove}>
<RetinaImg className="remove-icon" name="remove-attachment.png"/>
</div>
</span>
<span className="attachment-file-and-name">
<span className="attachment-file-icon">
<RetinaImg className="file-icon"
fallback="file-fallback.png"
name="file-#{@_extension()}.png"/>
</span>
<span className="attachment-file-name"><span className="uploading">Uploading:</span>&nbsp;{@_basename()}</span>
</span>
</div>
_uploadProgressStyle: =>
if @props.uploadData.fileSize <= 0
percent = 0
else
percent = (@props.uploadData.bytesUploaded / @props.uploadData.fileSize) * 100
width: "#{percent}%"
_onClickRemove: =>
Actions.abortUpload @props.uploadData
_basename: =>
path.basename(@props.uploadData.filePath)
_extension: -> path.extname(@_basename()).split('.').pop()
module.exports = FileUpload

View file

@ -1,69 +0,0 @@
path = require 'path'
React = require 'react'
{Actions,
FileUploadStore} = require 'nylas-exports'
class FileUpload extends React.Component
render: =>
<div className={"attachment-file-wrap " + @props.uploadData.state}>
<span className="attachment-bar-bg"></span>
<span className="attachment-upload-progress" style={@_uploadProgressStyle()}></span>
<span className="attachment-file-and-name">
<span className="attachment-file-icon"><i className="fa fa-file-o"></i>&nbsp;</span>
<span className="attachment-file-name">{@_basename()}</span>
</span>
<span className="attachment-file-actions">
<button className="btn btn-icon attachment-icon" onClick={@_onClickRemove}>
<i className="fa fa-remove"></i>
</button>
</span>
</div>
_uploadProgressStyle: =>
if @props.uploadData.fileSize <= 0
percent = 0
else
percent = (@props.uploadData.bytesUploaded / @props.uploadData.fileSize) * 100
width: "#{percent}%"
_onClickRemove: =>
Actions.abortUpload @props.uploadData
_basename: =>
path.basename(@props.uploadData.filePath)
class FileUploads extends React.Component
constructor: (@props) ->
@state =
uploads: FileUploadStore.uploadsForMessage(@props.localId) ? []
componentDidMount: =>
@storeUnlisten = FileUploadStore.listen(@_onFileUploadStoreChange)
componentWillUnmount: =>
@storeUnlisten() if @storeUnlisten
render: =>
<span className="file-uploads">
{@_fileUploads()}
</span>
_fileUploads: =>
@state.uploads.map (uploadData) =>
<FileUpload key={@_key(uploadData)} uploadData={uploadData} />
_key: (uploadData) =>
"#{uploadData.messageLocalId}-#{uploadData.filePath}"
# fileUploads:
# "some_local_msg_id /some/full/path/name":
# messageLocalId - The localId of the message (draft) we're uploading to
# filePath - The full absolute local system file path
# fileSize - The size in bytes
# fileName - The basename of the file
# bytesUploaded - Current number of bytes uploaded
# state - one of "started" "progress" "completed" "aborted" "failed"
_onFileUploadStoreChange: =>
@setState uploads: FileUploadStore.uploadsForMessage(@props.localId)
module.exports = FileUploads

View file

@ -0,0 +1,28 @@
path = require 'path'
React = require 'react'
FileUpload = require './file-upload'
{RetinaImg} = require 'nylas-component-kit'
class ImageFileUpload extends FileUpload
@displayName: 'ImageFileUpload'
@propTypes:
uploadData: React.PropTypes.object
render: =>
<div className="image-file-upload #{@props.uploadData.state}">
<span className="attachment-file-actions">
<div className="attachment-icon" onClick={@_onClickRemove}>
<RetinaImg className="remove-icon" name="remove-attachment.png"/>
</div>
</span>
<div className="attachment-preview" >
<img src={@props.uploadData.filePath} />
</div>
<span className="attachment-upload-progress" style={@_uploadProgressStyle()}></span>
</div>
module.exports = ImageFileUpload

View file

@ -11,7 +11,11 @@ ReactTestUtils = React.addons.TestUtils
DraftStore,
DatabaseStore,
NylasTestUtils,
NamespaceStore} = require "nylas-exports"
NamespaceStore,
FileUploadStore,
ComponentRegistry} = require "nylas-exports"
{InjectedComponent} = require 'nylas-component-kit'
u1 = new Contact(name: "Christine Spang", email: "spang@nylas.com")
u2 = new Contact(name: "Michael Grinich", email: "mg@nylas.com")
@ -30,6 +34,10 @@ textFieldStub = (className) ->
render: -> <div className={className}>{@props.children}</div>
focus: ->
passThroughStub = (props={})->
React.createClass
render: -> <div {...props}>{props.children}</div>
draftStoreProxyStub = (localId, returnedDraft) ->
listen: -> ->
draft: -> (returnedDraft ? new Message(draft: true))
@ -43,19 +51,19 @@ searchContactStub = (email) ->
_.filter(users, (u) u.email.toLowerCase() is email.toLowerCase())
ComposerView = proxyquire "../lib/composer-view",
"./file-uploads": reactStub("file-uploads")
"./file-upload": reactStub("file-upload")
"./image-file-upload": reactStub("image-file-upload")
"./participants-text-field": textFieldStub("")
"nylas-exports":
ContactStore:
searchContacts: (email) -> searchContactStub
ComponentRegistry:
listen: -> ->
findViewByName: (component) -> reactStub(component)
findAllViewsByRole: (role) -> [reactStub('a'),reactStub('b')]
findAllByRole: (role) -> [{view:reactStub('a'), name:'a'},{view:reactStub('b'),name:'b'}]
DraftStore: DraftStore
beforeEach ->
# spyOn(ComponentRegistry, "findComponentsMatching").andCallFake (matching) ->
# return passThroughStub
# spyOn(ComponentRegistry, "showComponentRegions").andReturn true
# The NamespaceStore isn't set yet in the new window, populate it first.
NamespaceStore.populateItems().then ->
new Promise (resolve, reject) ->
@ -306,7 +314,7 @@ describe "populated composer", ->
subject: "Subject"
to: [u1]
body: "Check out attached file"
files: [{filename:"abc"}]
files: [{id: "123", object: "file", filename:"abc"}]
makeComposer.call(@); @composer._sendDraft()
expect(Actions.sendDraft).toHaveBeenCalled()
expect(@dialog.showMessageBox).not.toHaveBeenCalled()
@ -411,3 +419,65 @@ describe "populated composer", ->
describe "When forwarding a message", ->
describe "When changing the subject of a message", ->
describe "A draft with files (attachments) and uploads", ->
beforeEach ->
@file1 =
id: "f_1"
object: "file"
filename: "f1.pdf"
size: 1230
@file2 =
id: "f_2"
object: "file"
filename: "f2.jpg"
size: 4560
@file3 =
id: "f_3"
object: "file"
filename: "f3.png"
size: 7890
@up1 =
uploadId: 4
messageLocalId: DRAFT_LOCAL_ID
filePath: "/foo/bar/f4.bmp"
fileName: "f4.bmp"
fileSize: 1024
@up2 =
uploadId: 5
messageLocalId: DRAFT_LOCAL_ID
filePath: "/foo/bar/f5.zip"
fileName: "f5.zip"
fileSize: 1024
spyOn(Actions, "fetchFile")
spyOn(FileUploadStore, "linkedUpload")
spyOn(FileUploadStore, "uploadsForMessage").andReturn [@up1, @up2]
useDraft.call @, files: [@file1, @file2]
makeComposer.call @
it 'preloads attached image files', ->
expect(Actions.fetchFile).toHaveBeenCalled()
expect(Actions.fetchFile.calls.length).toBe 1
expect(Actions.fetchFile.calls[0].args[0]).toBe @file2
it 'renders the non image file as an attachment', ->
els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: role: "Attachment")
expect(els.length).toBe 1
it 'renders the image file as an attachment', ->
els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: role: "Attachment:Image")
expect(els.length).toBe 1
it 'renders the non image upload as a FileUpload', ->
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@composer, "file-upload")
expect(els.length).toBe 1
it 'renders the image upload as an ImageFileUpload', ->
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@composer, "image-file-upload")
expect(els.length).toBe 1

View file

@ -136,14 +136,13 @@
.compose-body {
flex: 1;
z-index: 1;
display: flex;
cursor: text;
overflow: auto;
position: relative;
.quoted-text-control {
position: absolute;
bottom: 10px;
bottom: -25px;
left: 15px;
margin: 0;
}
@ -155,42 +154,24 @@
padding: @spacing-standard;
padding-top: @spacing-standard;
padding-bottom: 0;
margin-bottom: 37px;
margin-bottom: 30px;
}
.contenteditable-container {
width: 100%;
position: relative;
}
}
.composer-footer-region {
cursor: default;
&:hover {
cursor: default;
}
}
// TODO FIXME DRY From stylesheets/message-list.less
.attachments-area {
padding: 0 15px 0 15px;
.attachment-file-wrap {
padding-top: 5px;
&.pending, &.started, &.progress {
color: @text-color-subtle;
.attachment-file-actions { visibility: visible; }
.attachment-file-and-name:hover { cursor: default; color: @text-color-subtle; }
}
&.completed, &.success {
color: @text-color-success; // Success state
.attachment-file-actions { visibility: hidden; }
.attachment-file-and-name:hover { cursor: default; color: @text-color-success; }
.attachment-upload-progress { background: @background-color-success; }
}
&.aborted, &.failed {
color: @text-color-error;
.attachment-file-actions { visibility: hidden; }
.attachment-file-and-name:hover { cursor: default; color: @text-color-error; }
.attachment-upload-progress { background: @background-color-error; }
}
}
.attachment-file-actions {
margin-left: 0.4em;
}
}
.token {
@ -250,6 +231,7 @@ body.is-blurred .composer-inner-wrap .tokenizing-field .token {
display: flex;
flex: 1;
width: 100%;
position: relative;
div[contenteditable] {
height: auto;

View file

@ -71,10 +71,10 @@ class MessageItem extends React.Component
<div className={@props.className}>
<div className="message-item-area">
{@_renderHeader()}
{@_renderAttachments()}
<EmailFrame showQuotedText={@state.showQuotedText}>
{@_formatBody()}
</EmailFrame>
{@_renderAttachments()}
<a className={@_quotedTextClasses()} onClick={@_toggleQuotedText}></a>
</div>
</div>
@ -282,19 +282,44 @@ class MessageItem extends React.Component
_formatContacts: (contacts=[]) =>
_attachmentComponents: =>
attachments = _.filter @props.message.files, (f) =>
# We ignore files with no name because they're actually mime-parts of the
# message being served by the API as files.
hasName = f.filename and f.filename.length > 0
hasCIDInBody = f.contentId? and @props.message.body?.indexOf(f.contentId) > 0
hasName and not hasCIDInBody
imageAttachments = []
otherAttachments = []
attachments.map (file) =>
for file in (@props.message.files ? [])
continue unless @_isRealFile(file)
if Utils.looksLikeImage(file)
imageAttachments.push(file)
else
otherAttachments.push(file)
otherAttachments = otherAttachments.map (file) =>
<InjectedComponent
className="attachment-file-wrap"
matching={role:"Attachment"}
exposedProps={file:file, download: @state.downloads[file.id]}
key={file.id}/>
imageAttachments = imageAttachments.map (file) =>
props =
file: file
download: @state.downloads[file.id]
targetPath: FileDownloadStore.pathForFile(file)
<InjectedComponent
className="image-attachment-file-wrap"
matching={role:"Attachment:Image"}
exposedProps={props}
key={file.id} />
return otherAttachments.concat(imageAttachments)
# We ignore files with no name because they're actually mime-parts of the
# message being served by the API as files.
_isRealFile: (file) ->
hasName = file.filename and file.filename.length > 0
hasCIDInBody = file.contentId? and @props.message.body?.indexOf(file.contentId) > 0
return hasName and not hasCIDInBody
_isForwardedMessage: =>
Utils.isForwardedMessage(@props.message)

View file

@ -43,7 +43,6 @@ class MessageListScrollTooltip extends React.Component
{@state.idx} of {@state.count}
</div>
class MessageList extends React.Component
@displayName: 'MessageList'
@containerRequired: false

View file

@ -12,6 +12,7 @@ ReactTestUtils = React.addons.TestUtils
EmailFrameStub = React.createClass({render: -> <div></div>})
{InjectedComponent} = require 'nylas-component-kit'
file = new File
id: 'file_1_id'
filename: 'a.png'

View file

@ -0,0 +1,79 @@
File = require '../../src/flux/models/file'
Actions = require '../../src/flux/actions'
FileUploadStore = require '../../src/flux/stores/file-upload-store'
msgId = "local-123"
fpath = "/foo/bar/test123.jpg"
describe 'FileUploadStore', ->
beforeEach ->
@file = new File
id: "id_123"
filename: "test123.jpg"
size: 12345
@uploadData =
uploadId: 123
messageLocalId: msgId
filePath: fpath
fileSize: 12345
spyOn(atom, "showOpenDialog").andCallFake (props, callback) ->
callback(fpath)
spyOn(Actions, "queueTask")
describe 'when a user wants to attach a file', ->
it "throws if the message id is blank", ->
expect( -> Actions.attachFile()).toThrow()
it "throws if the message id is blank", ->
spyOn(Actions, "attachFilePath")
Actions.attachFile messageLocalId: msgId
expect(atom.showOpenDialog).toHaveBeenCalled()
expect(Actions.attachFilePath).toHaveBeenCalled()
args = Actions.attachFilePath.calls[0].args[0]
expect(args.messageLocalId).toBe msgId
expect(args.path).toBe fpath
describe 'when a user selected the file to attach', ->
it "throws if the message id is blank", ->
expect( -> Actions.attachFilePath()).toThrow()
it 'Creates a new file upload task', ->
Actions.attachFilePath
messageLocalId: msgId
path: fpath
expect(Actions.queueTask).toHaveBeenCalled()
t = Actions.queueTask.calls[0].args[0]
expect(t.filePath).toBe fpath
expect(t.messageLocalId).toBe msgId
describe 'when an uploading file is aborted', ->
it "dequeues the matching task", ->
spyOn(Actions, "dequeueMatchingTask")
Actions.abortUpload(@uploadData)
expect(Actions.dequeueMatchingTask).toHaveBeenCalled()
arg = Actions.dequeueMatchingTask.calls[0].args[0]
expect(arg).toEqual
type: "FileUploadTask"
matching: filePath: fpath
describe 'when upload state changes', ->
it 'updates the uploadData', ->
Actions.uploadStateChanged(@uploadData)
expect(FileUploadStore._fileUploads[123]).toBe @uploadData
describe 'when a file has been uploaded', ->
it 'adds to the linked files and removes from uploads', ->
FileUploadStore._fileUploads[123] = @uploadData
Actions.fileUploaded
file: @file
uploadData: @uploadData
expect(FileUploadStore._linkedFiles["id_123"]).toBe @uploadData
expect(FileUploadStore._fileUploads[123]).not.toBeDefined()
describe 'when a file has been aborted', ->
it 'removes it from the uploads', ->
FileUploadStore._fileUploads[123] = @uploadData
Actions.fileAborted(@uploadData)
expect(FileUploadStore._fileUploads[123]).not.toBeDefined()

View file

@ -37,14 +37,23 @@ testResponse = '[
]'
equivalentFile = (new File).fromJSON(JSON.parse(testResponse)[0])
uploadData =
messageLocalId: localId
filePath: test_file_paths[0]
fileSize: 1234
fileName: "file.txt"
bytesUploaded: 0
DATE = 1433963615918
describe "FileUploadTask", ->
beforeEach ->
spyOn(Date, "now").andReturn DATE
spyOn(FileUploadTask, "idGen").andReturn 3
@uploadData =
uploadId: 3
startedUploadingAt: DATE
messageLocalId: localId
filePath: test_file_paths[0]
fileSize: 1234
fileName: "file.txt"
bytesUploaded: 0
@task = new FileUploadTask(test_file_paths[0], localId)
it "rejects if not initialized with a path name", (done) ->
waitsForPromise ->
(new FileUploadTask).performLocal().catch (err) ->
@ -55,22 +64,27 @@ describe "FileUploadTask", ->
(new FileUploadTask(test_file_paths[0])).performLocal().catch (err) ->
expect(err instanceof Error).toBe true
beforeEach ->
@task = new FileUploadTask(test_file_paths[0], localId)
it 'initializes an uploadId', ->
task = new FileUploadTask(test_file_paths[0], localId)
expect(task._uploadId).toBeGreaterThan 2
it 'initializes the upload start', ->
task = new FileUploadTask(test_file_paths[0], localId)
expect(task._startedUploadingAt).toBe DATE
it "notifies when the task locally starts", ->
spyOn(Actions, "uploadStateChanged")
waitsForPromise =>
@task.performLocal().then ->
data = _.extend uploadData, state: "pending"
@task.performLocal().then =>
data = _.extend @uploadData, state: "pending", bytesUploaded: 0
expect(Actions.uploadStateChanged).toHaveBeenCalledWith data
it "notifies when the file upload fails", ->
spyOn(Actions, "uploadStateChanged")
spyOn(@task, "_getBytesUploaded").andReturn(0)
@task._rollbackLocal()
data = _.extend uploadData, state: "failed"
data = _.extend @uploadData, state: "failed", bytesUploaded: 0
expect(Actions.uploadStateChanged).toHaveBeenCalledWith(data)
describe "When successfully calling remote", ->
@ -86,13 +100,14 @@ describe "FileUploadTask", ->
Promise.resolve(
draft: => files: @testFiles
changes:
add: ({files}) => @changes = files
add: ({files}) => @changes = @changes.concat(files)
commit: -> Promise.resolve()
)
it "notifies when the task starts remote", ->
waitsForPromise =>
@task.performLocal().then ->
data = _.extend uploadData, state: "pending"
@task.performLocal().then =>
data = _.extend @uploadData, state: "pending", bytesUploaded: 0
expect(Actions.uploadStateChanged).toHaveBeenCalledWith data
it "should start an API request", ->
@ -118,12 +133,28 @@ describe "FileUploadTask", ->
Actions.fileUploaded.calls.length > 0
it "correctly fires the fileUploaded action", ->
runs ->
runs =>
expect(Actions.fileUploaded).toHaveBeenCalledWith
uploadData: _.extend {}, uploadData,
file: equivalentFile
uploadData: _.extend {}, @uploadData,
state: "completed"
bytesUploaded: 1000
describe "when attaching a lot of files", ->
it "attaches them all to the draft", ->
t1 = new FileUploadTask("1.a", localId)
t2 = new FileUploadTask("2.b", localId)
t3 = new FileUploadTask("3.c", localId)
t4 = new FileUploadTask("4.d", localId)
waitsForPromise => Promise.all([
t1.performRemote()
t2.performRemote()
t3.performRemote()
t4.performRemote()
]).then =>
expect(@changes.length).toBe 4
describe "cleanup", ->
it "should not do anything if the request has finished", ->
req = jasmine.createSpyObj('req', ['abort'])
@ -146,7 +177,7 @@ describe "FileUploadTask", ->
@task.cleanup()
expect(req.abort).toHaveBeenCalled()
data = _.extend uploadData,
data = _.extend @uploadData,
state: "aborted"
bytesUploaded: 0
expect(Actions.uploadStateChanged).toHaveBeenCalledWith(data)

View file

@ -71,6 +71,7 @@ isCoreSpec = specDirectory == fs.realpathSync(__dirname)
React = require "react/addons"
ReactTestUtils = React.addons.TestUtils
ReactTestUtils.scryRenderedComponentsWithTypeAndProps = (root, type, props) ->
if not root then throw new Error("Must supply a root to scryRenderedComponentsWithTypeAndProps")
_.compact _.map ReactTestUtils.scryRenderedComponentsWithType(root, type), (el) ->
if _.isEqual(_.pick(el.props, Object.keys(props)), props)
return el

View file

@ -48,5 +48,4 @@ class File extends Model
modelKey: 'contentId'
jsonKey: 'content_id'
module.exports = File

View file

@ -44,6 +44,16 @@ Utils =
set[item] = true for item in arr
return set
# Given a File object or uploadData of an uploading file object,
# determine if it looks like an image
looksLikeImage: (file={}) ->
name = file.filename ? file.fileName ? file.name ? ""
size = file.size ? file.fileSize ? 0
ext = path.extname(name).toLowerCase()
extensions = ['.jpg', '.bmp', '.gif', '.png', '.jpeg']
return ext in extensions and size > 512 and size < 1024*1024*10
# Escapes potentially dangerous html characters
# This code is lifted from Angular.js
# See their specs here:

View file

@ -26,6 +26,7 @@ class DraftChangeSet
reset: ->
@_pending = {}
@_saving = {}
clearTimeout(@_timer) if @_timer
@_timer = null
@ -48,11 +49,15 @@ class DraftChangeSet
if not draft
throw new Error("Tried to commit a draft that had already been removed from the database. DraftId: #{@localId}")
draft = @applyToModel(draft)
@_saving = @_pending
@_pending = {}
DatabaseStore.persistModel(draft).then =>
@_pending = {}
@_saving = {}
applyToModel: (model) =>
model.fromJSON(@_pending) if model
if model
model.fromJSON(@_saving)
model.fromJSON(@_pending)
model
###

View file

@ -4,6 +4,7 @@ ipc = require 'ipc'
path = require 'path'
shell = require 'shell'
mkdirp = require 'mkdirp'
Utils = require '../models/utils'
Reflux = require 'reflux'
_ = require 'underscore'
Actions = require '../actions'
@ -106,14 +107,16 @@ FileDownloadStore = Reflux.createStore
path.join(@_downloadDirectory, "#{file.id}-#{file.filename}")
downloadForFileId: (fileId) ->
_.find @_downloads, (d) -> d.fileId is fileId
return Utils.deepClone(_.find @_downloads, (d) -> d.fileId is fileId)
downloadsForFileIds: (fileIds=[]) ->
map = {}
for fileId in fileIds
download = @downloadForFileId(fileId)
map[fileId] = download if download
map
if download
map[fileId] = download
return Utils.deepClone(map)
########### PRIVATE ####################################################

View file

@ -21,6 +21,7 @@ FileUploadStore = Reflux.createStore
# The key is the messageLocalId. The value is a hash of paths and
# corresponding upload data.
@_fileUploads = {}
@_linkedFiles = {}
######### PUBLIC #######################################################
@ -30,6 +31,8 @@ FileUploadStore = Reflux.createStore
_.filter @_fileUploads, (uploadData, uploadKey) ->
uploadData.messageLocalId is messageLocalId
linkedUpload: (file) -> @_linkedFiles[file.id]
########### PRIVATE ####################################################
@ -51,6 +54,7 @@ FileUploadStore = Reflux.createStore
# Receives:
# uploadData:
# uploadId - A unique id
# messageLocalId - The localId of the message (draft) we're uploading to
# filePath - The full absolute local system file path
# fileSize - The size in bytes
@ -58,7 +62,7 @@ FileUploadStore = Reflux.createStore
# bytesUploaded - Current number of bytes uploaded
# state - one of "pending" "started" "progress" "completed" "aborted" "failed"
_onUploadStateChanged: (uploadData) ->
@_fileUploads[@_uploadId(uploadData)] = uploadData
@_fileUploads[uploadData.uploadId] = uploadData
@trigger()
_onAbortUpload: (uploadData) ->
@ -70,16 +74,14 @@ FileUploadStore = Reflux.createStore
})
_onFileUploaded: ({file, uploadData}) ->
delete @_fileUploads[@_uploadId(uploadData)]
@_linkedFiles[file.id] = uploadData
delete @_fileUploads[uploadData.uploadId]
@trigger()
_onFileAborted: (uploadData) ->
delete @_fileUploads[@_uploadId(uploadData)]
delete @_fileUploads[uploadData.uploadId]
@trigger()
_uploadId: (uploadData) ->
"#{uploadData.messageLocalId} #{uploadData.filePath}"
_verifyId: (messageLocalId) ->
if messageLocalId.blank?
throw new Error "You need to pass the ID of the message (draft) this Action refers to"

View file

@ -2,6 +2,7 @@ Reflux = require "reflux"
Actions = require "../actions"
Message = require "../models/message"
Thread = require "../models/thread"
Utils = require '../models/utils'
DatabaseStore = require "./database-store"
NamespaceStore = require "./namespace-store"
FocusedContentStore = require "./focused-content-store"
@ -150,7 +151,8 @@ MessageStore = Reflux.createStore
# is smart enough that calling this multiple times is not bad!
for msg in items
for file in msg.files
Actions.fetchFile(file) if file.contentId
if file.contentId or Utils.looksLikeImage(file)
Actions.fetchFile(file)
# Normally, we would trigger often and let the view's
# shouldComponentUpdate decide whether to re-render, but if we

View file

@ -152,7 +152,8 @@ class TaskQueue
toDequeue = @findTask(type, matching)
if not toDequeue
console.warn("Could not find task: #{task?.object}", task)
console.warn("Could not find task: #{type}", matching)
return
@dequeue(toDequeue, silent: true)
@_update()

View file

@ -9,11 +9,19 @@ NamespaceStore = require '../stores/namespace-store'
DatabaseStore = require '../stores/database-store'
{isTempId} = require '../models/utils'
NylasAPI = require '../nylas-api'
Utils = require '../models/utils'
idGen = 2
class FileUploadTask extends Task
@idGen: -> idGen
constructor: (@filePath, @messageLocalId) ->
super
@_startedUploadingAt = Date.now()
@_uploadId = FileUploadTask.idGen()
idGen += 1
@progress = null # The progress checking timer.
performLocal: ->
@ -89,7 +97,7 @@ class FileUploadTask extends Task
@req = null
@_attachFileToDraft(file).then =>
Actions.uploadStateChanged @_uploadData("completed")
Actions.fileUploaded(uploadData: @_uploadData("completed"))
Actions.fileUploaded(file: file, uploadData: @_uploadData("completed"))
resolve()
.catch(reject)
@ -98,7 +106,8 @@ class FileUploadTask extends Task
DraftStore.sessionForLocalId(@messageLocalId).then (session) =>
files = _.clone(session.draft().files) ? []
files.push(file)
return session.changes.add({files}, true)
session.changes.add({files})
return session.changes.commit()
_formData: ->
file: # Must be named `file` as per the Nylas API spec
@ -115,6 +124,8 @@ class FileUploadTask extends Task
# state - one of "pending" "started" "progress" "completed" "aborted" "failed"
_uploadData: (state) ->
@_memoUploadData ?=
uploadId: @_uploadId
startedUploadingAt: @_startedUploadingAt
messageLocalId: @messageLocalId
filePath: @filePath
fileSize: @_getFileSize(@filePath)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B