feat(attachments): Tons of tiny fixes to attachments, drag-and-drop attachments to other apps

Summary:
consolidate all the "untitled" stuff into a convenience method on the File itself. Previously it'd show "Unnamed Attachment", download as "Untitled" and open as "<file.id>". Now it's consistent everywhere and chooses names based on the contenttype (Event.ics).

Rewrite CSS rules for uploads and attachments to be simpler

- remove container divs and classnames from things that have no CSS
- switch to using Flexbox so it's not necesary to have so many containers
- remove zIndex hacks, apply overflow rules to name div only, so long filenames don't make action button unclickable
- consolidate CSS classnames for uploads/attachments
-

Other style fixes

- cursor "default" instead of text insertion on image attachments
- cursor "default" on action buttons
- image uplaods / attachments with long filenames truncate with ellpsis
- attachments are not indented by an extra 15px in message bodies

Prevent progress bar overflow (was ending above 100%, 100.12315%...)

Update FileDownloadStore so it never creates Download objects when file is downloaded already

- Previously, the download itself decided if it would be a no-op, but this meant the download was around for a split second and you'd see progress indicators flash for a moment when opening/saving an attachment.

Upgrade FileDownloadStore use of promises

Restore Image attachment drag and drop - was broken because the name gradient thing was covering the entire drag region.

Allow file attachments to be drag and dropped to the finder and other applications 😍😍😍

Test Plan: Tests still pass

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1745
This commit is contained in:
Ben Gotow 2015-07-15 13:15:05 -07:00
parent 4223fa089d
commit abcf2b7dad
14 changed files with 337 additions and 309 deletions

View file

