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
This commit is contained in:
Evan Morikawa 2015-06-22 15:49:52 -07:00
parent 2a847d36bd
commit 7739f08e84
17 changed files with 643 additions and 154 deletions

View file

@ -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'

View file

@ -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 {

View file

@ -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

View file

@ -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: =>
<div className="message-actions-wrap">
<div className="message-actions-ellipsis" onClick={@_onShowActionsMenu}>
<RetinaImg name={"message-actions-ellipsis.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
<ButtonDropdown
primaryItem={@_primaryMessageAction()}
secondaryItems={@_secondaryMessageActions()}/>
</div>
_primaryMessageAction: =>
if @_replyType() is "reply"
<span onClick={@_onReply}>
<RetinaImg name="reply-footer.png" mode={RetinaImg.Mode.ContentIsMask}/>
</span>
else # if "reply-all"
<span onClick={@_onReplyAll}>
<RetinaImg name="reply-all-footer.png" mode={RetinaImg.Mode.ContentIsMask}/>
</span>
_secondaryMessageActions: ->
if @_replyType() is "reply"
return [@_replyAllAction(), @_forwardAction()]
else #if "reply-all"
return [@_replyAction(), @_forwardAction()]
_forwardAction: ->
<span onClick={@_onForward}>
<RetinaImg name="forward-message-header.png" mode={RetinaImg.Mode.ContentIsMask}/>&nbsp;&nbsp;Forward
</span>
_replyAction: ->
<span onClick={@_onReply}>
<RetinaImg name="reply-footer.png" mode={RetinaImg.Mode.ContentIsMask}/>&nbsp;&nbsp;Reply
</span>
_replyAllAction: ->
<span onClick={@_onReplyAll}>
<RetinaImg name="reply-all-footer.png" mode={RetinaImg.Mode.ContentIsMask}/>&nbsp;&nbsp;Reply All
</span>
_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
# <InjectedComponentSet className="message-actions"
# inline={true}
# matching={role:"MessageAction"}
# exposedProps={thread:@props.thread, message: @props.message}>
# <button className="btn btn-icon" onClick={@_onReply}>
# <RetinaImg name={"message-reply.png"} mode={RetinaImg.Mode.ContentIsMask}/>
# </button>
# <button className="btn btn-icon" onClick={@_onReplyAll}>
# <RetinaImg name={"message-reply-all.png"} mode={RetinaImg.Mode.ContentIsMask}/>
# </button>
# <button className="btn btn-icon" onClick={@_onForward}>
# <RetinaImg name={"message-forward.png"} mode={RetinaImg.Mode.ContentIsMask}/>
# </button>
# </InjectedComponentSet>

View file

@ -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
</div>
_renderHeader: =>
<header className="message-header">
<header className="message-header" onClick={@_onClickHeader}>
<div className="message-header-right">
<MessageTimestamp className="message-time selectable"
isDetailed={@state.detailedHeaders}
date={@props.message.date} />
<MessageControls thread={@props.thread} message={@props.message}/>
<InjectedComponentSet className="message-indicator"
matching={role: "MessageIndicator"}
exposedProps={message: @props.message}/>
{if @state.detailedHeaders then @_renderMessageActionsInline() else @_renderMessageActionsTooltip()}
</div>
<MessageParticipants to={@props.message.to}
@ -99,15 +100,37 @@ class MessageItem extends React.Component
bcc={@props.message.bcc}
from={@props.message.from}
subject={@props.message.subject}
onClick={=> @setState detailedHeaders: true}
onClick={@_onClickParticipants}
thread_participants={@props.thread_participants}
isDetailed={@state.detailedHeaders}
message_participants={@props.message.participants()} />
{@_renderCollapseControl()}
{@_renderHeaderExpansionControl()}
</header>
_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 <span></span>
## TODO: For now leave blank. There may be an alternative UI in the
#future.
# <span className="msg-actions-tooltip"
# onClick={=> @setState detailedHeaders: true}>
# <RetinaImg name={"message-show-more.png"}/></span>
_renderMessageActions: =>
<div className="message-actions-wrap">
<div className="message-actions-ellipsis" onClick={@_onShowActionsMenu}>
<RetinaImg name={"message-actions-ellipsis.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
<InjectedComponentSet className="message-actions"
inline={true}
matching={role:"MessageAction"}
exposedProps={thread:@props.thread, message: @props.message}>
<button className="btn btn-icon" onClick={@_onReply}>
<RetinaImg name={"message-reply.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
<button className="btn btn-icon" onClick={@_onReplyAll}>
<RetinaImg name={"message-reply-all.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
<button className="btn btn-icon" onClick={@_onForward}>
<RetinaImg name={"message-forward.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
</InjectedComponentSet>
</div>
_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
<div className="collapse-control"
style={top: "4px", left: "-17px"}
onClick={=> @setState detailedHeaders: false}>
<div className="header-toggle-control"
style={top: "18px", left: "-14px"}
onClick={ (e) => @setState detailedHeaders: false; e.stopPropagation()}>
<RetinaImg name={"message-disclosure-triangle-active.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
else
<div className="collapse-control inactive"
style={top: "3px"}
onClick={=> @setState detailedHeaders: true}>
<div className="header-toggle-control inactive"
style={top: "18px"}
onClick={ (e) => @setState detailedHeaders: true; e.stopPropagation()}>
<RetinaImg name={"message-disclosure-triangle.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
@ -277,6 +259,7 @@ class MessageItem extends React.Component
showQuotedText: !@state.showQuotedText
_toggleCollapsed: =>
return if @props.isLastMsg
Actions.toggleMessageIdExpanded(@props.message.id)
_formatContacts: (contacts=[]) =>

View file

@ -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()}
<div className="headers" style={position:'relative'}>
<InjectedComponentSet
className="message-list-notification-bars"
@ -125,19 +128,24 @@ class MessageList extends React.Component
</div>
{@_messageComponents()}
</ScrollRegion>
{@_renderReplyArea()}
<Spinner visible={!@state.ready} />
</div>
_renderSubject: ->
<div className="message-subject-wrap">
<div className="message-count">{@state.messages.length} {if @state.messages.length is 1 then "message" else "messages"}</div>
<div className="message-subject">{@state.currentThread?.subject}</div>
</div>
_renderReplyArea: =>
if @_hasReplyArea()
<div className="footer-reply-area-wrap" onClick={@_onClickReplyArea}>
<div className="footer-reply-area-wrap" onClick={@_onClickReplyArea} key={Utils.generateTempId()}>
<div className="footer-reply-area">
<RetinaImg name="#{@_replyType()}-footer.png" mode={RetinaImg.Mode.ContentIsMask}/>
<span className="reply-text">Write a reply…</span>
</div>
</div>
else return <div></div>
else return <div key={Utils.generateTempId()}></div>
_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 <hr key={idx} className="message-item-divider collapsed" />
else
components.push <hr key={idx} className="message-item-divider" />
components.push @_renderReplyArea()
components
return components
_renderMinifiedBundle: (bundle) ->
BUNDLE_HEIGHT = 36
lines = bundle.messages[0...10]
h = Math.round(BUNDLE_HEIGHT / lines.length)
<div className="minified-bundle"
onClick={ => @setState minified: false }
key={Utils.generateTempId()}>
<div className="num-messages">{bundle.messages.length} older messages</div>
<div className="msg-lines" style={height: h*lines.length}>
{lines.map (msg, i) ->
<div style={height: h*2, top: -h*i} className="msg-line"></div>}
</div>
</div>
_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() ? [])

View file

@ -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"
<div onClick={ -> Actions.popSheet() }
className="message-nav-title">{title}</div>
_onChange: => _.defer =>
@setState(@_getStateFromStores())
_getStateFromStores: =>
tagId: FocusedTagStore.tagId()
module.exports = MessageNavTitle

View file

@ -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: =>
<div className="message-toolbar-subject">{@state.thread?.subject}</div>
_onChange: => _.defer =>
@setState(@_getStateFromStores())
_getStateFromStores: =>
thread: FocusedContentStore.focused('thread')
module.exports = MessageSubjectItem

View file

@ -175,15 +175,15 @@ describe "MessageList", ->
spyOn(MessageStore, "itemsLoading").andCallFake ->
false
@message_list = TestUtils.renderIntoDocument(<MessageList />)
@message_list_node = React.findDOMNode(@message_list)
@messageList = TestUtils.renderIntoDocument(<MessageList />)
@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'}
]

View file

@ -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;

View file

@ -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: =>
<div ref="button" onBlur={@_onBlur} tabIndex={999} className="#{@props.className ? ''} button-dropdown" >
<div className="primary-item">
{@props.primaryItem}
</div>
<div className="secondary-picker" onClick={@_toggleDropdown}>
<RetinaImg name={"icon-thread-disclosure.png"} mode={RetinaImg.Mode.ContentIsMask}/>
</div>
<div className="secondary-items" style={display: if @state.showing then "block" else "none"}>
{@props.secondaryItems.map (item) ->
<div key={Utils.generateTempId()} className="secondary-item">{item}</div>
}
</div>
</div>
_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

View file

@ -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

View file

@ -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()

View file

@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 B

View file

@ -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";