From 7739f08e848be90e6a9bab4caa423c527c911e78 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Mon, 22 Jun 2015 15:49:52 -0700 Subject: [PATCH] feat(message): New Message List UI Summary: Initial message list collapsing messages can be expanded explicitly styling message items composer UI and collapsing expanding and collapsing headers style new reply area adding in message controls Add message actions dropdown Test Plan: edgehill --test Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D1664 --- exports/nylas-component-kit.coffee | 1 + .../composer/stylesheets/composer.less | 6 +- internal_packages/message-list/lib/main.cjsx | 8 +- .../message-list/lib/message-controls.cjsx | 82 +++++++ .../message-list/lib/message-item.cjsx | 89 +++---- .../message-list/lib/message-list.cjsx | 99 ++++++-- .../message-list/lib/message-nav-title.cjsx | 34 +++ .../lib/message-subject-item.cjsx | 26 -- .../message-list/spec/message-list-spec.cjsx | 225 +++++++++++++++--- .../stylesheets/message-list.less | 142 +++++++++-- src/components/button-dropdown.cjsx | 38 +++ src/flux/stores/focused-tag-store.coffee | 6 +- src/flux/stores/message-store.coffee | 4 +- static/components/button-dropdown.less | 36 +++ .../thread-list/icon-thread-disclosure@2x.png | Bin 0 -> 333 bytes .../thread-list/icon-thread-reply@2x.png | Bin 0 -> 699 bytes static/index.less | 1 + 17 files changed, 643 insertions(+), 154 deletions(-) create mode 100644 internal_packages/message-list/lib/message-controls.cjsx create mode 100644 internal_packages/message-list/lib/message-nav-title.cjsx delete mode 100644 internal_packages/message-list/lib/message-subject-item.cjsx create mode 100644 src/components/button-dropdown.cjsx create mode 100644 static/components/button-dropdown.less create mode 100644 static/images/thread-list/icon-thread-disclosure@2x.png create mode 100644 static/images/thread-list/icon-thread-reply@2x.png diff --git a/exports/nylas-component-kit.coffee b/exports/nylas-component-kit.coffee index 4bcd80e63..cf7fc6340 100644 --- a/exports/nylas-component-kit.coffee +++ b/exports/nylas-component-kit.coffee @@ -13,6 +13,7 @@ module.exports = RetinaImg: require '../src/components/retina-img' ListTabular: require '../src/components/list-tabular' DraggableImg: require '../src/components/draggable-img' + ButtonDropdown: require '../src/components/button-dropdown' MultiselectList: require '../src/components/multiselect-list' MultiselectActionBar: require '../src/components/multiselect-action-bar' ResizableRegion: require '../src/components/resizable-region' diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 1b3f1577f..72df0c742 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -50,8 +50,8 @@ width: 100%; max-width: @compose-width; margin: 0 auto; - margin-top: @spacing-standard; padding: 0; + padding-top: 5px; flex: 1; display: flex; @@ -200,6 +200,10 @@ body.is-blurred .composer-inner-wrap .tokenizing-field .token { height: 100%; } + .composer-content-wrap { + margin-top: @spacing-standard; + } + .composer-inner-wrap { .composer-action-bar-wrap { diff --git a/internal_packages/message-list/lib/main.cjsx b/internal_packages/message-list/lib/main.cjsx index 49f64cdb9..cde55a311 100644 --- a/internal_packages/message-list/lib/main.cjsx +++ b/internal_packages/message-list/lib/main.cjsx @@ -1,6 +1,6 @@ MessageList = require "./message-list" MessageToolbarItems = require "./message-toolbar-items" -MessageSubjectItem = require "./message-subject-item" +MessageNavTitle = require "./message-nav-title" {ComponentRegistry, WorkspaceStore} = require 'nylas-exports' SidebarThreadParticipants = require "./sidebar-thread-participants" @@ -16,15 +16,15 @@ module.exports = ComponentRegistry.register MessageToolbarItems, location: WorkspaceStore.Location.MessageList.Toolbar - ComponentRegistry.register MessageSubjectItem, + ComponentRegistry.register MessageNavTitle, location: WorkspaceStore.Location.MessageList.Toolbar ComponentRegistry.register SidebarThreadParticipants, location: WorkspaceStore.Location.MessageListSidebar deactivate: -> - ComponentRegistry.unregister MessageToolbarItems - ComponentRegistry.unregister MessageSubjectItem ComponentRegistry.unregister MessageList + ComponentRegistry.unregister MessageNavTitle + ComponentRegistry.unregister MessageToolbarItems serialize: -> @state diff --git a/internal_packages/message-list/lib/message-controls.cjsx b/internal_packages/message-list/lib/message-controls.cjsx new file mode 100644 index 000000000..fd097f86b --- /dev/null +++ b/internal_packages/message-list/lib/message-controls.cjsx @@ -0,0 +1,82 @@ +React = require 'react' +{Actions} = require 'nylas-exports' +{RetinaImg, ButtonDropdown} = require 'nylas-component-kit' + +class MessageControls extends React.Component + @displayName: "MessageControls" + @propTypes: + thread: React.PropTypes.object.isRequired + message: React.PropTypes.object.isRequired + + constructor: (@props) -> + + render: => +
+
+ +
+ + +
+ + _primaryMessageAction: => + if @_replyType() is "reply" + + + + else # if "reply-all" + + + + + _secondaryMessageActions: -> + if @_replyType() is "reply" + return [@_replyAllAction(), @_forwardAction()] + else #if "reply-all" + return [@_replyAction(), @_forwardAction()] + + _forwardAction: -> + +   Forward + + _replyAction: -> + +   Reply + + _replyAllAction: -> + +   Reply All + + + _onReply: => + Actions.composeReply(thread: @props.thread, message: @props.message) + + _onReplyAll: => + Actions.composeReplyAll(thread: @props.thread, message: @props.message) + + _onForward: => + Actions.composeForward(thread: @props.thread, message: @props.message) + + _replyType: => + if @props.message.cc.length is 0 and @props.message.to.length is 1 + return "reply" + else return "reply-all" + +module.exports = MessageControls + + # + # + # + # + # diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index 16202489d..0c3edb05f 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -4,6 +4,7 @@ _ = require 'underscore' EmailFrame = require './email-frame' MessageParticipants = require "./message-participants" MessageTimestamp = require "./message-timestamp" +MessageControls = require './message-controls' {Utils, Actions, NylasAPI, @@ -80,18 +81,18 @@ class MessageItem extends React.Component _renderHeader: => -
+
+ + - - {if @state.detailedHeaders then @_renderMessageActionsInline() else @_renderMessageActionsTooltip()}
@setState detailedHeaders: true} + onClick={@_onClickParticipants} thread_participants={@props.thread_participants} isDetailed={@state.detailedHeaders} message_participants={@props.message.participants()} /> - {@_renderCollapseControl()} + {@_renderHeaderExpansionControl()}
+ _onClickParticipants: (e) => + el = e.target + while el isnt e.currentTarget + if "collapsed-participants" in el.classList + @setState detailedHeaders: true + e.stopPropagation() + return + el = el.parentElement + return + + _onClickHeader: (e) => + return if @state.detailedHeaders + el = e.target + while el isnt e.currentTarget + wl = ["message-header-right", + "collapsed-participants", + "header-toggle-control"] + if "message-header-right" in el.classList then return + if "collapsed-participants" in el.classList then return + el = el.parentElement + @_toggleCollapsed() + _renderAttachments: => attachments = @_attachmentComponents() if attachments.length > 0 @@ -120,47 +143,6 @@ class MessageItem extends React.Component 'no-quoted-text': (Utils.quotedTextIndex(@props.message.body) is -1) 'show-quoted-text': @state.showQuotedText - _renderMessageActionsInline: => - @_renderMessageActions() - - _renderMessageActionsTooltip: => - return - ## TODO: For now leave blank. There may be an alternative UI in the - #future. - # @setState detailedHeaders: true}> - # - - _renderMessageActions: => -
-
- -
- - - - - -
- - _onReply: => - Actions.composeReply(thread: @props.thread, message: @props.message) - - _onReplyAll: => - Actions.composeReplyAll(thread: @props.thread, message: @props.message) - - _onForward: => - Actions.composeForward(thread: @props.thread, message: @props.message) - _onReport: (issueType) => {Contact, Message, DatabaseStore, NamespaceStore} = require 'nylas-exports' @@ -216,17 +198,17 @@ class MessageItem extends React.Component menu.append(new MenuItem({ label: 'Show Original', click: => @_onShowOriginal()})) menu.popup(remote.getCurrentWindow()) - _renderCollapseControl: => + _renderHeaderExpansionControl: => if @state.detailedHeaders -
@setState detailedHeaders: false}> +
@setState detailedHeaders: false; e.stopPropagation()}>
else -
@setState detailedHeaders: true}> +
@setState detailedHeaders: true; e.stopPropagation()}>
@@ -277,6 +259,7 @@ class MessageItem extends React.Component showQuotedText: !@state.showQuotedText _toggleCollapsed: => + return if @props.isLastMsg Actions.toggleMessageIdExpanded(@props.message.id) _formatContacts: (contacts=[]) => diff --git a/internal_packages/message-list/lib/message-list.cjsx b/internal_packages/message-list/lib/message-list.cjsx index e38499899..d625acadf 100755 --- a/internal_packages/message-list/lib/message-list.cjsx +++ b/internal_packages/message-list/lib/message-list.cjsx @@ -52,6 +52,8 @@ class MessageList extends React.Component constructor: (@props) -> @state = @_getStateFromStores() + @state.minified = true + @MINIFY_THRESHOLD = 3 componentDidMount: => window.addEventListener("resize", @_onResize) @@ -85,7 +87,7 @@ class MessageList extends React.Component else if newDraftIds.length > 0 @_focusDraft(@refs["composerItem-#{newDraftIds[0]}"]) @_prepareContentForDisplay() - + _newDraftIds: (prevState) => oldDraftIds = _.map(_.filter((prevState.messages ? []), (m) -> m.draft), (m) -> m.id) newDraftIds = _.map(_.filter((@state.messages ? []), (m) -> m.draft), (m) -> m.id) @@ -113,6 +115,7 @@ class MessageList extends React.Component scrollTooltipComponent={MessageListScrollTooltip} onScroll={_.debounce(@_cacheScrollPos, 100)} ref="messageWrap"> + {@_renderSubject()}
{@_messageComponents()} - {@_renderReplyArea()}
+ _renderSubject: -> +
+
{@state.messages.length} {if @state.messages.length is 1 then "message" else "messages"}
+
{@state.currentThread?.subject}
+
+ _renderReplyArea: => if @_hasReplyArea() -
+
Write a reply…
- else return
+ else return
_hasReplyArea: => not _.last(@state.messages)?.draft @@ -191,7 +199,13 @@ class MessageList extends React.Component threadParticipants = @_threadParticipants() components = [] - @state.messages?.forEach (message, idx) => + messages = @_messagesWithMinification(@state.messages) + messages.forEach (message, idx) => + + if message.type is "minifiedBundle" + components.push(@_renderMinifiedBundle(message)) + return + collapsed = !@state.messagesExpandedState[message.id] initialScroll = not appliedInitialScroll and not collapsed and @@ -202,6 +216,7 @@ class MessageList extends React.Component className = classNames "message-item-wrap": true + "before-reply-area": (messages.length - 1 is idx) and @_hasReplyArea() "initial-scroll": initialScroll "unread": message.unread "draft": message.draft @@ -219,17 +234,70 @@ class MessageList extends React.Component message={message} className={className} collapsed={collapsed} + isLastMsg={(messages.length - 1 is idx)} thread_participants={threadParticipants} /> - if idx < @state.messages.length - 1 - next = @state.messages[idx + 1] - nextCollapsed = next and !@state.messagesExpandedState[next.id] - if collapsed and nextCollapsed - components.push
- else - components.push
+ components.push @_renderReplyArea() - components + return components + + _renderMinifiedBundle: (bundle) -> + + BUNDLE_HEIGHT = 36 + lines = bundle.messages[0...10] + h = Math.round(BUNDLE_HEIGHT / lines.length) + +
@setState minified: false } + key={Utils.generateTempId()}> +
{bundle.messages.length} older messages
+
+ {lines.map (msg, i) -> +
} +
+
+ + _messagesWithMinification: (messages=[]) => + return messages unless @state.minified + + messages = _.clone(messages) + minifyRanges = [] + consecutiveCollapsed = 0 + + messages.forEach (message, idx) => + return if idx is 0 # Never minify the 1st message + + expandState = @state.messagesExpandedState[message.id] + + if not expandState + consecutiveCollapsed += 1 + else + # We add a +1 because we don't minify the last collapsed message, + # but the MINIFY_THRESHOLD refers to the smallest N that can be in + # the "N older messages" minified block. + if expandState is "default" + minifyOffset = 1 + else # if expandState is "explicit" + minifyOffset = 0 + + if consecutiveCollapsed >= @MINIFY_THRESHOLD + minifyOffset + minifyRanges.push + start: idx - consecutiveCollapsed + length: (consecutiveCollapsed - minifyOffset) + consecutiveCollapsed = 0 + + indexOffset = 0 + for range in minifyRanges + start = range.start - indexOffset + minified = + type: "minifiedBundle" + messages: messages[start...(start+range.length)] + messages.splice(start, range.length, minified) + + # While we removed `range.length` items, we also added 1 back in. + indexOffset += (range.length - 1) + + return messages # Some child components (like the composer) might request that we scroll # to a given location. If `selectionTop` is defined that means we should @@ -256,7 +324,10 @@ class MessageList extends React.Component messageWrap = React.findDOMNode(@refs.messageWrap) _onChange: => - @setState(@_getStateFromStores()) + newState = @_getStateFromStores() + if @state.currentThread isnt newState.currentThread + newState.minified = true + @setState(newState) _getStateFromStores: => messages: (MessageStore.items() ? []) diff --git a/internal_packages/message-list/lib/message-nav-title.cjsx b/internal_packages/message-list/lib/message-nav-title.cjsx new file mode 100644 index 000000000..51b324598 --- /dev/null +++ b/internal_packages/message-list/lib/message-nav-title.cjsx @@ -0,0 +1,34 @@ + +_ = require 'underscore' +_str = require 'underscore.string' +React = require 'react' +{Actions, FocusedTagStore} = require 'nylas-exports' + +class MessageNavTitle extends React.Component + @displayName: 'MessageNavTitle' + + constructor: (@props) -> + @state = @_getStateFromStores() + + componentDidMount: => + @_unsubscriber = FocusedTagStore.listen @_onChange + + componentWillUnmount: => + @_unsubscriber() if @_unsubscriber + + render: => + if @state.tagId + title = "Back to #{_str.titleize(@state.tagId)}" + else + title = "Back" + +
Actions.popSheet() } + className="message-nav-title">{title}
+ + _onChange: => _.defer => + @setState(@_getStateFromStores()) + + _getStateFromStores: => + tagId: FocusedTagStore.tagId() + +module.exports = MessageNavTitle diff --git a/internal_packages/message-list/lib/message-subject-item.cjsx b/internal_packages/message-list/lib/message-subject-item.cjsx deleted file mode 100644 index 366035a50..000000000 --- a/internal_packages/message-list/lib/message-subject-item.cjsx +++ /dev/null @@ -1,26 +0,0 @@ -_ = require 'underscore' -React = require 'react' -{FocusedContentStore} = require 'nylas-exports' - -class MessageSubjectItem extends React.Component - @displayName: 'MessageSubjectItem' - - constructor: (@props) -> - @state = @_getStateFromStores() - - componentDidMount: => - @_unsubscriber = FocusedContentStore.listen @_onChange - - componentWillUnmount: => - @_unsubscriber() if @_unsubscriber - - render: => -
{@state.thread?.subject}
- - _onChange: => _.defer => - @setState(@_getStateFromStores()) - - _getStateFromStores: => - thread: FocusedContentStore.focused('thread') - -module.exports = MessageSubjectItem diff --git a/internal_packages/message-list/spec/message-list-spec.cjsx b/internal_packages/message-list/spec/message-list-spec.cjsx index 33f705937..38f96314e 100644 --- a/internal_packages/message-list/spec/message-list-spec.cjsx +++ b/internal_packages/message-list/spec/message-list-spec.cjsx @@ -175,15 +175,15 @@ describe "MessageList", -> spyOn(MessageStore, "itemsLoading").andCallFake -> false - @message_list = TestUtils.renderIntoDocument() - @message_list_node = React.findDOMNode(@message_list) + @messageList = TestUtils.renderIntoDocument() + @messageList_node = React.findDOMNode(@messageList) it "renders into the document", -> - expect(TestUtils.isCompositeComponentWithType(@message_list, + expect(TestUtils.isCompositeComponentWithType(@messageList, MessageList)).toBe true it "by default has zero children", -> - items = TestUtils.scryRenderedComponentsWithType(@message_list, + items = TestUtils.scryRenderedComponentsWithType(@messageList, MessageItem) expect(items.length).toBe 0 @@ -193,71 +193,240 @@ describe "MessageList", -> MessageStore._items = testMessages MessageStore._expandItemsToDefault() MessageStore.trigger(MessageStore) - @message_list.setState currentThread: test_thread + @messageList.setState currentThread: test_thread it "renders all the correct number of messages", -> - items = TestUtils.scryRenderedComponentsWithType(@message_list, + items = TestUtils.scryRenderedComponentsWithType(@messageList, MessageItem) expect(items.length).toBe 5 it "renders the correct number of expanded messages", -> - msgs = TestUtils.scryRenderedDOMComponentsWithClass(@message_list, "message-item-wrap collapsed") + msgs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "message-item-wrap collapsed") expect(msgs.length).toBe 4 it "aggregates participants across all messages", -> - expect(@message_list._threadParticipants().length).toBe 4 - expect(@message_list._threadParticipants()[0] instanceof Contact).toBe true + expect(@messageList._threadParticipants().length).toBe 4 + expect(@messageList._threadParticipants()[0] instanceof Contact).toBe true it "displays lists of participants on the page", -> - items = TestUtils.scryRenderedComponentsWithType(@message_list, + items = TestUtils.scryRenderedComponentsWithType(@messageList, MessageParticipants) expect(items.length).toBe 1 it "focuses new composers when a draft is added", -> - spyOn(@message_list, "_focusDraft") - msgs = @message_list.state.messages + spyOn(@messageList, "_focusDraft") + msgs = @messageList.state.messages - @message_list.setState + @messageList.setState messages: msgs.concat(draftMessages) - expect(@message_list._focusDraft).toHaveBeenCalled() - expect(@message_list._focusDraft.mostRecentCall.args[0].props.exposedProps.localId).toEqual(draftMessages[0].id) + expect(@messageList._focusDraft).toHaveBeenCalled() + expect(@messageList._focusDraft.mostRecentCall.args[0].props.exposedProps.localId).toEqual(draftMessages[0].id) describe "MessageList with draft", -> beforeEach -> MessageStore._items = testMessages.concat draftMessages MessageStore.trigger(MessageStore) - spyOn(@message_list, "_focusDraft") - @message_list.setState(currentThread: test_thread) + spyOn(@messageList, "_focusDraft") + @messageList.setState(currentThread: test_thread) it "renders the composer", -> - items = TestUtils.scryRenderedComponentsWithTypeAndProps(@message_list, InjectedComponent, matching: {role:"Composer"}) - expect(@message_list.state.messages.length).toBe 6 + items = TestUtils.scryRenderedComponentsWithTypeAndProps(@messageList, InjectedComponent, matching: {role:"Composer"}) + expect(@messageList.state.messages.length).toBe 6 expect(items.length).toBe 1 it "doesn't focus on initial load", -> - expect(@message_list._focusDraft).not.toHaveBeenCalled() + expect(@messageList._focusDraft).not.toHaveBeenCalled() describe "reply type", -> it "prompts for a reply when there's only one participant", -> MessageStore._items = [m3, m5] MessageStore.trigger() - @message_list.setState currentThread: test_thread - expect(@message_list._replyType()).toBe "reply" - cs = TestUtils.scryRenderedDOMComponentsWithClass(@message_list, "footer-reply-area") + @messageList.setState currentThread: test_thread + expect(@messageList._replyType()).toBe "reply" + cs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area") expect(cs.length).toBe 1 it "prompts for a reply-all when there's more then one participant", -> MessageStore._items = [m5, m3] MessageStore.trigger() - @message_list.setState currentThread: test_thread - expect(@message_list._replyType()).toBe "reply-all" - cs = TestUtils.scryRenderedDOMComponentsWithClass(@message_list, "footer-reply-area") + @messageList.setState currentThread: test_thread + expect(@messageList._replyType()).toBe "reply-all" + cs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area") expect(cs.length).toBe 1 it "hides the reply type if the last message is a draft", -> MessageStore._items = [m5, m3, draftMessages[0]] MessageStore.trigger() - @message_list.setState currentThread: test_thread - cs = TestUtils.scryRenderedDOMComponentsWithClass(@message_list, "footer-reply-area") + @messageList.setState currentThread: test_thread + cs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "footer-reply-area") expect(cs.length).toBe 0 + + describe "Message minification", -> + beforeEach -> + @messageList.MINIFY_THRESHOLD = 3 + @messageList.setState minified: true + @messages = [ + {id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'}, {id: 'g'} + ] + + it "ignores the first message if it's collapsed", -> + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: false, f: false, g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}] + }, + {id: 'f'}, + {id: 'g'} + ] + + it "ignores the first message if it's expanded", -> + @messageList.setState messagesExpandedState: + a: "default", b: false, c: false, d: false, e: false, f: false, g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}] + }, + {id: 'f'}, + {id: 'g'} + ] + + it "doesn't minify the last collapsed message", -> + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: false, f: "default", g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}] + }, + {id: 'e'}, + {id: 'f'}, + {id: 'g'} + ] + + it "allows explicitly expanded messages", -> + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: false, f: "explicit", g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}] + }, + {id: 'f'}, + {id: 'g'} + ] + + it "doesn't minify if the threshold isn't reached", -> + @messageList.setState messagesExpandedState: + a: false, b: "default", c: false, d: "default", e: false, f: "default", g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'}, + {id: 'd'}, + {id: 'e'}, + {id: 'f'}, + {id: 'g'} + ] + + it "doesn't minify if the threshold isn't reached due to the rule about not minifying the last collapsed messages", -> + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: "default", f: "default", g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'}, + {id: 'd'}, + {id: 'e'}, + {id: 'f'}, + {id: 'g'} + ] + + it "minifies at the threshold if the message is explicitly expanded", -> + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: "explicit", f: "default", g: "default" + + out = @messageList._messagesWithMinification(@messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}] + }, + {id: 'e'}, + {id: 'f'}, + {id: 'g'} + ] + + it "can have multiple minification blocks", -> + messages = [ + {id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'}, + {id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}, {id: 'k'}, {id: 'l'} + ] + + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: false, f: "default", + g: false, h: false, i: false, j: false, k: false, l: "default" + + out = @messageList._messagesWithMinification(messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}] + }, + {id: 'e'}, + {id: 'f'}, + { + type: "minifiedBundle" + messages: [{id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}] + }, + {id: 'k'}, + {id: 'l'} + ] + + it "can have multiple minification blocks next to explicitly expanded messages", -> + messages = [ + {id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'f'}, + {id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}, {id: 'k'}, {id: 'l'} + ] + + @messageList.setState messagesExpandedState: + a: false, b: false, c: false, d: false, e: "explicit", f: "default", + g: false, h: false, i: false, j: false, k: "explicit", l: "default" + + out = @messageList._messagesWithMinification(messages) + expect(out).toEqual [ + {id: 'a'}, + { + type: "minifiedBundle" + messages: [{id: 'b'}, {id: 'c'}, {id: 'd'}] + }, + {id: 'e'}, + {id: 'f'}, + { + type: "minifiedBundle" + messages: [{id: 'g'}, {id: 'h'}, {id: 'i'}, {id: 'j'}] + }, + {id: 'k'}, + {id: 'l'} + ] diff --git a/internal_packages/message-list/stylesheets/message-list.less b/internal_packages/message-list/stylesheets/message-list.less index db36907d7..52ab46822 100644 --- a/internal_packages/message-list/stylesheets/message-list.less +++ b/internal_packages/message-list/stylesheets/message-list.less @@ -2,6 +2,7 @@ @import "ui-mixins"; @message-max-width: 800px; +@message-spacing: 6px; .tag-picker { .menu { @@ -26,11 +27,10 @@ opacity: 0; } - .message-toolbar-subject { + .message-nav-title { order:-99; cursor: default; color:@text-color-heading; - -webkit-app-region: drag; margin:0; margin-top:11px; font-size: @font-size-h4; @@ -57,10 +57,11 @@ } .mode-split { - .message-toolbar-subject { - margin-left:@padding-base-horizontal; + .message-nav-title { + display: none; } } + #message-list { display: flex; flex-direction: column; @@ -72,6 +73,21 @@ padding: 0; order: 2; + .message-subject-wrap { + width: calc(~"100% - 12px"); + max-width: @message-max-width; + margin: 11px auto 10px auto; + padding: 0 20px; + } + .message-subject { + font-size: @font-size-large; + } + .message-count { + float: right; + font-size: @font-size-small; + color: @text-color-very-subtle; + } + .message-list-headers { margin: 0 auto; width: 100%; @@ -94,24 +110,46 @@ overflow-y: auto; opacity:0; transition: opacity 0s; + background: @background-secondary; } .messages-wrap.ready { opacity:1; transition: opacity .1s linear; } - .message-item-wrap { - position: relative; - width: 100%; + .minified-bundle + .message-item-wrap { + margin-top: 0; + } - margin: 0 auto; - padding: @spacing-standard 0 @spacing-double 0; + .message-item-wrap { + + transition: height 0.1s; + position: relative; + + max-width: @message-max-width; + width: calc(~"100% - 12px"); + + margin: @message-spacing auto; + padding: 0 0 5px 0; + + background: @background-primary; + border: 1px solid @border-secondary-bg; + box-shadow: 0 1px 1.5px rgba(0,0,0,0.14); + border-radius: 4px; &:first-child { padding-top: 0; } + &.before-reply-area { margin-bottom: 0; } + &.collapsed { - background-color: darken(@background-primary, 3%); - padding-bottom: 0; + background-color: darken(@background-primary, 2%); + padding-top: 19px; + padding-bottom: 8px; + margin-bottom: 0; + + &+.minified-bundle { + margin-top: -@message-spacing + } } &.collapsed .message-item-area { @@ -131,7 +169,7 @@ .collapsed-from { font-weight: @font-weight-semi-bold; - color: @text-color; + color: @text-color-very-subtle; // min-width: 60px; margin-right: 1em; } @@ -142,7 +180,10 @@ } } - transition: all .125s ease-in-out; + } + + .collapsed-participants { + padding: 10px 10px 10px 0; } .message-item-divider { @@ -159,15 +200,51 @@ } } + .minified-bundle { + margin-right: @message-spacing; + margin-left: @message-spacing; + position: relative; + .num-messages { + position: absolute; + top: 50%; + left: 50%; + margin-left: -80px; + margin-top: -12px; + border-radius: 15px; + border: 1px solid @border-color-divider; + width: 160px; + background: @background-primary; + text-align: center; + color: @text-color-very-subtle; + z-index: 2; + background: @background-primary; + &:hover { + cursor: default; + } + } + .msg-lines { + max-width: @message-max-width; + margin: 0 auto; + width: 100%; + } + .msg-line { + border-radius: 4px 4px 0 0; + position: relative; + border-top: 1px solid @border-color-divider; + border-right: 1px solid @border-color-divider; + border-left: 1px solid @border-color-divider; + background-color: darken(@background-primary, 2%); + box-shadow: 0px 1px 1px rgba(0,0,0,0.1) + } + } + .message-header { position: relative; font-size: @font-size-small; - border-bottom: 1px solid @border-color-divider; padding-bottom: @spacing-standard; - padding-top: 5px; + padding-top: 19px; .message-actions-wrap { - width: 100%; text-align: right; } @@ -200,8 +277,10 @@ } .message-time { + padding-top: 5px; z-index: 2; position: relative; display: inline-block; + min-width: 125px; } .msg-actions-tooltip { display: inline-block; @@ -214,9 +293,11 @@ } .message-header-right { - z-index: 4; position: relative; + z-index: 4; + position: relative; float: right; text-align: right; + display: flex; } } @@ -225,7 +306,7 @@ width: 100%; max-width: @message-max-width; margin: 0 auto; - padding: 0 @spacing-double @spacing-standard @spacing-double; + padding: 0 20px @spacing-standard 20px; iframe { width: 100%; @@ -236,22 +317,37 @@ } } + .collapse-region { + width: calc(~"100% - 30px"); + height: 56px; + position: absolute; + top: 0; + } - .collapse-control { + .header-toggle-control { &.inactive { display: none; } z-index: 3; position: absolute; top: 0; - left: -1 * @spacing-standard; + left: -1 * 13px; img { background: @text-color-very-subtle; } } .message-item-wrap:hover { - .collapse-control.inactive { display: block; } + .header-toggle-control.inactive { display: block; } } .footer-reply-area-wrap { - width: 100%; - border-top: 1px solid @border-color-divider; + width: calc(~"100% - 12px"); + max-width: @message-max-width; + margin: -3px auto @spacing-double auto; + + position: relative; + z-index: 2; + + border: 1px solid @border-secondary-bg; + box-shadow: 0 1px 1.5px rgba(0,0,0,0.14); + border-top: 1px dashed @border-color-divider; + border-radius: 0 0 4px 4px; background: @background-primary; color: @text-color-very-subtle; diff --git a/src/components/button-dropdown.cjsx b/src/components/button-dropdown.cjsx new file mode 100644 index 000000000..5f791ef0e --- /dev/null +++ b/src/components/button-dropdown.cjsx @@ -0,0 +1,38 @@ +RetinaImg = require './retina-img' +{Utils} = require 'nylas-exports' + +React = require 'react' +class ButtonDropdown extends React.Component + @displayName: "MessageControls" + @propTypes: + primaryItem: React.PropTypes.element + secondaryItems: React.PropTypes.arrayOf(React.PropTypes.element) + + constructor: (@props) -> + @state = showing: false + + render: => +
+
+ {@props.primaryItem} +
+
+ +
+
+ {@props.secondaryItems.map (item) -> +
{item}
+ } +
+
+ + _toggleDropdown: => + @setState showing: !@state.showing + + _onBlur: (event) => + target = event.nativeEvent.relatedTarget + if target? and React.findDOMNode(@refs.button).contains(target) + return + @setState showing: false + +module.exports = ButtonDropdown diff --git a/src/flux/stores/focused-tag-store.coffee b/src/flux/stores/focused-tag-store.coffee index ee00743e5..d4197806c 100644 --- a/src/flux/stores/focused-tag-store.coffee +++ b/src/flux/stores/focused-tag-store.coffee @@ -27,8 +27,8 @@ FocusedTagStore = Reflux.createStore @_setTag(tag) - _onSearchQueryCommitted: (query) -> - if query + _onSearchQueryCommitted: (_query) -> + if _query @_oldTag = @_tag @_setTag(null) else if @_oldTag @@ -40,7 +40,7 @@ FocusedTagStore = Reflux.createStore @trigger() # Public Methods - + tag: -> @_tag diff --git a/src/flux/stores/message-store.coffee b/src/flux/stores/message-store.coffee index dfd21ac74..2455b652b 100644 --- a/src/flux/stores/message-store.coffee +++ b/src/flux/stores/message-store.coffee @@ -93,7 +93,7 @@ MessageStore = Reflux.createStore if @_itemsExpanded[id] delete @_itemsExpanded[id] else - @_itemsExpanded[id] = true + @_itemsExpanded[id] = "explicit" for item, idx in @_items if @_itemsExpanded[item.id] and not _.isString(item.body) @_fetchMessageIdFromAPI(item.id) @@ -169,7 +169,7 @@ MessageStore = Reflux.createStore _expandItemsToDefault: -> for item, idx in @_items if item.unread or item.draft or idx is @_items.length - 1 - @_itemsExpanded[item.id] = true + @_itemsExpanded[item.id] = "default" _fetchMessages: -> namespace = NamespaceStore.current() diff --git a/static/components/button-dropdown.less b/static/components/button-dropdown.less new file mode 100644 index 000000000..8f5689b54 --- /dev/null +++ b/static/components/button-dropdown.less @@ -0,0 +1,36 @@ +@import "ui-variables"; + +.button-dropdown { + position: relative; + display: inline-block; + .primary-item { + .btn(); + height: 32px; + border-radius: 4px 0 0 4px; + padding-top: 7px; + } + .secondary-picker { + .btn(); + height: 32px; + border-radius: 0 4px 4px 0; + border-left: 0; + padding: 7px 8px 3px 8px; + } + .secondary-items { + &:hover { + cursor: default; + } + border: 1px solid rgba(0,0,0,0.1); + border-radius: 4px 0 4px 4px; + padding: 4px 15px; + box-shadow: 0 4px 12px rgba(0,0,0,0.55); + z-index: 2; + background-color: @background-primary;; + position: absolute; + width: 110px; + right: 0; + } + img { + background-color: @text-color; + } +} diff --git a/static/images/thread-list/icon-thread-disclosure@2x.png b/static/images/thread-list/icon-thread-disclosure@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a49a693f9ec1b6d0e552d4fe2f3e0dc748f7cbdc GIT binary patch literal 333 zcmV-T0kZyyP)Px$2T4RhR45f=U;u)E@HkKZh`2x?lM%!x1_XsC8u>>gT7lFu28PG^|7T>}0M$5s z!-|F3K=ywqA1mb_o>0Na_`e+_`Jdr`G}C`323{nUnE?^;Q-OS(EEG&;n)oL`<2ai z^X55UnL&yO8HC$_S>HhVQT!en8p`&EnQJvLRQ*7bAOK{a04lP8@IWNvK8On-27pBn zk`NX)gAi&k4FJi3y@ODM(`!H_*bTsH2uKh!jBpzOQjZcQAOU0m^4c$EPQQO&Iks)y fj0gi{H6S(s8clzSk0>{000000NkvXXu0mjf*8_nP literal 0 HcmV?d00001 diff --git a/static/images/thread-list/icon-thread-reply@2x.png b/static/images/thread-list/icon-thread-reply@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..b04143fb811e2dea021cf9b87daf53e5981af581 GIT binary patch literal 699 zcmV;s0!00ZP)Px%bxA})R7efYmd|TbK@i7h_9d|jUK9nvOVGBardkE1s8>-BsiyLp3f5Ky5B>$! zgXE;1yb1*|t%N3h(3Irj!5<*r^k7SC8iJnGyPz@DWXIXSi!aH`@4o87vO7E9nfZ{} zY=Y3@7}2jD6q*)g`EDuUMsi{HO^cyz%S0;?3P{tm*Q`Cs^MEGcek*aEYMMfsKm@Sg zEJ8q^&Ak(ALnu2S1bpQ#{<`f1jG5DAZmQRMK!u;4Kq3{%RqHp?M!RH!=mZE=HqY%vvQVWz+$C40*h3iaS}Q6h&=&VV?v%Ef93RTj%+1mFt#r5`1*a zZKn)l=DV^hoOWP{^e{0$cc&8|yO`0?)Cq3gQ|`*)N&f&G>@%IlLd6!vR$(G+K)43R%P4dU5 z^V~4ch;TZ1<=O+&?shc{<40cm9poBWYc3%~SlN7TKenQk7sn>fp%Bll1QO_YMfeC_ru?t+c{9jD~=4#q!1*_Z2(A2b5*UcVDE>y%5>}&|7#I|5a!HPs(7%tiK zgu~yf6iDzDKt5^;rNjyhh{pH==J~gaztI#|=bJIq+vG0hhB;@yOXhkL@n`9_^6L8a hJpp~f+g4Bq;y=CM`jpcCz8C-i002ovPDHLkV1hU|QuzP? literal 0 HcmV?d00001 diff --git a/static/index.less b/static/index.less index e11a7fac4..680b8e7b8 100644 --- a/static/index.less +++ b/static/index.less @@ -18,6 +18,7 @@ @import "components/tokenizing-text-field"; @import "components/extra"; @import "components/list-tabular"; +@import "components/button-dropdown"; @import "components/scroll-region"; @import "components/spinner"; @import "components/generated-form";