mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-03 06:32:06 +08:00
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:
parent
4223fa089d
commit
abcf2b7dad
14 changed files with 337 additions and 309 deletions
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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> {@_basename()}</span>
|
||||
</span>
|
||||
|
||||
<span className="file-name">
|
||||
<span className="uploading">Uploading:</span> {@_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: =>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {}
|
||||
|
|
|
@ -182,6 +182,7 @@
|
|||
// TODO FIXME DRY From stylesheets/message-list.less
|
||||
.attachments-area {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
|
|
Loading…
Reference in a new issue