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 000000000..a49a693f9 Binary files /dev/null and b/static/images/thread-list/icon-thread-disclosure@2x.png differ 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 000000000..b04143fb8 Binary files /dev/null and b/static/images/thread-list/icon-thread-reply@2x.png differ 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";