feat(messages): floating reply area in message-list

Summary:
reply text now scrolls to bottom on new draft

tests for reply type

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://review.inboxapp.com/D1338
This commit is contained in:
Evan Morikawa 2015-03-24 16:57:24 -04:00
parent 27345e7719
commit e1ec298d4b
11 changed files with 251 additions and 134 deletions

View file

@ -92,26 +92,30 @@ MessageItem = React.createClass
@_renderMessageActions()
_renderMessageActionsTooltip: ->
## TODO: Use Tooltip UI Component
<span className="msg-actions-tooltip"
onClick={=> @setState detailedHeaders: true}>
<RetinaImg name={"message-show-more.png"}/></span>
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: ->
messageActions = ComponentRegistry.findAllViewsByRole('MessageAction')
<div className="message-actions">
<button className="btn btn-icon" onClick={@_onReply}>
<RetinaImg name={"message-reply.png"}/>
</button>
<button className="btn btn-icon" onClick={@_onReplyAll}>
<RetinaImg name={"message-reply-all.png"}/>
</button>
<button className="btn btn-icon" onClick={@_onForward}>
<RetinaImg name={"message-forward.png"}/>
</button>
<div className="message-actions-wrap">
<div className="message-actions">
<button className="btn btn-icon" onClick={@_onReply}>
<RetinaImg name={"message-reply.png"}/>
</button>
<button className="btn btn-icon" onClick={@_onReplyAll}>
<RetinaImg name={"message-reply-all.png"}/>
</button>
<button className="btn btn-icon" onClick={@_onForward}>
<RetinaImg name={"message-forward.png"}/>
</button>
{<Action thread={@props.thread} message={@props.message} /> for Action in messageActions}
{<Action thread={@props.thread} message={@props.message} /> for Action in messageActions}
</div>
</div>
_onReply: ->
@ -129,13 +133,13 @@ MessageItem = React.createClass
_renderCollapseControl: ->
if @state.detailedHeaders
<div className="collapse-control"
style={top: "-1px", left: "-17px"}
style={top: "4px", left: "-17px"}
onClick={=> @setState detailedHeaders: false}>
<RetinaImg name={"message-disclosure-triangle-active.png"}/>
</div>
else
<div className="collapse-control inactive"
style={top: "-2px"}
style={top: "3px"}
onClick={=> @setState detailedHeaders: true}>
<RetinaImg name={"message-disclosure-triangle.png"}/>
</div>

View file

@ -2,7 +2,7 @@ _ = require 'underscore-plus'
React = require 'react'
MessageItem = require "./message-item"
{Actions, ThreadStore, MessageStore, ComponentRegistry} = require("inbox-exports")
{Spinner, ResizableRegion} = require('ui-components')
{Spinner, ResizableRegion, RetinaImg} = require('ui-components')
module.exports =
MessageList = React.createClass
@ -39,6 +39,7 @@ MessageList = React.createClass
else if didAddDraft
@_focusDraft(@refs["composerItem-#{addedDraftIds[0]}"])
@_prepareContentForDisplay()
_focusDraft: (draftDOMNode) ->
# We need a 100ms delay so the DOM can finish painting the elements on
@ -54,6 +55,7 @@ MessageList = React.createClass
wrapClass = React.addons.classSet
"messages-wrap": true
"has-reply-area": @_hasReplyArea()
"ready": @state.ready
<div className="message-list" id="message-list">
@ -65,9 +67,36 @@ MessageList = React.createClass
{@_messageListHeaders()}
{@_messageComponents()}
</div>
{@_renderReplyArea()}
<Spinner visible={!@state.ready} />
</div>
_renderReplyArea: ->
if @_hasReplyArea()
<div className="footer-reply-area-wrap" onClick={@_onClickReplyArea}>
<div className="footer-reply-area">
<RetinaImg name="#{@_replyType()}-footer.png" /><span className="reply-text">Write a reply…</span>
</div>
</div>
else return <div></div>
_hasReplyArea: ->
not _.last(@state.messages)?.draft
# Either returns "reply" or "reply-all"
_replyType: ->
lastMsg = _.last(_.filter((@state.messages ? []), (m) -> not m.draft))
if lastMsg?.cc.length is 0 and lastMsg?.to.length is 1
return "reply"
else return "reply-all"
_onClickReplyArea: ->
return unless @state.currentThread?.id
if @_replyType() is "reply-all"
Actions.composeReplyAll(threadId: @state.currentThread.id)
else
Actions.composeReply(threadId: @state.currentThread.id)
# There may be a lot of iframes to load which may take an indeterminate
# amount of time. As long as there is more content being painted onto
# the page and our height is changing, keep waiting. Then scroll to message.
@ -141,6 +170,9 @@ MessageList = React.createClass
className={className}
thread_participants={@_threadParticipants()} />
unless idx is @state.messages.length - 1
components.push <hr className="message-item-divider" />
components
_onChange: ->

