mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-04 19:54:32 +08:00
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:
parent
c8d62e25b5
commit
449e11cdda
30 changed files with 639 additions and 219 deletions
|
@ -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> </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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
48
internal_packages/composer/lib/file-upload.cjsx
Normal file
48
internal_packages/composer/lib/file-upload.cjsx
Normal 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> {@_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
|
|
@ -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> </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
|
28
internal_packages/composer/lib/image-file-upload.cjsx
Normal file
28
internal_packages/composer/lib/image-file-upload.cjsx
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -43,7 +43,6 @@ class MessageListScrollTooltip extends React.Component
|
|||
{@state.idx} of {@state.count}
|
||||
</div>
|
||||
|
||||
|
||||
class MessageList extends React.Component
|
||||
@displayName: 'MessageList'
|
||||
@containerRequired: false
|
||||
|
|
|
@ -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'
|
||||
|
|
79
spec-nylas/stores/file-upload-store-spec.coffee
Normal file
79
spec-nylas/stores/file-upload-store-spec.coffee
Normal 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()
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -48,5 +48,4 @@ class File extends Model
|
|||
modelKey: 'contentId'
|
||||
jsonKey: 'content_id'
|
||||
|
||||
|
||||
module.exports = 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:
|
||||
|
|
|
@ -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
|
||||
|
||||
###
|
||||
|
|
|
@ -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 ####################################################
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
BIN
static/images/attachments/file-doc@2x.png
Normal file
BIN
static/images/attachments/file-doc@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
static/images/attachments/file-docx@2x.png
Normal file
BIN
static/images/attachments/file-docx@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
static/images/attachments/file-fallback@2x.png
Normal file
BIN
static/images/attachments/file-fallback@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 643 B |
BIN
static/images/attachments/file-pdf@2x.png
Normal file
BIN
static/images/attachments/file-pdf@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
static/images/attachments/file-zip@2x.png
Normal file
BIN
static/images/attachments/file-zip@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
static/images/attachments/remove-attachment@1x.png
Normal file
BIN
static/images/attachments/remove-attachment@1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 407 B |
Loading…
Add table
Reference in a new issue