Responding to feedback on the updated visuals (#2549)

* Conversation List Item: timestamp bold only when convo has unread

* Preserve the positioning of overlays on re-entry into convo

* ConversationListItem: Handle missing and broken thumbnails

* Shorten timestamp in left pane for better Android consistency

* Update convo last updated if last was expire timer change

But not if it was from a sync instead of from you or from a contact.

* Make links in quotes the same color as the text

* MediaGridItem: Update placeholder icon colors for dark theme

* Ensure turning off timer shows 'Timer set to off' in left pane

* ConversationListItem: Show unread count in blue circle

* Add one pixel margin to blue indicator for text alignment

* Ensure replies to voice message can bet sent successfully
This commit is contained in:
Scott Nonnenberg 2018-07-20 16:37:57 -07:00 committed by GitHub
parent 60d56cf7e0
commit 643739f65d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 348 additions and 38 deletions

View File

@ -1149,6 +1149,17 @@
"description":
"Brief timestamp for messages sent about one hour ago. Displayed in the conversation list and message bubble."
},
"hoursAgoShort": {
"message": "$hours$ hr",
"description":
"Even further contracted form of 'X hours ago' which works both for singular and plural, used in the left pane",
"placeholders": {
"hours": {
"content": "$1",
"example": "2"
}
}
},
"hoursAgo": {
"message": "$hours$ hr ago",
"description":
@ -1160,6 +1171,17 @@
}
}
},
"minutesAgoShort": {
"message": "$minutes$ min",
"description":
"Even further contracted form of 'X minutes ago' which works both for singular and plural, used in the left pane",
"placeholders": {
"minutes": {
"content": "$1",
"example": "10"
}
}
},
"minutesAgo": {
"message": "$minutes$ min ago",
"description":

View File

@ -207,7 +207,7 @@
...this.format(),
lastUpdated: this.get('timestamp'),
hasUnread: Boolean(this.get('unreadCount')),
unreadCount: this.get('unreadCount') || 0,
isSelected: this.isSelected,
lastMessage: {
@ -795,7 +795,9 @@
return {
contentType,
fileName,
// Our protos library complains about this field being undefined, so we
// force it to null
fileName: fileName || null,
thumbnail: thumbnail
? {
...(await loadAttachmentData(thumbnail)),

View File

@ -193,7 +193,7 @@
const { expireTimer } = this.get('expirationTimerUpdate');
return i18n(
'timerSetTo',
Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer)
Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer || 0)
);
}
if (this.isKeyChange()) {

View File

@ -752,6 +752,10 @@
},
focusMessageField() {
if (this.panels && this.panels.length) {
return;
}
this.$messageField.focus();
},
@ -1286,6 +1290,7 @@
if (message) {
const quote = await this.model.makeQuote(this.quotedMessage);
console.log('DEBUG', { quote });
this.quote = quote;
this.focusMessageFieldAndClearDisabled();

View File

@ -810,6 +810,10 @@
line-height: 18px;
color: $color-light-90;
a {
color: $color-light-90;
}
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
@ -1805,6 +1809,7 @@
background-color: $color-light-10;
margin-right: 4px;
margin-bottom: 4px;
position: relative;
}
.module-media-grid-item__image {
@ -1813,6 +1818,18 @@
object-fit: cover;
}
.module-media-grid-item__icon {
position: absolute;
top: 15px;
bottom: 15px;
left: 15px;
right: 15px;
}
.module-media-grid-item__icon-image {
@include color-svg('../images/image.svg', $color-light-35);
}
.module-media-grid-item__image-container {
height: 94px;
width: 94px;
@ -1844,6 +1861,14 @@
@include color-svg('../images/play.svg', $color-signal-blue);
}
.module-media-grid-item__icon-video {
@include color-svg('../images/movie.svg', $color-light-35);
}
.module-media-grid-item__icon-generic {
@include color-svg('../images/file.svg', $color-light-35);
}
/* Module: Empty State*/
.module-empty-state {
@ -1964,7 +1989,6 @@
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
font-weight: 300;
overflow-x: hidden;
white-space: nowrap;
@ -1973,17 +1997,22 @@
text-transform: uppercase;
}
.module-conversation-list-item__header__date--has-unread {
font-weight: 300;
}
.module-conversation-list-item__message {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 3px;
}
.module-conversation-list-item__message__text {
flex-grow: 1;
flex-shrink: 1;
margin-top: 3px;
font-size: 13px;
line-height: 18px;
@ -1997,6 +2026,23 @@
font-weight: 300;
}
.module-conversation-list-item__unread-count {
color: $color-white;
background-color: $color-signal-blue;
text-align: center;
// For alignment with the message text
margin-top: 1px;
font-size: 10px;
margin-left: 5px;
min-width: 20px;
height: 20px;
width: 20px;
line-height: 20px;
border-radius: 10px;
}
.module-conversation-list-item__message__status-icon {
flex-shrink: 0;

View File

@ -927,6 +927,10 @@ body.dark-theme {
.module-quote__primary__text {
color: $color-dark-05;
a {
color: $color-dark-05;
}
}
.module-quote__primary__type-label {
@ -1275,6 +1279,18 @@ body.dark-theme {
background-color: $color-dark-85;
}
.module-media-grid-item__icon-image {
@include color-svg('../images/image.svg', $color-dark-60);
}
.module-media-grid-item__icon-video {
@include color-svg('../images/movie.svg', $color-dark-60);
}
.module-media-grid-item__icon-generic {
@include color-svg('../images/file.svg', $color-dark-60);
}
// Module: Empty State
.module-empty-state {

View File

@ -22,6 +22,7 @@
phoneNumber="(202) 555-0011"
name="Mr. Fire🔥"
color="green"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Just a second',
status: 'read',
@ -34,16 +35,38 @@
#### With unread
```jsx
<ConversationListItem
phoneNumber="(202) 555-0011"
hasUnread={true}
lastMessage={{
text: 'Hey there!',
status: 'sending',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
unreadCount={4}
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Hey there!',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
unreadCount={10}
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Hey there!',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
unreadCount={250}
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Hey there!',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
```
#### Selected
@ -52,6 +75,7 @@
<ConversationListItem
phoneNumber="(202) 555-0011"
isSelected={true}
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Hey there!',
}}
@ -68,6 +92,7 @@ We don't want Jumbomoji or links.
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Download at http://signal.org',
}}
@ -76,6 +101,7 @@ We don't want Jumbomoji or links.
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: '🔥',
}}
@ -94,6 +120,7 @@ We only show one line.
<ConversationListItem
phoneNumber="(202) 555-0011"
name="Long contact name. Esquire. The third. And stuff. And more! And more!"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Normal message',
}}
@ -102,6 +129,7 @@ We only show one line.
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text:
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
@ -111,6 +139,7 @@ We only show one line.
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text:
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
@ -122,6 +151,18 @@ We only show one line.
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
unreadCount={8}
lastMessage={{
text:
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text:
"Many lines. This is a many-line message.\nLine 2 is really exciting but it shouldn't be seen.\nLine three is even better.\nLine 4, well.",
@ -131,6 +172,7 @@ We only show one line.
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text:
"Many lines. This is a many-line message.\nLine 2 is really exciting but it shouldn't be seen.\nLine three is even better.\nLine 4, well.",
@ -142,6 +184,35 @@ We only show one line.
</div>
```
#### More narrow
On platforms that show scrollbars all the time, this is true all the time.
```jsx
<div style={{ width: '280px' }}>
<ConversationListItem
phoneNumber="(202) 555-0011"
name="Long contact name. Esquire. The third. And stuff. And more! And more!"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: 'Normal message',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text:
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
```
#### With various ages
```jsx

View File

@ -14,7 +14,7 @@ interface Props {
avatarPath?: string;
lastUpdated: number;
hasUnread: boolean;
unreadCount: number;
isSelected: boolean;
lastMessage?: {
@ -71,7 +71,14 @@ export class ConversationListItem extends React.Component<Props> {
}
public renderHeader() {
const { i18n, lastUpdated, name, phoneNumber, profileName } = this.props;
const {
unreadCount,
i18n,
lastUpdated,
name,
phoneNumber,
profileName,
} = this.props;
return (
<div className="module-conversation-list-item__header">
@ -83,7 +90,14 @@ export class ConversationListItem extends React.Component<Props> {
i18n={i18n}
/>
</div>
<div className="module-conversation-list-item__header__date">
<div
className={classNames(
'module-conversation-list-item__header__date',
unreadCount > 0
? 'module-conversation-list-item__header__date--has-unread'
: null
)}
>
<Timestamp
timestamp={lastUpdated}
extended={false}
@ -95,8 +109,22 @@ export class ConversationListItem extends React.Component<Props> {
);
}
public renderUnread() {
const { unreadCount } = this.props;
if (unreadCount > 0) {
return (
<div className="module-conversation-list-item__unread-count">
{unreadCount}
</div>
);
}
return null;
}
public renderMessage() {
const { lastMessage, hasUnread, i18n } = this.props;
const { lastMessage, unreadCount, i18n } = this.props;
if (!lastMessage) {
return null;
@ -108,7 +136,7 @@ export class ConversationListItem extends React.Component<Props> {
<div
className={classNames(
'module-conversation-list-item__message__text',
hasUnread
unreadCount > 0
? 'module-conversation-list-item__message__text--has-unread'
: null
)}
@ -131,12 +159,13 @@ export class ConversationListItem extends React.Component<Props> {
)}
/>
) : null}
{this.renderUnread()}
</div>
);
}
public render() {
const { hasUnread, onClick, isSelected } = this.props;
const { unreadCount, onClick, isSelected } = this.props;
return (
<div
@ -144,7 +173,7 @@ export class ConversationListItem extends React.Component<Props> {
onClick={onClick}
className={classNames(
'module-conversation-list-item',
hasUnread ? 'module-conversation-list-item--has-unread' : null,
unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null,
isSelected ? 'module-conversation-list-item--is-selected' : null
)}
>

View File

@ -1,4 +1,4 @@
## With image
#### With image
```jsx
const message = {
@ -14,7 +14,7 @@ const message = {
<MediaGridItem i18n={util.i18n} message={message} />;
```
## With video
#### With video
```jsx
const message = {
@ -30,7 +30,69 @@ const message = {
<MediaGridItem i18n={util.i18n} message={message} />;
```
## Without image
#### Missing image
```jsx
const message = {
id: '1',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
};
<MediaGridItem i18n={util.i18n} message={message} />;
```
#### Missing video
```jsx
const message = {
id: '1',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
],
};
<MediaGridItem i18n={util.i18n} message={message} />;
```
#### Image thumbnail failed to load
```jsx
const message = {
id: '1',
thumbnailObjectUrl: 'nonexistent',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'image/jpeg',
},
],
};
<MediaGridItem i18n={util.i18n} message={message} />;
```
#### Video thumbnail failed to load
```jsx
const message = {
id: '1',
thumbnailObjectUrl: 'nonexistent',
attachments: [
{
fileName: 'foo.jpg',
contentType: 'video/mp4',
},
],
};
<MediaGridItem i18n={util.i18n} message={message} />;
```
#### Other contentType
```jsx
const message = {

View File

@ -1,4 +1,5 @@
import React from 'react';
import classNames from 'classnames';
import {
isImageTypeSupported,
@ -13,14 +14,34 @@ interface Props {
i18n: Localizer;
}
export class MediaGridItem extends React.Component<Props> {
interface State {
imageBroken: boolean;
}
export class MediaGridItem extends React.Component<Props, State> {
private onImageErrorBound: () => void;
constructor(props: Props) {
super(props);
this.state = {
imageBroken: false,
};
this.onImageErrorBound = this.onImageError.bind(this);
}
public onImageError() {
this.setState({
imageBroken: true,
});
}
public renderContent() {
const { message, i18n } = this.props;
const { imageBroken } = this.state;
const { attachments } = message;
if (!message.thumbnailObjectUrl) {
return null;
}
if (!attachments || !attachments.length) {
return null;
}
@ -29,20 +50,44 @@ export class MediaGridItem extends React.Component<Props> {
const { contentType } = first;
if (contentType && isImageTypeSupported(contentType)) {
if (imageBroken || !message.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-image'
)}
/>
);
}
return (
<img
alt={i18n('lightboxImageAlt')}
className="module-media-grid-item__image"
src={message.thumbnailObjectUrl}
onError={this.onImageErrorBound}
/>
);
} else if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !message.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-video'
)}
/>
);
}
return (
<div className="module-media-grid-item__image-container">
<img
alt={i18n('lightboxImageAlt')}
className="module-media-grid-item__image"
src={message.thumbnailObjectUrl}
onError={this.onImageErrorBound}
/>
<div className="module-media-grid-item__circle-overlay">
<div className="module-media-grid-item__play-overlay" />
@ -51,7 +96,14 @@ export class MediaGridItem extends React.Component<Props> {
);
}
return null;
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-generic'
)}
/>
);
}
public render() {

View File

@ -76,7 +76,7 @@ describe('Conversation', () => {
});
});
context('for expired message', () => {
context('for expire timer update from sync', () => {
it('should update message but not timestamp (to prevent bump to top)', () => {
const input = {
currentLastMessageText: 'I am expired',
@ -89,7 +89,7 @@ describe('Conversation', () => {
timestamp: 666,
expirationTimerUpdate: {
expireTimer: 111,
fromSync: false,
fromSync: true,
source: '+12223334455',
},
} as IncomingMessage,

View File

@ -1,4 +1,3 @@
import is from '@sindresorhus/is';
import { Message } from './Message';
interface ConversationLastMessageUpdate {
@ -28,10 +27,12 @@ export const createLastMessageUpdate = ({
};
}
const { type } = lastMessage;
const { type, expirationTimerUpdate } = lastMessage;
const isVerifiedChangeMessage = type === 'verified-change';
const isExpiringMessage = is.object(lastMessage.expirationTimerUpdate);
const shouldUpdateTimestamp = !isVerifiedChangeMessage && !isExpiringMessage;
const isExpireTimerUpdateFromSync =
expirationTimerUpdate && expirationTimerUpdate.fromSync;
const shouldUpdateTimestamp =
!isVerifiedChangeMessage && !isExpireTimerUpdateFromSync;
const newTimestamp = shouldUpdateTimestamp
? lastMessage.sent_at

View File

@ -44,9 +44,13 @@ export function formatRelativeTime(
} else if (diff.days() >= 1 || !isToday(timestamp)) {
return timestamp.format(formats.d);
} else if (diff.hours() >= 1) {
return i18n('hoursAgo', [String(diff.hours())]);
const key = extended ? 'hoursAgo' : 'hoursAgoShort';
return i18n(key, [String(diff.hours())]);
} else if (diff.minutes() >= 1) {
return i18n('minutesAgo', [String(diff.minutes())]);
const key = extended ? 'minutesAgo' : 'minutesAgoShort';
return i18n(key, [String(diff.minutes())]);
}
return i18n('justNow');