-
-
{@props.file.filename}
+
+
+
{@props.file.displayName()}
{@_imgOrLoader()}
-
_canAbortDownload: -> false
_renderRemoveIcon: ->
-
+
_renderDownloadButton: ->
-
+
_imgOrLoader: ->
if @props.download
diff --git a/internal_packages/attachments/stylesheets/attachments.less b/internal_packages/attachments/stylesheets/attachments.less
index 7fa53b8d1..07c677bc0 100644
--- a/internal_packages/attachments/stylesheets/attachments.less
+++ b/internal_packages/attachments/stylesheets/attachments.less
@@ -1,33 +1,32 @@
@import "ui-variables";
@import "ui-mixins";
-.attachment-file-wrap {
+.file-wrap {
cursor: default;
display: inline-block;
position: relative;
font-size: @font-size-small;
- margin: 0 @spacing-standard @spacing-standard @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;
+ margin: 0 0 @spacing-standard @spacing-standard;
width: calc(~"50% - 23px");
- border-radius: 4px;
+ -webkit-user-drag: element;
- &.non-image-attachment {
- width: calc(~"50% - 23px");
- margin-left: @spacing-standard;
+ .inner {
+ border-radius: 4px;
+ background: @background-off-primary;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.09);
+ padding: 13px @spacing-standard 13px @spacing-standard;
+ height:54px;
}
- &:nth-child(even) {
- margin-left: 0;
+ &:hover {
+ cursor: default;
+ .inner {
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
+ }
}
&.file-upload {
- border-radius: 4px;
- padding: 13px @spacing-standard 13px @spacing-standard;
- .attachment-file-name {
+ .file-name {
color: @text-color-very-subtle;
.uploading {
color: @text-color;
@@ -35,101 +34,66 @@
}
}
- .attachment-inner-wrap {
- border-radius: 4px;
- padding: 13px @spacing-standard 13px @spacing-standard;
- position: relative;
+ .progress-bar-wrap {
+ display: none;
+
+ &.state-downloading, &.state-started, &.state-progress {
+ display: block;
+ }
+
+ &.state-completed, &.state-success {
+ display: block;
+ .progress-foreground { background: @background-color-success; }
+ }
+
+ &.state-aborted, &.state-failed {
+ display: block;
+ .progress-foreground { background: @background-color-error; }
+ }
+
+ .progress-foreground {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 2px;
+ width: 0; // Changed by React
+ z-index: 3;
+ display: block;
+ background: @progress-bar-fill;
+ border-bottom-left-radius:4px;
+ }
+ .progress-background {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 2px;
+ width: 100%;
+ z-index: 2;
+ display: block;
+ background: @progress-bar-background;
+ border-bottom-left-radius:4px;
+ border-bottom-right-radius:4px;
+ }
+ }
+
+ .file-icon {
+ margin-right: 10px;
+ flex-shrink:0;
+ }
+ .file-name {
+ font-weight: @font-weight-medium;
+ flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-
- &:hover {
- cursor: default;
- }
-
- &.pending, &.started, &.progress, &.downloading {
- }
-
- .attachment-file-icon {
- margin-right: 7px;
- }
-
- .attachment-file-name {
- font-weight: @font-weight-medium;
- vertical-align: middle;
- }
- .attachment-file-and-name {
- position: relative;
- z-index: 2;
- vertical-align: middle;
- }
-
- .attachment-file-actions {
- position: relative;
- z-index: 3;
- .attachment-icon {
- float: right;
- margin-left: 10px;
- }
+ .file-action-icon {
+ margin-left: 10px;
+ flex-shrink:0;
}
}
-.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: 0;
- height: 2px;
- width: 0; // Changed by React
- z-index: 3;
- display: block;
- background: @progress-bar-fill;
- }
- .attachment-bar-bg {
- position: absolute;
- left: 0;
- bottom: 0;
- height: 2px;
- width: 100%;
- z-index: 2;
- display: block;
- background: @progress-bar-background;
- }
-}
-
-.image-attachment-file-wrap,
-.image-file-upload {
-
+.file-wrap.file-image-wrap {
position: relative;
text-align: center;
display:inline-block;
@@ -137,30 +101,27 @@
margin-bottom: @spacing-standard;
margin-right: @spacing-standard;
margin-left: @spacing-standard;
+ width: initial;
+ max-width: calc(~"100% - 30px");
- .attachment-download-progress,
- .attachment-upload-progress {
+ .progress-foreground,
+ .progress-foreground {
bottom: -2px;
}
- .attachment-bar-bg {
+ .progress-background {
bottom: -2px;
}
- .attachment-file-actions {
- position: relative;
- z-index: 2;
+ .file-action-icon, .file-name-container, .file-name {
+ display: none;
}
-
&:hover {
- .attachment-file-actions, .attachment-name-container, .attachment-name {
+ .file-action-icon, .file-name-container, .file-name {
display: block;
}
}
- .attachment-file-actions, .attachment-name-container, .attachment-name {
- display: none;
- }
- .attachment-icon {
+ .file-action-icon {
position: absolute;
z-index: 2;
right: -8px;
@@ -169,12 +130,13 @@
border-radius: 0 0 0 3px;
}
- .attachment-preview {
+ .file-preview {
position: relative;
z-index: 1;
overflow: hidden;
- .attachment-name-container {
+ .file-name-container {
+ cursor: default;
position: absolute;
bottom: 0;
top: 0;
@@ -185,10 +147,15 @@
background: linear-gradient(to top, rgba(0,0,0,0.75) 0%,rgba(0,0,0,0) 23%);
vertical-align:bottom;
- .attachment-name {
+ // Important! file-name-container is on top of the image and prevents you from dragging it.
+ pointer-events: none;
+
+ .file-name {
color: @white;
left: @spacing-standard;
+ right: @spacing-standard;
bottom: @spacing-standard;
+ text-align:left;
position: absolute;
z-index: 3;
}
diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx
index b9f99266f..5edd61c31 100644
--- a/internal_packages/composer/lib/composer-view.cjsx
+++ b/internal_packages/composer/lib/composer-view.cjsx
@@ -2,6 +2,7 @@ React = require 'react'
_ = require 'underscore'
{Utils,
+ File,
Actions,
DraftStore,
UndoManager,
@@ -295,7 +296,7 @@ class ComposerView extends React.Component
_renderAttachments: ->
renderSubset = (arr, attachmentRole, UploadComponent) =>
arr.map (fileOrUpload) =>
- if fileOrUpload.object is "file"
+ if fileOrUpload instanceof File
@_attachmentComponent(fileOrUpload, attachmentRole)
else
@@ -317,9 +318,9 @@ class ComposerView extends React.Component
messageLocalId: @props.localId
if role is "Attachment"
- className = "non-image-attachment attachment-file-wrap"
+ className = "file-wrap"
else
- className = "image-attachment-file-wrap"
+ className = "file-wrap file-image-wrap"
e.preventDefault()
+
+ # Accept drops of real files from other applications
for file in e.dataTransfer.files
Actions.attachFilePath({path: file.path, messageLocalId: @props.localId})
+
+ # Accept drops from attachment components within the app
+ if "text/nylas-file-url" in event.dataTransfer.types
+ downloadURL = event.dataTransfer.getData("text/nylas-file-url")
+ downloadFilePath = downloadURL.split('file://')[1]
+ Actions.attachFilePath({path: downloadFilePath, messageLocalId: @props.localId})
+
+ # Accept drops of images from within the app
+ if "text/uri-list" in event.dataTransfer.types
+ uri = event.dataTransfer.getData('text/uri-list')
+ if uri.indexOf('file://') is 0
+ uri = uri.split('file://')[1]
+ Actions.attachFilePath({path: uri, messageLocalId: @props.localId})
+
true
_onFilePaste: (path) =>
diff --git a/internal_packages/composer/lib/file-upload.cjsx b/internal_packages/composer/lib/file-upload.cjsx
index 0e2121c19..5f572f527 100644
--- a/internal_packages/composer/lib/file-upload.cjsx
+++ b/internal_packages/composer/lib/file-upload.cjsx
@@ -1,6 +1,6 @@
path = require 'path'
React = require 'react'
-{RetinaImg} = require 'nylas-component-kit'
+{RetinaImg, Flexbox} = require 'nylas-component-kit'
{Utils,
Actions,
FileUploadStore} = require 'nylas-exports'
@@ -9,32 +9,32 @@ class FileUpload extends React.Component
@displayName: 'FileUpload'
render: =>
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
+
-
- Uploading: {@_basename()}
-
-
+
+ Uploading: {@_basename()}
+
+
+
+
+
+
_uploadProgressStyle: =>
if @props.uploadData.fileSize <= 0
percent = 0
else
- percent = (@props.uploadData.bytesUploaded / @props.uploadData.fileSize) * 100
+ percent = Math.min(1, (@props.uploadData.bytesUploaded / @props.uploadData.fileSize)) * 100
width: "#{percent}%"
_onClickRemove: =>
diff --git a/internal_packages/composer/lib/image-file-upload.cjsx b/internal_packages/composer/lib/image-file-upload.cjsx
index 1cbaf76a4..cb25dee5d 100644
--- a/internal_packages/composer/lib/image-file-upload.cjsx
+++ b/internal_packages/composer/lib/image-file-upload.cjsx
@@ -10,22 +10,22 @@ class ImageFileUpload extends FileUpload
uploadData: React.PropTypes.object
render: =>
-
-
-
-
-
+
+
+
-
-
-
{@props.uploadData.fileName}
+
+
+
{@props.uploadData.fileName}
+
-
-
+
+
+
module.exports = ImageFileUpload
diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx
index f20b3e9dd..d45195c93 100644
--- a/internal_packages/composer/spec/composer-view-spec.cjsx
+++ b/internal_packages/composer/spec/composer-view-spec.cjsx
@@ -522,21 +522,18 @@ describe "populated composer", ->
describe "A draft with files (attachments) and uploads", ->
beforeEach ->
- @file1 =
+ @file1 = new File
id: "f_1"
- object: "file"
filename: "f1.pdf"
size: 1230
- @file2 =
+ @file2 = new File
id: "f_2"
- object: "file"
filename: "f2.jpg"
size: 4560
- @file3 =
+ @file3 = new File
id: "f_3"
- object: "file"
filename: "f3.png"
size: 7890
@@ -566,21 +563,14 @@ describe "populated composer", ->
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")
+ it 'injects an Attachment component for non image files', ->
+ 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")
+ it 'injects an Attachment:Image component for image files', ->
+ els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment:Image"})
expect(els.length).toBe 1
- it 'renders the uploads with the correct components', ->
- el = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, 'file-upload')
- expect(el).toBeDefined()
-
- el = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, 'image-file-upload')
- expect(el).toBeDefined()
-
describe "when the DraftStore `isSending` isn't stubbed out", ->
beforeEach ->
DraftStore._pendingEnqueue = {}
diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less
index 0f3344016..0c0987c98 100644
--- a/internal_packages/composer/stylesheets/composer.less
+++ b/internal_packages/composer/stylesheets/composer.less
@@ -182,6 +182,7 @@
// TODO FIXME DRY From stylesheets/message-list.less
.attachments-area {
padding: 0;
+ margin: 0;
}
.token {
diff --git a/internal_packages/file-list/lib/file-list.cjsx b/internal_packages/file-list/lib/file-list.cjsx
index 0c32040cb..21d8261bb 100644
--- a/internal_packages/file-list/lib/file-list.cjsx
+++ b/internal_packages/file-list/lib/file-list.cjsx
@@ -25,7 +25,7 @@ class FileList extends React.Component
name: "Name"
flex: 1
resolver: (file) =>
-
{file.filename}
+
{file.displayName()}
c2 = new ListTabular.Column
name: "Size"
diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx
index f728a1069..df146349e 100644
--- a/internal_packages/message-list/lib/message-item.cjsx
+++ b/internal_packages/message-list/lib/message-item.cjsx
@@ -226,7 +226,7 @@ class MessageItem extends React.Component
otherAttachments = otherAttachments.map (file) =>
@@ -238,7 +238,7 @@ class MessageItem extends React.Component
targetPath: FileDownloadStore.pathForFile(file)
diff --git a/internal_packages/message-list/stylesheets/message-list.less b/internal_packages/message-list/stylesheets/message-list.less
index 2a8c0e1a6..a22c340cc 100644
--- a/internal_packages/message-list/stylesheets/message-list.less
+++ b/internal_packages/message-list/stylesheets/message-list.less
@@ -375,6 +375,13 @@
}
.attachments-area {
padding-top: @spacing-standard;
+
+ // attachments are padded on both sides so that things like the remove "X" can
+ // overhang them. To make the attachments line up with the body, we need to outdent
+ margin-left: -@spacing-standard;
+ margin-right: -@spacing-standard;
+
+ cursor:default;
}
diff --git a/src/components/draggable-img.cjsx b/src/components/draggable-img.cjsx
index f05dc5710..ae21d87a7 100644
--- a/src/components/draggable-img.cjsx
+++ b/src/components/draggable-img.cjsx
@@ -11,7 +11,7 @@ class DraggableImg extends React.Component
constructor: (@props) ->
render: =>
-
+
_onDragStart: (event) =>
img = React.findDOMNode(@refs.img)
diff --git a/src/flux/models/file.coffee b/src/flux/models/file.coffee
index 4029c6646..c645a7e69 100644
--- a/src/flux/models/file.coffee
+++ b/src/flux/models/file.coffee
@@ -48,4 +48,22 @@ class File extends Model
modelKey: 'contentId'
jsonKey: 'content_id'
+ # Public: Files can have empty names, or no name. `displayName` returns the file's
+ # name if one is present, and falls back to appropriate default name based on
+ # the contentType. It will always return a non-empty string.
+ #
+ displayName: ->
+ defaultNames = {
+ 'text/calendar': "Event.ics",
+ 'image/png': 'Unnamed Image.png'
+ 'image/jpg': 'Unnamed Image.jpg'
+ 'image/jpeg': 'Unnamed Image.jpg'
+ }
+ if @filename and @filename.length
+ return @filename
+ else if defaultNames[@contentType]
+ return defaultNames[@contentType]
+ else
+ return "Unnamed Attachment"
+
module.exports = File
diff --git a/src/flux/stores/file-download-store.coffee b/src/flux/stores/file-download-store.coffee
index 92972b39a..456e70d69 100644
--- a/src/flux/stores/file-download-store.coffee
+++ b/src/flux/stores/file-download-store.coffee
@@ -12,22 +12,32 @@ progress = require 'request-progress'
NamespaceStore = require '../stores/namespace-store'
NylasAPI = require '../nylas-api'
-UNTITLED = "Untitled"
+Promise.promisifyAll(fs)
+
+mkdirpAsync = (folder) ->
+ new Promise (resolve, reject) ->
+ mkdirp folder, (err) ->
+ if err then reject(err) else resolve(folder)
class Download
constructor: ({@fileId, @targetPath, @filename, @filesize, @progressCallback}) ->
+ if not @filename or @filename.length is 0
+ throw new Error("Download.constructor: You must provide a non-empty filename.")
+ if not @fileId
+ throw new Error("Download.constructor: You must provide a fileID to download.")
+ if not @targetPath
+ throw new Error("Download.constructor: You must provide a target path to download.")
+
@percent = 0
@promise = null
- if (@filename ? "").trim().length is 0
- @filename = UNTITLED
@
state: ->
if not @promise
'unstarted'
- if @promise.isFulfilled()
+ else if @promise.isFulfilled()
'finished'
- if @promise.isRejected()
+ else if @promise.isRejected()
'failed'
else
'downloading'
@@ -49,62 +59,47 @@ class Download
return @promise if @promise
@promise = new Promise (resolve, reject) =>
- return reject(new Error("Must pass a fileID to download")) unless @fileId?
- return reject(new Error("Must have a target path to download")) unless @targetPath?
+ namespace = NamespaceStore.current()?.id
+ stream = fs.createWriteStream(@targetPath)
+ finished = false
+ finishedAction = null
- fs.exists @targetPath, (exists) =>
- # Does the file already exist on disk? If so, just resolve immediately.
- if exists
- fs.stat @targetPath, (err, stats) =>
- if not err and stats.size >= @filesize
- return resolve(@)
- else
- @_doDownload(resolve, reject)
+ # We need to watch the request for `success` or `error`, but not fire
+ # a callback until the stream has ended. These helper functions ensure
+ # that resolve or reject is only fired once regardless of the order
+ # these two events (stream end and `success`) happen in.
+ streamEnded = ->
+ finished = true
+ if finishedAction
+ finishedAction(@)
+
+ onStreamEnded = (action) ->
+ if finished
+ action(@)
else
- @_doDownload(resolve, reject)
+ finishedAction = action
- _doDownload: (resolve, reject) =>
- namespace = NamespaceStore.current()?.id
- stream = fs.createWriteStream(@targetPath)
- finished = false
- finishedAction = null
+ NylasAPI.makeRequest
+ json: false
+ path: "/n/#{namespace}/files/#{@fileId}/download"
+ started: (req) =>
+ @request = req
+ progress(@request, {throtte: 250})
+ .on "progress", (progress) =>
+ @percent = progress.percent
+ @progressCallback()
+ .on "end", =>
+ # Wait for the file stream to finish writing before we resolve or reject
+ stream.end(streamEnded)
+ .pipe(stream)
- # We need to watch the request for `success` or `error`, but not fire
- # a callback until the stream has ended. These helper functions ensure
- # that resolve or reject is only fired once regardless of the order
- # these two events (stream end and `success`) happen in.
- streamEnded = ->
- finished = true
- if finishedAction
- finishedAction(@)
+ success: =>
+ # At this point, the file stream has not finished writing to disk.
+ # Don't resolve yet, or the browser will load only part of the image.
+ onStreamEnded(resolve)
- onStreamEnded = (action) ->
- if finished
- action(@)
- else
- finishedAction = action
-
- NylasAPI.makeRequest
- json: false
- path: "/n/#{namespace}/files/#{@fileId}/download"
- started: (req) =>
- @request = req
- progress(@request, {throtte: 250})
- .on "progress", (progress) =>
- @percent = progress.percent
- @progressCallback()
- .on "end", =>
- # Wait for the file stream to finish writing before we resolve or reject
- stream.end(streamEnded)
- .pipe(stream)
-
- success: =>
- # At this point, the file stream has not finished writing to disk.
- # Don't resolve yet, or the browser will load only part of the image.
- onStreamEnded(resolve)
-
- error: =>
- onStreamEnded(reject)
+ error: =>
+ onStreamEnded(reject)
abort: ->
@request?.abort()
@@ -130,11 +125,7 @@ FileDownloadStore = Reflux.createStore
#
pathForFile: (file) ->
return undefined unless file
- if file.filename and file.filename.length > 0
- downloadFilename = file.filename
- else
- downloadFilename = file.id
- path.join(@_downloadDirectory, file.id, downloadFilename)
+ path.join(@_downloadDirectory, file.id, file.displayName())
downloadDataForFile: (fileId) -> @_downloads[fileId]?.data()
@@ -150,8 +141,8 @@ FileDownloadStore = Reflux.createStore
########### PRIVATE ####################################################
- # Returns a promise allowing other actions to be daisy-chained
- # to the end of the download operation
+ # Returns a promise with a Download object, allowing other actions to be
+ # daisy-chained to the end of the download operation.
_startDownload: (file, options = {}) ->
@_prepareFolder(file).then =>
targetPath = @pathForFile(file)
@@ -161,31 +152,46 @@ FileDownloadStore = Reflux.createStore
download = @_downloads[file.id]
return download.run() if download
- # create a new download for this file and add it to our queue
+ # create a new download for this file
download = new Download
fileId: file.id
filesize: file.size
- filename: file.filename
+ filename: file.displayName()
targetPath: targetPath
progressCallback: => @trigger()
- cleanup = =>
- @_cleanupDownload(download)
- Promise.resolve(download)
-
- @_downloads[file.id] = download
- promise = download.run().catch(cleanup).then(cleanup)
- @trigger()
- return promise
-
- _prepareFolder: (file) ->
- new Promise (resolve, reject) =>
- folder = path.join(@_downloadDirectory, file.id)
- fs.exists folder, (exists) =>
- if exists then resolve(folder)
+ # Do we actually need to queue and run the download? Queuing a download
+ # for an already-downloaded file has side-effects, like making the UI
+ # flicker briefly.
+ @_checkForDownloadedFile(file).then (downloaded) =>
+ if downloaded
+ # If we have the file, just resolve with a resolved download representing the file.
+ download.promise = Promise.resolve()
+ return Promise.resolve(download)
else
- mkdirp folder, (err) =>
- if err then reject(err) else resolve(folder)
+ cleanup = =>
+ @_cleanupDownload(download)
+ Promise.resolve(download)
+ @_downloads[file.id] = download
+ @trigger()
+ return download.run().catch(cleanup).then(cleanup)
+
+ # Returns a promise that resolves with true or false. True if the file has
+ # been downloaded, false if it should be downloaded.
+ #
+ _checkForDownloadedFile: (file) ->
+ fs.statAsync(@pathForFile(file)).catch (err) =>
+ return Promise.resolve(false)
+ .then (stats) =>
+ return Promise.resolve(stats.size >= file.size)
+
+ # Checks that the folder for the download is ready. Returns a promise that
+ # resolves when the download directory for the file has been created.
+ #
+ _prepareFolder: (file) ->
+ targetFolder = path.join(@_downloadDirectory, file.id)
+ fs.statAsync(targetFolder).catch =>
+ mkdirpAsync(targetFolder)
_fetch: (file) ->
@_startDownload(file)
@@ -226,10 +232,4 @@ FileDownloadStore = Reflux.createStore
if not fs.existsSync(downloadDir)
downloadDir = os.tmpdir()
- path.join(downloadDir, @_filename(file.filename))
-
- # Sometimes files can have no name.
- _filename: (filename="") ->
- if filename.trim().length is 0
- return UNTITLED
- else return filename
+ path.join(downloadDir, file.displayName())