View file

@ -18,7 +18,7 @@ MessageParticipants = React.createClass
_renderCollapsed: ->
<span className="collapsed-participants">
<span className="participant-name from-contact">{@_shortNames(@props.from)}</span>
<span className="participant-label to-label">&nbsp;>&nbsp;</span>
<span className="participant-label to-label">To:&nbsp;</span>
<span className="participant-name to-contact">{@_shortNames(@props.to)}</span>
<span style={if @props.cc?.length > 0 then display:"inline" else display:"none"}>
<span className="participant-label cc-label">Cc:&nbsp;</span>
@ -33,7 +33,6 @@ MessageParticipants = React.createClass
_renderExpanded: ->
<div className="expanded-participants">
<div className="participant-type">
<div className="participant-label from-label">From:&nbsp;</div>
<div className="participant-name from-contact">{@_fullContact(@props.from)}</div>
</div>
@ -54,11 +53,6 @@ MessageParticipants = React.createClass
<div className="participant-name cc-contact">{@_fullContact(@props.bcc)}</div>
</div>
<div className="participant-type">
<div className="subject-label">Subject:&nbsp;</div>
<div className="subject">{@props.subject}</div>
</div>
</div>
_shortNames: (contacts=[]) ->

View file

@ -66,93 +66,92 @@ user_5 = _.extend _.clone(user_headers),
name: "User Five"
email: "user5@inboxapp.com"
testMessages = [
(new Message).fromJSON({
"id" : "111",
"from" : [ user_1 ],
"to" : [ user_2 ],
"cc" : [ user_3, user_4 ],
"bcc" : null,
"body" : "Body One",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet one...",
"subject" : "Subject One",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
}),
(new Message).fromJSON({
"id" : "222",
"from" : [ user_2 ],
"to" : [ user_1 ],
"cc" : [ user_3, user_4 ],
"bcc" : null,
"body" : "Body Two",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Two...",
"subject" : "Subject Two",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
}),
(new Message).fromJSON({
"id" : "333",
"from" : [ user_3 ],
"to" : [ user_1 ],
"cc" : [ user_2, user_4 ],
"bcc" : [],
"body" : "Body Three",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Three...",
"subject" : "Subject Three",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
}),
(new Message).fromJSON({
"id" : "444",
"from" : [ user_4 ],
"to" : [ user_1 ],
"cc" : [],
"bcc" : [ user_5 ],
"body" : "Body Four",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Four...",
"subject" : "Subject Four",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
}),
(new Message).fromJSON({
"id" : "555",
"from" : [ user_1 ],
"to" : [ user_4 ],
"cc" : [],
"bcc" : [],
"body" : "Body Five",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Five...",
"subject" : "Subject Five",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
}),
]
m1 = (new Message).fromJSON({
"id" : "111",
"from" : [ user_1 ],
"to" : [ user_2 ],
"cc" : [ user_3, user_4 ],
"bcc" : null,
"body" : "Body One",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet one...",
"subject" : "Subject One",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
})
m2 = (new Message).fromJSON({
"id" : "222",
"from" : [ user_2 ],
"to" : [ user_1 ],
"cc" : [ user_3, user_4 ],
"bcc" : null,
"body" : "Body Two",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Two...",
"subject" : "Subject Two",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
})
m3 = (new Message).fromJSON({
"id" : "333",
"from" : [ user_3 ],
"to" : [ user_1 ],
"cc" : [ user_2, user_4 ],
"bcc" : [],
"body" : "Body Three",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Three...",
"subject" : "Subject Three",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
})
m4 = (new Message).fromJSON({
"id" : "444",
"from" : [ user_4 ],
"to" : [ user_1 ],
"cc" : [],
"bcc" : [ user_5 ],
"body" : "Body Four",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Four...",
"subject" : "Subject Four",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
})
m5 = (new Message).fromJSON({
"id" : "555",
"from" : [ user_1 ],
"to" : [ user_4 ],
"cc" : [],
"bcc" : [],
"body" : "Body Five",
"date" : 1415814587,
"draft" : false
"files" : [],
"unread" : false,
"object" : "message",
"snippet" : "snippet Five...",
"subject" : "Subject Five",
"thread_id" : "thread_12345",
"namespace_id" : "nsid"
})
testMessages = [m1, m2, m3, m4, m5]
draftMessages = [
(new Message).fromJSON({
"id" : "666",
@ -273,3 +272,27 @@ describe "MessageList", ->
expect(items.length).toBe 1
expect(items.length).toBe 1
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")
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")
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")
expect(cs.length).toBe 0

View file

@ -82,6 +82,9 @@
// top:0; left:0; right:0; bottom:0;
opacity:0;
transition: opacity 0s;
&.has-reply-area {
padding-bottom: 63px; // height of reply footer
}
}
.messages-wrap.ready {
opacity:1;
@ -93,37 +96,61 @@
width: 100%;
margin: 0 auto;
padding: @spacing-double 0;
padding: @spacing-standard 0 @spacing-double 0;
&:first-child { padding-top: 0; }
border-bottom: 1px solid @border-primary-bg;
&:last-child { border-bottom: 0; }
&.collapsed {
padding: @spacing-standard @spacing-double;
}
}
.message-item-divider {
border-top: 2px solid @border-secondary-bg;
height: 3px;
background: @background-secondary;
border-bottom: 1px solid @border-primary-bg;
margin: 0;
margin-right: 2px;
}
.message-header {
position: relative;
font-size: @font-size-small;
border-bottom: 1px solid @border-color-divider;
padding-bottom: @spacing-standard;
padding-top: 5px;
.message-actions-wrap {
width: 100%;
text-align: right;
}
.message-actions {
display: inline-block;
width: 120px;
height: 22px;
border: 1px solid @border-color-divider;
border-radius: 11px;
z-index: 4;
margin-top: 0.35em;
margin-left: 0.5em;
text-align: right;
text-align: center;
.btn-icon {
opacity: 0.75;
padding: 0 @spacing-half;
&:last-child { padding-right: 0; }
margin-left: 10px;
margin-right: 0;
height: 20px;
line-height: 10px;
border-radius: 0;
border-right: 1px solid @border-color-divider;
&:last-child { border-right: 0; }
margin: 0;
&:active {background: transparent;}
}
}
.message-time {
.message-time {
z-index: 2; position: relative;
display: inline-block;
}
@ -141,6 +168,7 @@
.message-header-right {
z-index: 4; position: relative;
float: right;
text-align: right;
}
}
@ -149,7 +177,7 @@
width: 100%;
max-width: @message-max-width;
margin: 0 auto;
padding: @spacing-standard @spacing-double;
padding: 0 @spacing-double @spacing-standard @spacing-double;
iframe {
width: 100%;
@ -175,6 +203,34 @@
.collapse-control:hover {cursor: pointer;}
.footer-reply-area-wrap {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
color: @text-color-very-subtle;
border-top: 1px solid @border-color-divider;
background: @background-primary;
z-index: 10;
&:hover {
cursor: default;
}
.footer-reply-area {
width: 100%;
max-width: @message-max-width;
margin: 0 auto;
padding: 20px @spacing-double;
}
.reply-text {
display: inline-block;
margin-left: 0.5em;
position: relative;
top: 2px;
}
}
}
.attachments-area {
padding-top: @spacing-standard;
@ -195,8 +251,7 @@
.from-label, .to-label, .cc-label, .bcc-label, .subject-label {
color: @text-color-very-subtle;
}
.to-label { font-weight: 600; }
.cc-label, .bcc-label, .subject-label { margin-left: @spacing-standard; }
.to-label, .cc-label, .bcc-label, .subject-label { margin-left: @spacing-standard; }
.to-contact, .cc-contact, .to-everyone {
color: @text-color-very-subtle;
}
@ -218,25 +273,32 @@
.from-label, .to-label, .cc-label, .bcc-label, .subject-label {
float: left;
display: block;
font-weight: 400;
font-weight: @font-weight-normal;
margin-left: 0;
}
.from-contact, .to-contact, .cc-contact, .subject {
padding-left: 4.4em;
font-weight: 500;
.from-contact, .subject {
font-weight: @font-weight-semi-bold;
}
.from-label { margin-right: 1em; }
.to-label, .cc-label { margin-right: 2.15em; }
.bcc-label { margin-right: 1.7em; }
// .from-label { margin-right: 1em; }
.to-label, .cc-label { margin-right: 0.5em; }
.bcc-label { margin-right: 0; }
.participant-primary {
color: @text-color;
color: @text-color-very-subtle;
}
.participant-secondary {
color: @text-color-very-subtle;
}
.from-contact {
.participant-primary {
color: @text-color;
}
.participant-secondary {
color: @text-color;
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

View file

@ -128,7 +128,9 @@ body.is-blurred {
border-bottom: 1px solid @border-color-divider;
width: 100%;
height: 50px;
box-shadow: 0px 0px 6px rgba(0,0,0,0.09);
// prevent flexbox from ever, ever resizing toolbars, no matter
// how much it thinks other content is being squished
min-height: 50px;
@ -229,4 +231,4 @@ body.platform-win32 {
.flexbox-handle-horizontal {
cursor:ew-resize;
}
}
}