@ -1,19 +1,45 @@
_ = require 'underscore'
path = require 'path'
React = require 'react'
{RetinaImg} = require 'nylas-component-kit'
{Actions, Utils} = require 'nylas-exports'
{RetinaImg, Flexbox} = require 'nylas-component-kit'
{Actions, Utils, FileDownloadStore} = require 'nylas-exports'
# Passed in as props from MessageItem and FileDownloadStore
# This is empty if the attachment isn't downloading.
# @props.download is a FileDownloadStore.Download object
# @props.file is a File object
{DragDropMixin} = require 'react-dnd'
AttachmentDragContainer = React.createClass
displayName: "AttachmentDragContainer"
mixins: [DragDropMixin]
statics:
configureDragDrop: (registerType) =>
registerType('attachment', {
dragSource:
beginDrag: (component) =>
# Why is event defined in this scope? Magic. We need to use react-dnd
# because otherwise it's global onDragStart listener will cancel the
# drag. We don't actually intend to do a react-dnd drag/drop, but we
# can use this hook to populate the event.dataTransfer
DownloadURL = component.props.downloadUrl
event.dataTransfer.setData("DownloadURL", DownloadURL)
event.dataTransfer.setData("text/nylas-file-url", DownloadURL)
# This is bogus we don't care about the rest of the react-dnd lifecycle.
return {item: {DownloadURL}}
})
render: ->
<div {...@dragSourceFor('attachment')} draggable="true">
{@props.children}
</div>
class AttachmentComponent extends React.Component
@displayName: 'AttachmentComponent'
@propTypes:
file: React.PropTypes.object.isRequired,
file: React.PropTypes.object.isRequired
download: React.PropTypes.object
removable: React.PropTypes.bool
targetPath: React.PropTypes.string
@ -23,67 +49,72 @@ class AttachmentComponent extends React.Component
@state = progressPercent: 0
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>
<AttachmentDragContainer downloadUrl={@_getDragDownloadURL()}>
<div className="inner" onClick={@_onClickView}>
<span className={"progress-bar-wrap state-#{@props.download?.state ? ""}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_downloadProgressStyle()}></span>
</span>
<span className="attachment-file-actions">
{@_renderFileActions()}
</span>
<span className="attachment-file-and-name" onClick={@_onClickView}>
<span className="attachment-file-icon">
<Flexbox direction="row" style={alignItems: 'center'}>
<RetinaImg className="file-icon"
fallback="file-fallback.png"
name="file-#{@_extension()}.png"/>
</span>
<span className="attachment-file-name">{@props.file.filename ? "Unnamed Attachment"}</span>
</span>
</div>
<span className="file-name">{@props.file.displayName()}</span>
{@_renderFileActions()}
</Flexbox>
</div>
</AttachmentDragContainer>
_renderFileActions: =>
if @props.removable
<div className="attachment-icon" onClick={@_onClickRemove}>
<div className="file-action-icon" onClick={@_onClickRemove}>
{@_renderRemoveIcon()}
</div>
else if @_isDownloading() and @_canAbortDownload()
<div className="attachment-icon" onClick={@_onClickAbort}>
<div className="file-action-icon" onClick={@_onClickAbort}>
{@_renderRemoveIcon()}
</div>
else
<div className="attachment-icon" onClick={@_onClickDownload}>
<div className="file-action-icon" onClick={@_onClickDownload}>
{@_renderDownloadButton()}
</div>
_downloadProgressStyle: =>
width: "#{@props.download?.percent ? 0}%"
_onClickRemove: =>
Actions.removeFile
file: @props.file
messageLocalId: @props.messageLocalId
_canAbortDownload: -> true
_renderRemoveIcon: ->
<RetinaImg className="remove-icon" name="remove-attachment.png"/>
_renderDownloadButton: ->
<RetinaImg className="download-icon" name="icon-attachment-download.png"/>
_onClickView: => Actions.fetchAndOpenFile(@props.file) if @_canClickToView()
_onClickDownload: => Actions.fetchAndSaveFile(@props.file)
_onClickAbort: => Actions.abortDownload(@props.file, @props.download)
_canClickToView: => not @props.removable and not @_isDownloading()
_isDownloading: => @props.download?.state is "downloading"
_renderRemoveIcon: ->
<RetinaImg name="remove-attachment.png"/>
_renderDownloadButton: ->
<RetinaImg name="icon-attachment-download.png"/>
_getDragDownloadURL: (event) =>
path = FileDownloadStore.pathForFile(@props.file)
return "#{@props.file.contentType}:#{@props.file.displayName()}:file://#{path}"
_onClickView: => Actions.fetchAndOpenFile(@props.file) if @_canClickToView()
_onClickRemove: (event) =>
Actions.removeFile
file: @props.file
messageLocalId: @props.messageLocalId
event.stopPropagation() # Prevent 'onClickView'
_onClickDownload: (event) =>
Actions.fetchAndSaveFile(@props.file)
event.stopPropagation() # Prevent 'onClickView'
_onClickAbort: (event) =>
Actions.abortDownload(@props.file, @props.download)
event.stopPropagation() # Prevent 'onClickView'
_extension: -> @props.file.filename.split('.').pop()

View file

@ -7,32 +7,29 @@ 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>
<div>
<span className={"progress-bar-wrap state-#{@props.download?.state ? ""}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_downloadProgressStyle()}></span>
</span>
<div className="attachment-file-actions">
{@_renderFileActions()}
</div>
{@_renderFileActions()}
<div className="attachment-preview" onClick={@_onClickView}>
<div className="attachment-name-container">
<div className="attachment-name">{@props.file.filename}</div>
<div className="file-preview" onClick={@_onClickView}>
<div className="file-name-container">
<div className="file-name">{@props.file.displayName()}</div>
</div>
{@_imgOrLoader()}
</div>
</div>
_canAbortDownload: -> false
_renderRemoveIcon: ->
<RetinaImg className="image-remove-icon" name="image-cancel-button.png"/>
<RetinaImg name="image-cancel-button.png"/>
_renderDownloadButton: ->
<RetinaImg className="image-download-icon" name="image-download-button.png"/>
<RetinaImg name="image-download-button.png"/>
_imgOrLoader: ->
if @props.download

View file

@ -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;
}

View file

@ -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
<UploadComponent key={fileOrUpload.uploadId} uploadData={fileOrUpload} />
@ -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"
<InjectedComponent key={file.id}
matching={role: role}
@ -464,8 +465,24 @@ class ComposerView extends React.Component
_onDrop: (e) =>
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) =>

