From 282cc40e9a4ffc20de4c31d528095216b5662fc0 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Tue, 10 Mar 2015 10:08:04 -0700 Subject: [PATCH] feat(message): change long format timestamp Summary: Minor UI fixes Test Plan: edgehill --test Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://review.inboxapp.com/D1271 --- exports/ui-components.coffee | 5 +- .../message-list/lib/message-item.cjsx | 83 ++++++++++--- .../message-list/lib/message-list.cjsx | 42 ++++++- .../lib/message-participants.cjsx | 23 +++- .../message-list/lib/message-timestamp.cjsx | 2 +- .../spec/message-participants-spec.cjsx | 4 +- .../spec/message-timestamp-spec.cjsx | 4 +- .../stylesheets/message-list.less | 116 +++++++++++------- src/components/tooltip.cjsx | 15 +++ .../message-disclosure-triangle-active@2x.png | Bin 0 -> 345 bytes .../message-disclosure-triangle@2x.png | Bin 0 -> 349 bytes .../message-list/message-forward@1x.png | Bin 0 -> 1035 bytes .../message-list/message-forward@2x.png | Bin 0 -> 1230 bytes .../message-list/message-reply-all@1x.png | Bin 0 -> 1204 bytes .../message-list/message-reply-all@2x.png | Bin 0 -> 1561 bytes .../images/message-list/message-reply@1x.png | Bin 0 -> 1148 bytes .../images/message-list/message-reply@2x.png | Bin 0 -> 1280 bytes .../message-list/message-show-more@1x.png | Bin 0 -> 1067 bytes .../message-list/message-show-more@2x.png | Bin 0 -> 1187 bytes static/variables/ui-variables.less | 4 +- 20 files changed, 220 insertions(+), 78 deletions(-) create mode 100644 src/components/tooltip.cjsx create mode 100644 static/images/message-list/message-disclosure-triangle-active@2x.png create mode 100644 static/images/message-list/message-disclosure-triangle@2x.png create mode 100644 static/images/message-list/message-forward@1x.png create mode 100644 static/images/message-list/message-forward@2x.png create mode 100644 static/images/message-list/message-reply-all@1x.png create mode 100644 static/images/message-list/message-reply-all@2x.png create mode 100644 static/images/message-list/message-reply@1x.png create mode 100644 static/images/message-list/message-reply@2x.png create mode 100644 static/images/message-list/message-show-more@1x.png create mode 100644 static/images/message-list/message-show-more@2x.png diff --git a/exports/ui-components.coffee b/exports/ui-components.coffee index dcdb01536..0ba34745a 100644 --- a/exports/ui-components.coffee +++ b/exports/ui-components.coffee @@ -4,8 +4,9 @@ module.exports = # Models Menu: require '../src/components/menu' Popover: require '../src/components/popover' - TokenizingTextField: require '../src/components/tokenizing-text-field' - ResizableRegion: require '../src/components/resizable-region' + Tooltip: require '../src/components/tooltip' Flexbox: require '../src/components/flexbox' RetinaImg: require '../src/components/retina-img' ListTabular: require '../src/components/list-tabular' + ResizableRegion: require '../src/components/resizable-region' + TokenizingTextField: require '../src/components/tokenizing-text-field' diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index 8528726b6..b1dce5110 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -3,13 +3,16 @@ _ = require 'underscore-plus' EmailFrame = require './email-frame' MessageParticipants = require "./message-participants.cjsx" MessageTimestamp = require "./message-timestamp.cjsx" -{ComponentRegistry, FileDownloadStore, Utils} = require 'inbox-exports' +{ComponentRegistry, FileDownloadStore, Utils, Actions} = require 'inbox-exports' +{RetinaImg} = require 'ui-components' Autolinker = require 'autolinker' module.exports = MessageItem = React.createClass displayName: 'MessageItem' + propTypes: + thread: React.PropTypes.object.isRequired message: React.PropTypes.object.isRequired thread_participants: React.PropTypes.arrayOf(React.PropTypes.object) collapsed: React.PropTypes.bool @@ -31,7 +34,6 @@ MessageItem = React.createClass @_storeUnlisten() if @_storeUnlisten render: -> - messageActions = ComponentRegistry.findAllViewsByRole('MessageAction') messageIndicators = ComponentRegistry.findAllViewsByRole('MessageIndicator') attachments = @_attachmentComponents() if attachments.length > 0 @@ -40,30 +42,29 @@ MessageItem = React.createClass header =
- @setState detailedHeaders: true} - isDetailed={@state.detailedHeaders} - date={@props.message.date} /> +
+ -
- { for Action in messageActions} + {
for Indicator in messageIndicators} + + {if @state.detailedHeaders then @_renderMessageActionsInline() else @_renderMessageActionsTooltip()}
- {
for Indicator in messageIndicators} + @setState detailedHeaders: true} thread_participants={@props.thread_participants} - detailedParticipants={@state.detailedHeaders} + isDetailed={@state.detailedHeaders} message_participants={@props.message.participants()} /> -
@setState detailedHeaders: false}> -
+ {@_renderCollapseControl()} +
-
+
{header} {attachments} @@ -79,6 +80,58 @@ MessageItem = React.createClass 'no-quoted-text': !Utils.containsQuotedText(@props.message.body) 'show-quoted-text': @state.showQuotedText + _renderMessageActionsInline: -> + @_renderMessageActions() + + _renderMessageActionsTooltip: -> + ## TODO: Use Tooltip UI Component + @setState detailedHeaders: true}> + + + _renderMessageActions: -> + messageActions = ComponentRegistry.findAllViewsByRole('MessageAction') +
+ + + + + { for Action in messageActions} + +
+ + _onReply: -> + tId = @props.thread.id; mId = @props.message.id + Actions.composeReply(threadId: tId, messageId: mId) if (tId and mId) + + _onReplyAll: -> + tId = @props.thread.id; mId = @props.message.id + Actions.composeReplyAll(threadId: tId, messageId: mId) if (tId and mId) + + _onForward: -> + tId = @props.thread.id; mId = @props.message.id + Actions.composeForward(threadId: tId, messageId: mId) if (tId and mId) + + _renderCollapseControl: -> + if @state.detailedHeaders +
@setState detailedHeaders: false}> + +
+ else +
@setState detailedHeaders: true}> + +
+ # Eventually, _formatBody will run a series of registered body transformers. # For now, it just runs a few we've hardcoded here, which are all synchronous. _formatBody: -> diff --git a/internal_packages/message-list/lib/message-list.cjsx b/internal_packages/message-list/lib/message-list.cjsx index a9e7a0aa8..d93721a07 100755 --- a/internal_packages/message-list/lib/message-list.cjsx +++ b/internal_packages/message-list/lib/message-list.cjsx @@ -16,6 +16,8 @@ MessageList = React.createClass @_unsubscribers = [] @_unsubscribers.push MessageStore.listen @_onChange @_unsubscribers.push ThreadStore.listen @_onChange + @_lastHeight = -1 + @_scrollToBottom() componentWillUnmount: -> unsubscribe() for unsubscribe in @_unsubscribers @@ -26,6 +28,8 @@ MessageList = React.createClass @_focusComposerId = newDrafts[0] componentDidUpdate: -> + @_lastHeight = -1 + @_scrollToBottom() if @_focusComposerId? @_focusRef(@refs["composerItem-#{@_focusComposerId}"]) @_focusComposerId = null @@ -41,7 +45,7 @@ MessageList = React.createClass return
if not @state.current_thread?
-
+
{@_messageListNotificationBars()}
@@ -84,7 +88,7 @@ MessageList = React.createClass _messageComponents: -> ComposerItem = @state.Composer - containsUnread = _.any @state.messages, (m) -> m.unread + # containsUnread = _.any @state.messages, (m) -> m.unread collapsed = false components = [] @@ -94,12 +98,15 @@ MessageList = React.createClass ref="composerItem-#{message.id}" key={@state.messageLocalIds[message.id]} localId={@state.messageLocalIds[message.id]} - containerClass="message-item-wrap"/> + containerClass="message-item-wrap draft-message"/> else + className = "message-item-wrap" + if message.unread then className += " unread-message" components.push components @@ -125,6 +132,35 @@ MessageList = React.createClass participants[contact.email] = contact return _.values(participants) + # 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, we keep trying to scroll to the bottom. We scroll to the top + # of the last message. + # + # We don't scroll if there's only 1 item. + # We don't screll if you're actively focused somewhere in the message + # list. + _scrollToBottom: -> + _.defer => + if @isMounted() + messageWrap = @refs?.messageWrap?.getDOMNode?() + + return if not messageWrap? + return if messageWrap.children <= 1 + return if @getDOMNode().contains document.activeElement + + msgToScroll = messageWrap.querySelector(".draft-message, .unread-message") + if not msgToScroll? + msgToScroll = messageWrap.children[messageWrap.children.length - 1] + + currentHeight = messageWrap.getBoundingClientRect().height + + if currentHeight isnt @_lastHeight + @_lastHeight = currentHeight + @_scrollToBottom() + else + scrollTo = currentHeight - msgToScroll.getBoundingClientRect().height + @getDOMNode().scrollTop = scrollTo MessageList.minWidth = 600 MessageList.maxWidth = 900 diff --git a/internal_packages/message-list/lib/message-participants.cjsx b/internal_packages/message-list/lib/message-participants.cjsx index f870fa09f..5eae77e92 100644 --- a/internal_packages/message-list/lib/message-participants.cjsx +++ b/internal_packages/message-list/lib/message-participants.cjsx @@ -9,10 +9,10 @@ MessageParticipants = React.createClass classSet = React.addons.classSet "participants": true "message-participants": true - "collapsed": not @props.detailedParticipants + "collapsed": not @props.isDetailed
- {if @props.detailedParticipants then @_renderExpanded() else @_renderCollapsed()} + {if @props.isDetailed then @_renderExpanded() else @_renderCollapsed()}
_renderCollapsed: -> @@ -28,17 +28,18 @@ MessageParticipants = React.createClass _renderExpanded: ->
-
+
From: 
{@_fullContact(@props.from)}
-
+
To: 
{@_fullContact(@props.to)}
-
0 then display:"inline" else display:"none"}> +
0 then display:"block" else display:"none"}>
Cc: 
{@_fullContact(@props.cc)}
@@ -48,4 +49,14 @@ MessageParticipants = React.createClass _.map(contacts, (c) -> c.displayFirstName()).join(", ") _fullContact: (contacts=[]) -> - _.map(contacts, (c) -> c.displayFullContact()).join(", ") + _.map(contacts, (c) -> + if c.name?.length > 0 and c.name isnt c.email +
+ {c.name}  + <{c.email}> +
+ else +
+ {c.email} +
+ ) diff --git a/internal_packages/message-list/lib/message-timestamp.cjsx b/internal_packages/message-list/lib/message-timestamp.cjsx index ef39effe3..ccd915941 100644 --- a/internal_packages/message-list/lib/message-timestamp.cjsx +++ b/internal_packages/message-list/lib/message-timestamp.cjsx @@ -19,7 +19,7 @@ MessageTimestamp = React.createClass _timeFormat: -> if @props.isDetailed - return "ddd, MMM Do YYYY, h:mm:ss a z" + return "DD / MM / YYYY h:mm a z" else today = moment(@_today()) dayOfEra = today.dayOfYear() + today.year() * 365 diff --git a/internal_packages/message-list/spec/message-participants-spec.cjsx b/internal_packages/message-list/spec/message-participants-spec.cjsx index 75c384e40..818a58e58 100644 --- a/internal_packages/message-list/spec/message-participants-spec.cjsx +++ b/internal_packages/message-list/spec/message-participants-spec.cjsx @@ -80,7 +80,7 @@ describe "MessageParticipants", -> cc={test_message.cc} from={test_message.from} thread_participants={many_thread_users} - detailedParticipants={true} + isDetailed={true} message_participants={test_message.participants()} /> ) @@ -90,7 +90,7 @@ describe "MessageParticipants", -> it "uses full names", -> to = ReactTestUtils.findRenderedDOMComponentWithClass(@participants, "to-contact") - expect(to.getDOMNode().innerHTML).toBe "User Two <user2@nilas.com>" + expect(to.getDOMNode().innerText).toEqual "User Two " # TODO: We no longer display "to everyone" diff --git a/internal_packages/message-list/spec/message-timestamp-spec.cjsx b/internal_packages/message-list/spec/message-timestamp-spec.cjsx index 069b2668f..7b79a8026 100644 --- a/internal_packages/message-list/spec/message-timestamp-spec.cjsx +++ b/internal_packages/message-list/spec/message-timestamp-spec.cjsx @@ -35,7 +35,7 @@ describe "MessageTimestamp", -> it "displays the full time when in detailed timestamp mode", -> itemDetailed = TestUtils.renderIntoDocument( - + ) spyOn(itemDetailed, "_today").andCallFake -> testDate() - expect(itemDetailed._timeFormat()).toBe "ddd, MMM Do YYYY, h:mm:ss a z" + expect(itemDetailed._timeFormat()).toBe "DD / MM / YYYY h:mm a z" diff --git a/internal_packages/message-list/stylesheets/message-list.less b/internal_packages/message-list/stylesheets/message-list.less index eb6a3eb63..a4898ff5c 100644 --- a/internal_packages/message-list/stylesheets/message-list.less +++ b/internal_packages/message-list/stylesheets/message-list.less @@ -65,42 +65,53 @@ } } + .message-header { + position: relative; + font-size: @font-size-small; + + .message-actions { + z-index: 4; + margin-top: 0.35em; + margin-left: 0.5em; + text-align: right; + + .btn-icon { + padding: 0 @spacing-half; + &:last-child { padding-right: 0; } + margin-left: 10px; + margin-right: 0; + &:active {background: transparent;} + } + } + + .message-time { + z-index: 2; position: relative; + display: inline-block; + } + .msg-actions-tooltip { + display: inline-block; + margin-left: 1em; + } + .message-participants { z-index: 1; position: relative; } + + .message-time, .message-indicator { + color: @text-color-very-subtle; + } + + + .message-header-right { + z-index: 4; position: relative; + float: right; + } + + } + .message-item-area { width: 100%; max-width: @message-max-width; margin: 0 auto; padding: @spacing-standard @spacing-double; - .message-header { - position: relative; - font-size: @font-size-small; - .message-actions { - float:right; - padding-right:15px; - .btn-icon { - font-size:16px; - color:rgba(35, 31, 32, 0.1); - &:hover { - color:rgba(35, 31, 32, 0.5); - } - } - } - } - - .message-time, .message-indicator { - color: @text-color-very-subtle; - float: right; - margin-left: 1em; - - &:hover { - cursor: pointer; - } - } - - .message-indicator { - margin-right: 4px; - } - iframe { width: 100%; border: 0; @@ -111,14 +122,19 @@ } - .collapse-headers { + .collapse-control { + &.inactive { display: none; } + z-index: 3; position: absolute; - bottom: 0; - right: 0; + top: 0; + left: -1 * @spacing-standard; color: @text-color-very-subtle; } + .message-item-wrap:hover { + .collapse-control.inactive { display: block; } + } - .collapse-headers:hover {cursor: pointer;} + .collapse-control:hover {cursor: pointer;} } .attachments-area { @@ -137,36 +153,44 @@ font-weight: @headings-font-weight; color: @text-color; } - - .to-label, .cc-label { + .from-label, .to-label, .cc-label { color: @text-color-very-subtle; } - .cc-label { - margin-left: @spacing-standard; - } + .to-label { font-weight: 600; } + .cc-label { margin-left: @spacing-standard; } .to-contact, .cc-contact, .to-everyone { color: @text-color-very-subtle; } - .to-label { font-weight: 600; } - .expanded-participants { position: relative; padding-right: 1.2em; + .participant-type { + margin-top: 0.5em; + &:first-child {margin-top: 0;} + } + .from-label, .to-label, .cc-label { float: left; display: block; - font-weight: 600; + font-weight: 400; margin-left: 0; } - .to-label, .cc-label { - margin-right: 1.15em; + .from-contact, .to-contact, .cc-contact { + padding-left: 3.85em; + font-weight: 500; } - .from-contact, .to-contact, .cc-contact { - padding-left: 2.85em; + .from-label { margin-right: 1em; } + .to-label, .cc-label { margin-right: 2.15em; } + + .participant-primary { + color: @text-color; + } + .participant-secondary { + color: @text-color-very-subtle; } } } diff --git a/src/components/tooltip.cjsx b/src/components/tooltip.cjsx new file mode 100644 index 000000000..9e2df19b5 --- /dev/null +++ b/src/components/tooltip.cjsx @@ -0,0 +1,15 @@ +_ = require 'underscore-plus' +React = require 'react/addons' + +### +The Tooltip component displays a consistent hovering tooltip for use when +extra context information is required. +### + +module.exports = +Tooltip = React.createClass + render: -> +
{@props.children}
+ + _styles: -> + @props.modifierElement diff --git a/static/images/message-list/message-disclosure-triangle-active@2x.png b/static/images/message-list/message-disclosure-triangle-active@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..525f9dac11aecab22996f8bdbe3df498ecf6a840 GIT binary patch literal 345 zcmV-f0jBPx$6G=otR5%f>Q_Bj%Kn$#ptKdaF`V-##e-EAn#m7ZO@pTZ<3JN}F>UN1G+ZPQ? zH)b=_G>Ik8^J+jBD9Sz%>Q(XZfi2Jmim*9E{IXG+;(QdKdV|J{Iu#@K!b#zl`JML| zfOsJ}teE}^IMc~#d+>vFSKb%6*G|ox?0AsuYWr$?yQ?e_>JuF rU#0G>RmiV00000NkvXXu0mjfVrP^( literal 0 HcmV?d00001 diff --git a/static/images/message-list/message-disclosure-triangle@2x.png b/static/images/message-list/message-disclosure-triangle@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..28f6c905d15884599727df198b905ff2e01f64f5 GIT binary patch literal 349 zcmV-j0iyniP)Px$7fD1xR5%f(lR;0yKoCYNnjm^m(F+IlM7VkO;{X2xNP>pw#SmfygBT%d%lk-& zv`yRWb|-n+DZBGJWp|?}idqmHl03jgsOb>F9bCgP%pnHvkZ1r0a1Ii#;S0RNL{@Y} z(t|E6;1`^tD6*nclAHA5lQFT0s_1cIlP=`zSfy$^8Sy7ur-F2XHGh9R_JR{L& zY(O36!bhY++l)){+-CTStcw$pThOtQFbTOHs?{01hn8Ax8afiMFow9P=!4=R?EiFM zk*?1O-XIsQB3pf$ literal 0 HcmV?d00001 diff --git a/static/images/message-list/message-forward@1x.png b/static/images/message-list/message-forward@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..1d9731b6b6466f8c1cd4f3525e02d9112711f4c8 GIT binary patch literal 1035 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 bI1Ct~Z06P;u28uKDy%(S{an^LB{Ts5_%25S literal 0 HcmV?d00001 diff --git a/static/images/message-list/message-reply@1x.png b/static/images/message-list/message-reply@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..05029623b98848d532c0fe60b85105ad3a08006a GIT binary patch literal 1148 zcmaJ>TWHfz7|zyN>)d3*9Bd*UqToc^B;C3#aowd!>k7*(U2zq>Wy#qVw_HrlY&Jxv z=$x+#DhQ(Z;){wth@di*f(lM_AUeU9z6g5}5o8bIJgIB-!5T=;`49j1{kQMre4=l6 zb!9^(K@ipP9wCX>TKul{dGYt;n%g{H>QOX}`i&u!kt|4bE5;xMaZMVANhm23M_
QJGc9&JNJ-aF{@YNCJ*FrrVqoCiA=;-n+*X3GyyzG)(?ZDlH}eZ&(n7 z0xf=-W|~1O8(=~$t!(Q~5X3e`w^2dbPqSR8iKByHF*LW zvq-DkD{aCJqa4Ykm;mjTRB#l<|GR2h(c4Bz_$S|g65FW>6H-ZN8{?LY2RF3E4P|n? z1tnxyDZ>~mbTKhvAj2LpOu+N`u7O5Tl2zS3+@42?A{WXJ1)-TZvH&gh7*t}G zp@DqioLV{;p(Ni5Rw7@w%J^a| z#)a;b^KtOU@^YugcV!>j1j=II+PduXDQCT>&RqO(aO_!CZ#vsGJy)JX;2M#AR<)I= z+c`bw%_-n#MYf~sN8Qy}^X8`qUY&VP@40n3UjD-SdaA)&e!S~=?aZ?LbarZ~qULiM zv$4aoFoP;~)Liv^6+p+D?EJ&3-c`AGiSPYj?>VoBd*ID+;Ad6V+x579X5n7_((0L; z*QZa9eOjG8lHTvDd_M#poNYMb%c>7ARKIxBvz5aKG%-Y=U2o sJo&b1kE45X`@yAC+cu>8ZqyMznt1yv6ap{jb@zV|kM;?7B8N}^0^>V$)&Kwi literal 0 HcmV?d00001 diff --git a/static/images/message-list/message-reply@2x.png b/static/images/message-list/message-reply@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..bbf6a3151084eda6aec5d86745010b406abc2ae2 GIT binary patch literal 1280 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 vFOPEQo+sC@gs$Pw0=oPP