From 449e11cdda44963bad94d0619d06bbbd72ad645d Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Thu, 11 Jun 2015 11:52:49 -0700 Subject: [PATCH] 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 --- .../attachments/lib/attachment-component.cjsx | 43 +++-- .../lib/image-attachment-component.cjsx | 25 +++ internal_packages/attachments/lib/main.cjsx | 7 +- .../attachments/stylesheets/attachments.less | 155 +++++++++++++----- .../composer/lib/composer-view.cjsx | 104 ++++++++++-- .../composer/lib/file-upload.cjsx | 48 ++++++ .../composer/lib/file-uploads.cjsx | 69 -------- .../composer/lib/image-file-upload.cjsx | 28 ++++ .../spec/inbox-composer-view-spec.cjsx | 86 +++++++++- .../composer/stylesheets/composer.less | 40 ++--- .../message-list/lib/message-item.cjsx | 41 ++++- .../message-list/lib/message-list.cjsx | 1 - .../message-list/spec/message-item-spec.cjsx | 1 + .../stores/file-upload-store-spec.coffee | 79 +++++++++ spec-nylas/tasks/file-upload-task-spec.coffee | 65 ++++++-- spec/spec-helper.coffee | 1 + src/flux/models/file.coffee | 1 - src/flux/models/utils.coffee | 10 ++ src/flux/stores/draft-store-proxy.coffee | 9 +- src/flux/stores/file-download-store.coffee | 9 +- src/flux/stores/file-upload-store.coffee | 14 +- src/flux/stores/message-store.coffee | 4 +- src/flux/stores/task-queue.coffee | 3 +- src/flux/tasks/file-upload-task.coffee | 15 +- static/images/attachments/file-doc@2x.png | Bin 0 -> 1675 bytes static/images/attachments/file-docx@2x.png | Bin 0 -> 1675 bytes .../images/attachments/file-fallback@2x.png | Bin 0 -> 643 bytes static/images/attachments/file-pdf@2x.png | Bin 0 -> 1563 bytes static/images/attachments/file-zip@2x.png | Bin 0 -> 1222 bytes .../attachments/remove-attachment@1x.png | Bin 0 -> 407 bytes 30 files changed, 639 insertions(+), 219 deletions(-) create mode 100644 internal_packages/attachments/lib/image-attachment-component.cjsx create mode 100644 internal_packages/composer/lib/file-upload.cjsx delete mode 100644 internal_packages/composer/lib/file-uploads.cjsx create mode 100644 internal_packages/composer/lib/image-file-upload.cjsx create mode 100644 spec-nylas/stores/file-upload-store-spec.coffee create mode 100644 static/images/attachments/file-doc@2x.png create mode 100644 static/images/attachments/file-docx@2x.png create mode 100644 static/images/attachments/file-fallback@2x.png create mode 100644 static/images/attachments/file-pdf@2x.png create mode 100644 static/images/attachments/file-zip@2x.png create mode 100644 static/images/attachments/remove-attachment@1x.png diff --git a/internal_packages/attachments/lib/attachment-component.cjsx b/internal_packages/attachments/lib/attachment-component.cjsx index a353eb3b9..8e27b1b07 100644 --- a/internal_packages/attachments/lib/attachment-component.cjsx +++ b/internal_packages/attachments/lib/attachment-component.cjsx @@ -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: => -
+
- -   - {@props.file.filename} - - {@_fileActions()} + + + + + {@props.file.filename} + +
_fileActions: => if @props.removable - +
+ +
else if @_isDownloading() - +
+ +
else - +
+ +
_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 diff --git a/internal_packages/attachments/lib/image-attachment-component.cjsx b/internal_packages/attachments/lib/image-attachment-component.cjsx new file mode 100644 index 000000000..cd8f7c762 --- /dev/null +++ b/internal_packages/attachments/lib/image-attachment-component.cjsx @@ -0,0 +1,25 @@ +path = require 'path' +React = require 'react' +AttachmentComponent = require './attachment-component' + +class ImageAttachmentComponent extends AttachmentComponent + @displayName: 'ImageAttachmentComponent' + + render: => +
+ + + + + + + {@_fileActions()} + + +
+ +
+ +
+ +module.exports = ImageAttachmentComponent diff --git a/internal_packages/attachments/lib/main.cjsx b/internal_packages/attachments/lib/main.cjsx index a88b46582..73cd05d0d 100644 --- a/internal_packages/attachments/lib/main.cjsx +++ b/internal_packages/attachments/lib/main.cjsx @@ -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 diff --git a/internal_packages/attachments/stylesheets/attachments.less b/internal_packages/attachments/stylesheets/attachments.less index 8282cae72..908154634 100644 --- a/internal_packages/attachments/stylesheets/attachments.less +++ b/internal_packages/attachments/stylesheets/attachments.less @@ -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%; } } + diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 887dc8afe..7f4b6206d 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -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" /> -
- {@_renderFooterRegions()} + {@_renderFooterRegions()} + +
@@ -193,6 +207,7 @@ class ComposerView extends React.Component fields.push( @setState showcc: false} @@ -214,6 +230,7 @@ class ComposerView extends React.Component fields.push( @setState showbcc: false} @@ -223,7 +240,7 @@ class ComposerView extends React.Component if @state.showsubject fields.push( -
+
return
unless @props.localId - +
- { - (@state.files ? []).map (file) => - - } - + {@_renderNonImageAttachmentsAndUploads()} + {@_renderImageAttachmentsAndUploads()}
- +
+ + _renderNonImageAttachmentsAndUploads: -> + @_nonImages().map (fileOrUpload) => + if fileOrUpload.object is "file" + @_attachmentComponent(fileOrUpload) + else + + + _renderImageAttachmentsAndUploads: -> + @_images().map (fileOrUpload) => + if fileOrUpload.object is "file" + @_attachmentComponent(fileOrUpload, "Attachment:Image") + else + + + _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" + + + + _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
unless @props.localId diff --git a/internal_packages/composer/lib/file-upload.cjsx b/internal_packages/composer/lib/file-upload.cjsx new file mode 100644 index 000000000..0e2121c19 --- /dev/null +++ b/internal_packages/composer/lib/file-upload.cjsx @@ -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: => +
+ + + + +
+ +
+
+ + + + + + Uploading: {@_basename()} + + +
+ + _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 diff --git a/internal_packages/composer/lib/file-uploads.cjsx b/internal_packages/composer/lib/file-uploads.cjsx deleted file mode 100644 index 07b0a0591..000000000 --- a/internal_packages/composer/lib/file-uploads.cjsx +++ /dev/null @@ -1,69 +0,0 @@ -path = require 'path' -React = require 'react' -{Actions, - FileUploadStore} = require 'nylas-exports' - -class FileUpload extends React.Component - render: => -
- - - -   - {@_basename()} - - - - -
- - _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: => - - {@_fileUploads()} - - - _fileUploads: => - @state.uploads.map (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 diff --git a/internal_packages/composer/lib/image-file-upload.cjsx b/internal_packages/composer/lib/image-file-upload.cjsx new file mode 100644 index 000000000..00edd09d7 --- /dev/null +++ b/internal_packages/composer/lib/image-file-upload.cjsx @@ -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: => +
+ +
+ +
+
+ +
+ +
+ + + +
+ +module.exports = ImageFileUpload diff --git a/internal_packages/composer/spec/inbox-composer-view-spec.cjsx b/internal_packages/composer/spec/inbox-composer-view-spec.cjsx index ef3aa1bd6..f020a9798 100644 --- a/internal_packages/composer/spec/inbox-composer-view-spec.cjsx +++ b/internal_packages/composer/spec/inbox-composer-view-spec.cjsx @@ -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: ->
{@props.children}
focus: -> +passThroughStub = (props={})-> + React.createClass + render: ->
{props.children}
+ 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 diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 6629cd34f..f77e4a439 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -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; diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index d49f7bbfc..c99bee233 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -71,10 +71,10 @@ class MessageItem extends React.Component
{@_renderHeader()} - {@_renderAttachments()} {@_formatBody()} + {@_renderAttachments()}
@@ -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) => + imageAttachments = imageAttachments.map (file) => + props = + file: file + download: @state.downloads[file.id] + targetPath: FileDownloadStore.pathForFile(file) + + + + 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) diff --git a/internal_packages/message-list/lib/message-list.cjsx b/internal_packages/message-list/lib/message-list.cjsx index 27115c9b8..6b1ffeaf5 100755 --- a/internal_packages/message-list/lib/message-list.cjsx +++ b/internal_packages/message-list/lib/message-list.cjsx @@ -43,7 +43,6 @@ class MessageListScrollTooltip extends React.Component {@state.idx} of {@state.count}
- class MessageList extends React.Component @displayName: 'MessageList' @containerRequired: false diff --git a/internal_packages/message-list/spec/message-item-spec.cjsx b/internal_packages/message-list/spec/message-item-spec.cjsx index c559f1eef..f5b7d5fe4 100644 --- a/internal_packages/message-list/spec/message-item-spec.cjsx +++ b/internal_packages/message-list/spec/message-item-spec.cjsx @@ -12,6 +12,7 @@ ReactTestUtils = React.addons.TestUtils EmailFrameStub = React.createClass({render: ->
}) {InjectedComponent} = require 'nylas-component-kit' + file = new File id: 'file_1_id' filename: 'a.png' diff --git a/spec-nylas/stores/file-upload-store-spec.coffee b/spec-nylas/stores/file-upload-store-spec.coffee new file mode 100644 index 000000000..c215bf437 --- /dev/null +++ b/spec-nylas/stores/file-upload-store-spec.coffee @@ -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() diff --git a/spec-nylas/tasks/file-upload-task-spec.coffee b/spec-nylas/tasks/file-upload-task-spec.coffee index fbda1214c..b01052e0d 100644 --- a/spec-nylas/tasks/file-upload-task-spec.coffee +++ b/spec-nylas/tasks/file-upload-task-spec.coffee @@ -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) diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 6b4139551..a4c152335 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -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 diff --git a/src/flux/models/file.coffee b/src/flux/models/file.coffee index a3d4c05dc..4029c6646 100644 --- a/src/flux/models/file.coffee +++ b/src/flux/models/file.coffee @@ -48,5 +48,4 @@ class File extends Model modelKey: 'contentId' jsonKey: 'content_id' - module.exports = File diff --git a/src/flux/models/utils.coffee b/src/flux/models/utils.coffee index bd96cc7bf..836efc331 100644 --- a/src/flux/models/utils.coffee +++ b/src/flux/models/utils.coffee @@ -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: diff --git a/src/flux/stores/draft-store-proxy.coffee b/src/flux/stores/draft-store-proxy.coffee index a95813055..514d07c06 100644 --- a/src/flux/stores/draft-store-proxy.coffee +++ b/src/flux/stores/draft-store-proxy.coffee @@ -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 ### diff --git a/src/flux/stores/file-download-store.coffee b/src/flux/stores/file-download-store.coffee index 5aea1a10f..e313e82e2 100644 --- a/src/flux/stores/file-download-store.coffee +++ b/src/flux/stores/file-download-store.coffee @@ -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 #################################################### diff --git a/src/flux/stores/file-upload-store.coffee b/src/flux/stores/file-upload-store.coffee index cf344c074..996195431 100644 --- a/src/flux/stores/file-upload-store.coffee +++ b/src/flux/stores/file-upload-store.coffee @@ -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" diff --git a/src/flux/stores/message-store.coffee b/src/flux/stores/message-store.coffee index ffb01da4f..1362d8b34 100644 --- a/src/flux/stores/message-store.coffee +++ b/src/flux/stores/message-store.coffee @@ -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 diff --git a/src/flux/stores/task-queue.coffee b/src/flux/stores/task-queue.coffee index 2979ce2a4..8498fc4b3 100644 --- a/src/flux/stores/task-queue.coffee +++ b/src/flux/stores/task-queue.coffee @@ -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() diff --git a/src/flux/tasks/file-upload-task.coffee b/src/flux/tasks/file-upload-task.coffee index a3e94bde4..d10b4c3c2 100644 --- a/src/flux/tasks/file-upload-task.coffee +++ b/src/flux/tasks/file-upload-task.coffee @@ -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) diff --git a/static/images/attachments/file-doc@2x.png b/static/images/attachments/file-doc@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..245ba46bda296ac9339d027df871460f02ab18a9 GIT binary patch literal 1675 zcmbtTi8s`H6#vcGCu?SqlBI;KGnQl!(hC)1E1lkxCA&=0Ymq$Z=_OlxYD%P(p)zI+ zMm)PWmNd39wk959Y-5bs@qX`hj(^~N&s{$Edq3ZM?m6FkGcn$Wm0;R1005LcJ>303 zZwx9*UJA6^&c?h1ophAzF;@T}Nek^Dth+cC2xk- zAG$a3CIC6e%3iQeQ}<|GdcLkx5W@1%k52l=nA-b%;pT1@u{gLfTEzgh_jDGRf@$h2 zAa?2>#e&U)qHcO%`M;Bslf!1SmzI_`i~mo6BoAzCY)IDD5oyhq#D0-vZG|sVKKD`M zwOas497p0O+P7Ja_$r;)4=Nqv*clFOVpc!8j5f~HiX-ldDwS##Z;Pum zjCu>Shz%p(s5S8n@ARm)3Hd_t@b@|0#A@A`cV=gbY$Kl8$G$+vJa>qBZXI82hQ%9Q zAQ)e(-F2e@RnaSvNESJ~@#)2!%6^k1((b6YX5mGsP@LWA{C%e%+lJ(uhZpXNEZG_J zUNfGw?OKCXWuHJOVsrUZbL{@nnbzLXx|TjNg<3^!BN1Cjl}#jKGl|qfu5Pcc@2+nj z5Q)U#B+M;w`^RQF2fuanjWET#xdYGIk4j-IG@T0GrH`stK;)0*IKLC-D-D6~8(*>7;8X24H zH8rft=yT?0Hi!b2LRv$Kz8X9u-DIf;RoZb!=*Y3?g=*ct4c znhLT1rU~EbjZj0hV5nV|7I$wd_8GoLpsqM=?)Yv*!Ea$TrBPsCNQ>)8EPDVHf|{z zzu+E7Eq|F;R8dB#lR9WfybZb8Trl2{o11F$FbLtD|CNSRIA(Y;mWMO zOHN*|tQB8Dz_M)eJChH3>wTbop@7^e5bbOHIv(vL2h+LOakH1}XqUNNMW~ zEc;UaOLb}sJqKrp9j5dIJ;+kaYFa-*y>-dlz*`*GN2*%f>M9d7veZi(RuEv=r(1+3XVb;q-gxizVk0)xc zsz4XbE}g&F#A=VIw$K@AsQ>P)7*Ip0wGLacmHq5bi`4TfYJgcM!j!Dw&X)lLU1ygH zUxah|^$wo`C^ZVh*?&~apOIx`sq2)yIzQ-V}-SnSl-?pF7WP1v2`LLW;-r&}wCF<-mKkI>hP$oTct3PlYwU z9G3di!N+47s=*5$7Yqn5&$80scyX*qC?_q;0IoyUQ=c}m3SlO@ZMrQT4YtbNlFx%> zDf>bL6X{4oQR1>fW{|5}%Fog9*<`bt+3w=f)&;V&Mx42$lLwBTS)ppOgR2Z literal 0 HcmV?d00001 diff --git a/static/images/attachments/file-docx@2x.png b/static/images/attachments/file-docx@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..245ba46bda296ac9339d027df871460f02ab18a9 GIT binary patch literal 1675 zcmbtTi8s`H6#vcGCu?SqlBI;KGnQl!(hC)1E1lkxCA&=0Ymq$Z=_OlxYD%P(p)zI+ zMm)PWmNd39wk959Y-5bs@qX`hj(^~N&s{$Edq3ZM?m6FkGcn$Wm0;R1005LcJ>303 zZwx9*UJA6^&c?h1ophAzF;@T}Nek^Dth+cC2xk- zAG$a3CIC6e%3iQeQ}<|GdcLkx5W@1%k52l=nA-b%;pT1@u{gLfTEzgh_jDGRf@$h2 zAa?2>#e&U)qHcO%`M;Bslf!1SmzI_`i~mo6BoAzCY)IDD5oyhq#D0-vZG|sVKKD`M zwOas497p0O+P7Ja_$r;)4=Nqv*clFOVpc!8j5f~HiX-ldDwS##Z;Pum zjCu>Shz%p(s5S8n@ARm)3Hd_t@b@|0#A@A`cV=gbY$Kl8$G$+vJa>qBZXI82hQ%9Q zAQ)e(-F2e@RnaSvNESJ~@#)2!%6^k1((b6YX5mGsP@LWA{C%e%+lJ(uhZpXNEZG_J zUNfGw?OKCXWuHJOVsrUZbL{@nnbzLXx|TjNg<3^!BN1Cjl}#jKGl|qfu5Pcc@2+nj z5Q)U#B+M;w`^RQF2fuanjWET#xdYGIk4j-IG@T0GrH`stK;)0*IKLC-D-D6~8(*>7;8X24H zH8rft=yT?0Hi!b2LRv$Kz8X9u-DIf;RoZb!=*Y3?g=*ct4c znhLT1rU~EbjZj0hV5nV|7I$wd_8GoLpsqM=?)Yv*!Ea$TrBPsCNQ>)8EPDVHf|{z zzu+E7Eq|F;R8dB#lR9WfybZb8Trl2{o11F$FbLtD|CNSRIA(Y;mWMO zOHN*|tQB8Dz_M)eJChH3>wTbop@7^e5bbOHIv(vL2h+LOakH1}XqUNNMW~ zEc;UaOLb}sJqKrp9j5dIJ;+kaYFa-*y>-dlz*`*GN2*%f>M9d7veZi(RuEv=r(1+3XVb;q-gxizVk0)xc zsz4XbE}g&F#A=VIw$K@AsQ>P)7*Ip0wGLacmHq5bi`4TfYJgcM!j!Dw&X)lLU1ygH zUxah|^$wo`C^ZVh*?&~apOIx`sq2)yIzQ-V}-SnSl-?pF7WP1v2`LLW;-r&}wCF<-mKkI>hP$oTct3PlYwU z9G3di!N+47s=*5$7Yqn5&$80scyX*qC?_q;0IoyUQ=c}m3SlO@ZMrQT4YtbNlFx%> zDf>bL6X{4oQR1>fW{|5}%Fog9*<`bt+3w=f)&;V&Mx42$lLwBTS)ppOgR2Z literal 0 HcmV?d00001 diff --git a/static/images/attachments/file-fallback@2x.png b/static/images/attachments/file-fallback@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7014a9905322021a61f9fd0abbff159190017096 GIT binary patch literal 643 zcmeAS@N?(olHy`uVBq!ia0vp^YCvqr!3-oD4Yc45bDP46hOx7_4S6Fo+k-*%fF5lvott6XFV_v4SgCuKfM` z7b5lN&mXL6Fa^JW!Mu6%K7aoF@#9BK1^7k&|NsB*-#?(eKpTJm{{8D0$o&Z$xV{4^ z{gNQRUrT@ye)nA#?;9tAdthx!O?_EP(e|N zCpP5vd7xItByV>Yh7ML)4_1`6DX^CT`}oRPT9F9pST$|h%>RV9kY$^IM67t z@A%s1od-9(Nwg4p-A=p~y_&l7lpf^xWqy`)T6AfZ zQSDVft=<1my;lkCyeKAm@X^ZBtnjAxOIe%Oz1^65H!f%E^x0v;HhtFwY_{gT-EH`K z-m|=fS+m_f=<6;uk#1HCiT}OUTzZw-qUrBzuN}YSw&hviwy1nN*_>;ubskAY$Q`*^ zw!M7AZMjDZ=?WhsT3(g>d-bFyD6!Bbv(Dj+{vCapAAcq#{|@$ACMe9_F>B`bI)>Lh Wx@u;8eWJiHV(@hJb6Mw<&;$T0b=Y_S literal 0 HcmV?d00001 diff --git a/static/images/attachments/file-pdf@2x.png b/static/images/attachments/file-pdf@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2274c3b2f1474d5821a09fa427d65cbfdc732faf GIT binary patch literal 1563 zcmbtTiBpqD6yFaLgmB3bjv5eJ3;7ZV6oX*6G2s$0K&V)cg0>=}VpY`9HliYOE6gBO zkxLN;is3Bclt#*7AcO!ym{gF%A_fTI3J4Lp)>i+3zS-S({r0`x_jZ$*f!><9&u{<$ zO039q z07$|Cp#2Ph;qjdMAZGxSPBQ&j35L4;u`hI&w#q#p<+xhu|3m0k_433M-2k`WZ7ZCGNg+{18a)!zOrCK`u zKk*ZX7#L*u$&;MO$lU}21CRf342e(}Y-HrAuYa3Dan{wf)7DNQk)X>Qhci`Ei!nEc zCx^pLPjBWQN=r+V$z;>h)1N~BKdH{kYi(_9RiWV9*gU4w-@!Z2&2{N@0J%A<&%?>Jw2U7;#zWYgRk#|P$)prF$QBHF0K~<{j0{t@wT?5*;&{`saQNE63vc| zDppn&XJ+J6Qwx)m3lkH|a{0>2%3NLD42Lt%<;uId7I?g=^z;cfdom&6twbU#E?ycL zk!EL4=jAPm#js$o16AqyZy@{bRB&JjK(HznmMFDdUs=hLh?2R%sEWuR)BKhn!QwRa5~%be9OFmDmr~=KbnZ6_D$Au~xq}U>s||x{ zdX@nKCbrdyIjqlO(qhH)#@3r7J91sl_wnUpx3(I474At5(-+)vxbU=*k&_lHoe_d2hi>A6mOht8NEh0{1M7-&%nfvwVO9c6|c&fUl z>J0bbNO^Bj^ywV~RcAJ0CT~i{U#0e@xo4HW7FZ8xD;H79kEQnin1;-=s*MwaA3SRG z&+$~w#G9Id>v&UV4CZQ;7Qs>Jp0;TR4d^+YQVkuh7;R0z95)$LWle zn$Q?hEHGX7jcUkQrA;arORey8V|+CKbYf$VyEkY~Cu}{Tb-Q=S7D4&6^9rJ?gQtG3 z%UKELnGFW7G!Sc`4@;D52@*0rfAYkc??S?;PeymXMtFO;je|Yvd9;`vdrhu;#%>Jw z)@?1$onXd&*|~GGj|s~hdo_MJ;fnVV7>>v&2zu(5PYQIRS!~)uaYe|9*HSc-lKO67 zML&q2Ci(Kda&$ityq{C6TPAk{U!S==!MysS>ZXGb?YfUg$#G6L!4p5d4N E2IDK3K>z>% literal 0 HcmV?d00001 diff --git a/static/images/attachments/file-zip@2x.png b/static/images/attachments/file-zip@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6b5b205c7ca60c3ba28afe6ba62064692c3ec96a GIT binary patch literal 1222 zcmeAS@N?(olHy`uVBq!ia0vp^YCvqr!3-oD4Yc45bDP46hOx7_4S6Fo+k-*%fHRz`!^uz$e5Nr~oU-$;t8e z_rG}Y;-5c%uqwbM_yr8+&Yk=D^XHErKjKnGQ2hV@|2{rGZ{ECFwQ5yRP*8Yy_`iSu zJUl$Ct*uwASmEU4C+YhYnr2a^=K{6X(yLfAr|l!i5WG&6>4*`SRPhZ|~Z*YuU18 z=gyrwe*E~sg9m}3_3+`t1q&7c9kF@yW?-<$-I#S7NEw#|`2{mD@e2qFiHNGoMZRQ| z5SM3`Qe&0TlGA0=(|_}}?A;qaa~I#BU=D3NCucKZE?EUdC7v6?l5Rllj7i?^E({&4 zvK~MVXMsm#F#`j)5C}6~x?2Z~Ql=nJ7sn8b(^IcjhYLAMG<=MVG#3&`+0&5PB$f44 z)m&hafUM-%2?krYa5pYk7uh}ilp}}!&x5mLkI1G(zIu3IX6?_piT|V+7-GC9TR1%S zsadUXC(&s3+49xdju}sVyB@cibyr+Ao^}5{e}=I4>;udRqB%W6%bO1-=nCta9pu_k zW^F9Qd_}{0lX0~*yNv9)+dX*}XV1-%!|z=Rn`EW4YU{3T|K9yQcI?+J zE#oW6v*j~ppS^k9e}3@)1N)X-ewn7TdFt7Vo1!I?r`@@C^X}c7ey+Ruh{b4 zbi@AtZ-37dah)oDX~wiElRvYwS6ZqY9mBo!ytAdL&`GZQ1Uz5NR3i5Th${`wLfnHY2Y)Ds@# zH*-?H-e|SDc4Otcyyw%W*PDdt#0gHio*4c_B7059DY4Mf?pXO5KQF${y1}ZLo*E?_ zrZ)AQw4vvrNmA?dR!u(joY(l>{u#R^gV#A$t-NO(_I;}N)VPba{5>J{^NU=qZT#LJ zcyQq`8!s{DbBM<-p literal 0 HcmV?d00001 diff --git a/static/images/attachments/remove-attachment@1x.png b/static/images/attachments/remove-attachment@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..6df89c951297b25a688496be238d8c9c15fb169a GIT binary patch literal 407 zcmeAS@N?(olHy`uVBq!ia0vp@K+MO%3?vUPOnU&NSkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP$DhBC&U#k{d78oSx`PS%1Rm(CZZC|?ysF*Ry z+uensgH;x!le54hvY3H^TL^?1FWs&C0~Ad3ba4#fxNdvqrdX2$5Ay{D35k>tF>{uO z|MySwv)<~z*HYcF&}rW$rgLkgT{9{j-4`pw74Fs+uHu}^_xOQ8pNi~CXTP)ZA0P7g w9}9n_rj_IMBTT}6#(wX(xO(Xgzqiz|AAZZ4vE|Q$P@rWDp00i_>zopr0E$m{UH||9 literal 0 HcmV?d00001