View file

@ -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: =>
<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 className={"file-wrap file-upload"}>
<div className="inner">
<div className={"progress-bar-wrap state-#{@props.uploadData.state}"}>
<span className="progress-background"></span>
<span className="progress-foreground" style={@_uploadProgressStyle()}></span>
</div>
</span>
<span className="attachment-file-and-name">
<span className="attachment-file-icon">
<Flexbox direction="row" style={alignItems: 'center'}>
<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>
<span className="file-name">
<span className="uploading">Uploading:</span>&nbsp;{@_basename()}
</span>
<div className="file-action-icon" onClick={@_onClickRemove}>
<RetinaImg name="remove-attachment.png"/>
</div>
</Flexbox>
</div>
</div>
_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: =>

View file

@ -10,22 +10,22 @@ class ImageFileUpload extends FileUpload
uploadData: React.PropTypes.object
render: =>
<div className="image-file-upload #{@props.uploadData.state}">
<div className="attachment-file-actions">
<div className="attachment-icon" onClick={@_onClickRemove}>
<RetinaImg className="image-remove-icon" name="image-cancel-button.png"/>
</div>
<div className="file-wrap file-image-wrap file-upload">
<div className="file-action-icon" onClick={@_onClickRemove}>
<RetinaImg name="image-cancel-button.png"/>
</div>
<div className="attachment-preview" >
<div className="attachment-name-container">
<div className="attachment-name">{@props.uploadData.fileName}</div>
<div className="file-preview">
<div className="file-name-container">
<div className="file-name">{@props.uploadData.fileName}</div>
</div>
<DraggableImg src={@props.uploadData.filePath} />
</div>
<span className="attachment-upload-progress" style={@_uploadProgressStyle()}></span>
<div className={"progress-bar-wrap state-#{@props.uploadData.state}"}>
<span className="progress-foreground" style={@_uploadProgressStyle()}></span>
</div>
</div>
module.exports = ImageFileUpload

View file

@ -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 = {}

View file

@ -182,6 +182,7 @@
// TODO FIXME DRY From stylesheets/message-list.less
.attachments-area {
padding: 0;
margin: 0;
}
.token {

View file

@ -25,7 +25,7 @@ class FileList extends React.Component
name: "Name"
flex: 1
resolver: (file) =>
<div>{file.filename}</div>
<div>{file.displayName()}</div>
c2 = new ListTabular.Column
name: "Size"

View file

@ -226,7 +226,7 @@ class MessageItem extends React.Component
otherAttachments = otherAttachments.map (file) =>
<InjectedComponent
className="attachment-file-wrap"
className="file-wrap"
matching={role:"Attachment"}
exposedProps={file:file, download: @state.downloads[file.id]}
key={file.id}/>
@ -238,7 +238,7 @@ class MessageItem extends React.Component
targetPath: FileDownloadStore.pathForFile(file)
<InjectedComponent
className="image-attachment-file-wrap"
className="file-wrap file-image-wrap"
matching={role:"Attachment:Image"}
exposedProps={props}
key={file.id} />

View file

@ -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;
}

View file

@ -11,7 +11,7 @@ class DraggableImg extends React.Component
constructor: (@props) ->
render: =>
<img ref="img" onDragStart={@_onDragStart} {...@props} />
<img ref="img" draggable="true" onDragStart={@_onDragStart} {...@props} />
_onDragStart: (event) =>
img = React.findDOMNode(@refs.img)

View file

@ -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

View file

@ -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())