New React component: ConversationListItem, installed in left pane

When collecting a conversation's last message, we grab that message's
status as well (if outgoing) and show it.
This commit is contained in:
Scott Nonnenberg 2018-07-17 20:25:55 -07:00
parent 7e2d7b5e60
commit 675e34fc8d
17 changed files with 713 additions and 303 deletions

View file

@ -78,6 +78,18 @@
window.getInboxCollection = () => inboxCollection; window.getInboxCollection = () => inboxCollection;
window.ConversationController = { window.ConversationController = {
markAsSelected(toSelect) {
conversations.each(conversation => {
const current = conversation.isSelected || false;
const newValue = conversation.id === toSelect.id;
// eslint-disable-next-line no-param-reassign
conversation.isSelected = newValue;
if (current !== newValue) {
conversation.trigger('change');
}
});
},
get(id) { get(id) {
if (!this._initialFetchComplete) { if (!this._initialFetchComplete) {
throw new Error( throw new Error(

View file

@ -26,6 +26,7 @@
Errors, Errors,
Message, Message,
VisualAttachment, VisualAttachment,
PhoneNumber,
} = window.Signal.Types; } = window.Signal.Types;
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations; const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
@ -110,6 +111,17 @@
this.messageCollection.on('change:errors', this.handleMessageError, this); this.messageCollection.on('change:errors', this.handleMessageError, this);
this.messageCollection.on('send-error', this.onMessageError, this); this.messageCollection.on('send-error', this.onMessageError, this);
const debouncedUpdateLastMessage = _.debounce(
this.updateLastMessage.bind(this),
1000
);
this.listenTo(
this.messageCollection,
'add remove',
debouncedUpdateLastMessage
);
this.listenTo(this.model, 'newmessage', debouncedUpdateLastMessage);
this.on('change:avatar', this.updateAvatarUrl); this.on('change:avatar', this.updateAvatarUrl);
this.on('change:profileAvatar', this.updateAvatarUrl); this.on('change:profileAvatar', this.updateAvatarUrl);
this.on('change:profileKey', this.onChangeProfileKey); this.on('change:profileKey', this.onChangeProfileKey);
@ -119,6 +131,7 @@
this.on('newmessage', this.addSingleMessage); this.on('newmessage', this.addSingleMessage);
this.on('delivered', this.updateMessage); this.on('delivered', this.updateMessage);
this.on('read', this.updateMessage); this.on('read', this.updateMessage);
this.on('sent', this.updateLastMessage);
this.on('expired', this.onExpired); this.on('expired', this.onExpired);
this.listenTo( this.listenTo(
this.messageCollection, this.messageCollection,
@ -158,6 +171,7 @@
// Used to update existing messages when updated from out-of-band db access, // Used to update existing messages when updated from out-of-band db access,
// like read and delivery receipts. // like read and delivery receipts.
updateMessage(message) { updateMessage(message) {
this.updateLastMessage();
this.messageCollection.add(message, { merge: true }); this.messageCollection.add(message, { merge: true });
}, },
@ -168,6 +182,43 @@
return model; return model;
}, },
format() {
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
const avatar = this.getAvatar();
const color = this.getColor();
return {
phoneNumber: format(this.id, {
ourRegionCode: regionCode,
}),
color,
avatarPath: avatar ? avatar.url : null,
name: this.getName(),
profileName: this.getProfileName(),
title: this.getTitle(),
};
},
getPropsForListItem() {
const result = {
...this.format(),
lastUpdated: this.get('timestamp'),
hasUnread: Boolean(this.get('unreadCount')),
isSelected: this.isSelected,
lastMessage: {
status: this.get('lastMessageStatus'),
text: this.get('lastMessage'),
},
onClick: () => this.trigger('select', this),
};
return result;
},
onMessageError() { onMessageError() {
this.updateVerified(); this.updateVerified();
}, },
@ -850,6 +901,7 @@
active_at: now, active_at: now,
timestamp: now, timestamp: now,
lastMessage: message.getNotificationText(), lastMessage: message.getNotificationText(),
lastMessageStatus: 'sending',
}); });
const conversationType = this.get('type'); const conversationType = this.get('type');
@ -889,10 +941,14 @@
const lastMessage = collection.at(0); const lastMessage = collection.at(0);
const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null; const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null;
const lastMessageStatus = lastMessage
? lastMessage.getMessagePropStatus()
: null;
const lastMessageUpdate = Conversation.createLastMessageUpdate({ const lastMessageUpdate = Conversation.createLastMessageUpdate({
currentLastMessageText: this.get('lastMessage') || null, currentLastMessageText: this.get('lastMessage') || null,
currentTimestamp: this.get('timestamp') || null, currentTimestamp: this.get('timestamp') || null,
lastMessage: lastMessageJSON, lastMessage: lastMessageJSON,
lastMessageStatus,
lastMessageNotificationText: lastMessage lastMessageNotificationText: lastMessage
? lastMessage.getNotificationText() ? lastMessage.getNotificationText()
: null, : null,

View file

@ -422,6 +422,10 @@
}; };
}, },
getMessagePropStatus() { getMessagePropStatus() {
if (!this.isOutgoing()) {
return null;
}
if (this.hasErrors()) { if (this.hasErrors()) {
return 'error'; return 'error';
} }
@ -763,6 +767,7 @@
sent: true, sent: true,
expirationStartTimestamp: now, expirationStartTimestamp: now,
}); });
this.trigger('sent', this);
this.sendSyncMessage(); this.sendSyncMessage();
}) })
.catch(result => { .catch(result => {

View file

@ -19,6 +19,9 @@ const { ContactName } = require('../../ts/components/conversation/ContactName');
const { const {
ConversationHeader, ConversationHeader,
} = require('../../ts/components/conversation/ConversationHeader'); } = require('../../ts/components/conversation/ConversationHeader');
const {
ConversationListItem,
} = require('../../ts/components/ConversationListItem');
const { const {
EmbeddedContact, EmbeddedContact,
} = require('../../ts/components/conversation/EmbeddedContact'); } = require('../../ts/components/conversation/EmbeddedContact');
@ -151,6 +154,7 @@ exports.setup = (options = {}) => {
ContactListItem, ContactListItem,
ContactName, ContactName,
ConversationHeader, ConversationHeader,
ConversationListItem,
EmbeddedContact, EmbeddedContact,
Emojify, Emojify,
GroupNotification, GroupNotification,

View file

@ -1,4 +1,4 @@
/* global Whisper, _, extension, Backbone, Mustache */ /* global Whisper, Signal, Backbone */
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
(function() { (function() {
@ -13,118 +13,38 @@
return `conversation-list-item contact ${this.model.cid}`; return `conversation-list-item contact ${this.model.cid}`;
}, },
templateName: 'conversation-preview', templateName: 'conversation-preview',
events: {
click: 'select',
},
initialize() { initialize() {
// auto update this.listenTo(this.model, 'destroy', this.remove);
this.listenTo(
this.model,
'change',
_.debounce(this.render.bind(this), 1000)
);
this.listenTo(this.model, 'destroy', this.remove); // auto update
this.listenTo(this.model, 'opened', this.markSelected); // auto update
const updateLastMessage = _.debounce(
this.model.updateLastMessage.bind(this.model),
1000
);
this.listenTo(
this.model.messageCollection,
'add remove',
updateLastMessage
);
this.listenTo(this.model, 'newmessage', updateLastMessage);
extension.windows.onClosed(() => {
this.stopListening();
});
this.timeStampView = new Whisper.TimestampView({ brief: true });
this.model.updateLastMessage(); this.model.updateLastMessage();
}, },
markSelected() {
this.$el
.addClass('selected')
.siblings('.selected')
.removeClass('selected');
},
select() {
this.markSelected();
this.$el.trigger('select', this.model);
},
remove() { remove() {
if (this.nameView) { if (this.childView) {
this.nameView.remove(); this.childView.remove();
this.nameView = null; this.childView = null;
}
if (this.bodyView) {
this.bodyView.remove();
this.bodyView = null;
} }
Backbone.View.prototype.remove.call(this); Backbone.View.prototype.remove.call(this);
}, },
render() { render() {
const lastMessage = this.model.get('lastMessage'); if (this.childView) {
this.childView.remove();
this.$el.html( this.childView = null;
Mustache.render(
_.result(this, 'template', ''),
{
last_message: Boolean(lastMessage),
last_message_timestamp: this.model.get('timestamp'),
number: this.model.getNumber(),
avatar: this.model.getAvatar(),
unreadCount: this.model.get('unreadCount'),
},
this.render_partials()
)
);
this.timeStampView.setElement(this.$('.last-timestamp'));
this.timeStampView.update();
if (this.nameView) {
this.nameView.remove();
this.nameView = null;
} }
this.nameView = new Whisper.ReactWrapperView({
className: 'name-wrapper', const props = this.model.getPropsForListItem();
Component: window.Signal.Components.ContactName, this.childView = new Whisper.ReactWrapperView({
props: { className: 'list-item-wrapper',
phoneNumber: this.model.getNumber(), Component: Signal.Components.ConversationListItem,
name: this.model.getName(), props,
profileName: this.model.getProfileName(),
},
}); });
this.$('.name').append(this.nameView.el);
if (lastMessage) { const update = () =>
if (this.bodyView) { this.childView.update(this.model.getPropsForListItem());
this.bodyView.remove();
this.bodyView = null;
}
this.bodyView = new Whisper.ReactWrapperView({
className: 'body-wrapper',
Component: window.Signal.Components.MessageBody,
props: {
text: lastMessage,
disableJumbomoji: true,
disableLinks: true,
},
});
this.$('.last-message').append(this.bodyView.el);
}
const unread = this.model.get('unreadCount'); this.listenTo(this.model, 'change', update);
if (unread > 0) {
this.$el.addClass('unread'); this.$el.append(this.childView.el);
} else {
this.$el.removeClass('unread');
}
return this; return this;
}, },

View file

@ -107,11 +107,12 @@
const inboxCollection = getInboxCollection(); const inboxCollection = getInboxCollection();
inboxCollection.on('messageError', () => { this.listenTo(inboxCollection, 'messageError', () => {
if (this.networkStatusView) { if (this.networkStatusView) {
this.networkStatusView.render(); this.networkStatusView.render();
} }
}); });
this.listenTo(inboxCollection, 'select', this.openConversation);
this.inboxListView = new Whisper.ConversationListView({ this.inboxListView = new Whisper.ConversationListView({
el: this.$('.inbox'), el: this.$('.inbox'),
@ -144,11 +145,7 @@
this.searchView.$el.show(); this.searchView.$el.show();
this.inboxListView.$el.hide(); this.inboxListView.$el.hide();
}); });
this.listenTo( this.listenTo(this.searchView, 'open', this.openConversation);
this.searchView,
'open',
this.openConversation.bind(this, null)
);
this.networkStatusView = new Whisper.NetworkStatusView(); this.networkStatusView = new Whisper.NetworkStatusView();
this.$el this.$el
@ -175,7 +172,6 @@
click: 'onClick', click: 'onClick',
'click #header': 'focusHeader', 'click #header': 'focusHeader',
'click .conversation': 'focusConversation', 'click .conversation': 'focusConversation',
'select .gutter .conversation-list-item': 'openConversation',
'input input.search': 'filterContacts', 'input input.search': 'filterContacts',
}, },
startConnectionListener() { startConnectionListener() {
@ -250,7 +246,9 @@
input.removeClass('active'); input.removeClass('active');
} }
}, },
openConversation(e, conversation) { openConversation(conversation) {
ConversationController.markAsSelected(conversation);
this.searchView.hideHints(); this.searchView.hideHints();
if (conversation) { if (conversation) {
this.conversation_stack.open( this.conversation_stack.open(

View file

@ -207,53 +207,6 @@ $avatar-size: 44px;
$unread-badge-size: 21px; $unread-badge-size: 21px;
.conversation-list-item {
cursor: pointer;
color: $color-light-90;
&:hover {
background: $color-black-008;
}
.number {
display: none;
}
.unread-count {
float: right;
margin: 3px 10px 0 20px;
display: inline-block;
padding: 0 3px;
min-width: $unread-badge-size;
height: $unread-badge-size;
line-height: $unread-badge-size;
font-size: 12px;
font-weight: bold;
text-align: center;
border-radius: $border-radius;
background-color: $blue;
color: white;
border: solid 1px rgba(255, 255, 255, 0.6);
}
}
.inactive .contact.selected {
padding-left: 8px;
border-left: 4px solid $blue;
}
.contact {
padding: 12px;
white-space: nowrap;
overflow: hidden;
&:first-child {
margin-top: 0;
}
&:last-child::after {
display: none;
}
}
.banner { .banner {
// what's the right color? // what's the right color?
background-color: $blue_l; background-color: $blue_l;

View file

@ -1823,6 +1823,183 @@
color: $color-light-45; color: $color-light-45;
} }
// Module: Conversation List Item
.module-conversation-list-item {
max-width: 300px;
display: flex;
flex-direction: row;
padding-right: 16px;
padding-left: 16px;
align-items: center;
cursor: pointer;
&:hover {
background-color: $color-black-008;
}
}
.module-conversation-list-item--has-unread {
padding-left: 12px;
border-left: 4px solid $color-signal-blue;
}
.module-conversation-list-item--is-selected {
background-color: $color-black-008;
}
.module-conversation-list-item__avatar {
margin-top: 8px;
margin-bottom: 8px;
height: 48px;
width: 48px;
border-radius: 24px;
min-width: 48px;
object-fit: cover;
}
.module-conversation-list-item__default-avatar {
color: white;
font-size: 26px;
line-height: 48px;
text-align: center;
background-color: $color-conversation-grey;
}
.module-conversation-list-item__default-avatar--blue {
background-color: $color-conversation-blue;
}
.module-conversation-list-item__default-avatar--cyan {
background-color: $color-conversation-cyan;
}
.module-conversation-list-item__default-avatar--deep_orange {
background-color: $color-conversation-deep_orange;
}
.module-conversation-list-item__default-avatar--green {
background-color: $color-conversation-green;
}
.module-conversation-list-item__default-avatar--indigo {
background-color: $color-conversation-indigo;
}
.module-conversation-list-item__default-avatar--pink {
background-color: $color-conversation-pink;
}
.module-conversation-list-item__default-avatar--purple {
background-color: $color-conversation-purple;
}
.module-conversation-list-item__default-avatar--red {
background-color: $color-conversation-red;
}
.module-conversation-list-item__default-avatar--teal {
background-color: $color-conversation-teal;
}
.module-conversation-list-item__content {
flex-grow: 1;
margin-left: 12px;
// 300 - 32px (left/right margin) - 48px (for avatar) - 12px (our margin)
max-width: 208px;
display: flex;
flex-direction: column;
align-items: stretch;
}
.module-conversation-list-item__header {
display: flex;
flex-direction: row;
align-items: center;
}
.module-conversation-list-item__header__name {
flex-grow: 1;
flex-shrink: 1;
font-size: 14px;
line-height: 18px;
font-weight: 300;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.module-conversation-list-item__header__timestamp {
flex-shrink: 0;
margin-left: 6px;
font-size: 11px;
line-height: 16px;
letter-spacing: 0.3px;
font-weight: 300;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-transform: uppercase;
}
.module-conversation-list-item__message {
display: flex;
flex-direction: row;
align-items: center;
}
.module-conversation-list-item__message__text {
flex-grow: 1;
flex-shrink: 1;
margin-top: 3px;
font-size: 13px;
line-height: 18px;
height: 1.2em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.module-conversation-list-item__message__text--has-unread {
font-weight: 300;
}
.module-conversation-list-item__message__status-icon {
flex-shrink: 0;
margin-top: 2px;
width: 12px;
height: 12px;
display: inline-block;
margin-left: 6px;
}
.module-conversation-list-item__message__status-icon--sending {
@include color-svg('../images/sending.svg', $color-light-60);
animation: module-conversation-list-item__message__status-icon--spinning 4s
linear infinite;
}
@keyframes module-conversation-list-item__message__status-icon--spinning {
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.module-conversation-list-item__message__status-icon--sent {
@include color-svg('../images/check-circle-outline.svg', $color-light-35);
}
.module-conversation-list-item__message__status-icon--delivered {
@include color-svg('../images/double-check.svg', $color-light-35);
width: 18px;
}
.module-conversation-list-item__message__status-icon--read {
@include color-svg('../images/read.svg', $color-light-35);
width: 18px;
}
// Third-party module: react-contextmenu // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View file

@ -236,20 +236,6 @@ body.dark-theme {
} }
} }
.conversation-list-item {
color: $color-dark-05;
&:hover {
background: $color-dark-70;
}
.unread-count {
background-color: $blue;
color: white;
border: solid 1px rgba(255, 255, 255, 0.6);
}
}
.banner { .banner {
// what's the right color? // what's the right color?
background-color: $blue_l; background-color: $blue_l;
@ -739,7 +725,7 @@ body.dark-theme {
} }
.module-message__metadata__status-icon--sending { .module-message__metadata__status-icon--sending {
@include color-svg('../images/sending.svg', $color-dark-30); @include color-svg('../images/sending.svg', $color-light-35);
} }
.module-message__metadata__status-icon--sent { .module-message__metadata__status-icon--sent {
@ -1238,7 +1224,7 @@ body.dark-theme {
} }
.module-message-detail__contact__status-icon--sending { .module-message-detail__contact__status-icon--sending {
@include color-svg('../images/sending.svg', $color-dark-30); @include color-svg('../images/sending.svg', $color-light-35);
} }
.module-message-detail__contact__status-icon--sent { .module-message-detail__contact__status-icon--sent {
@ -1295,6 +1281,71 @@ body.dark-theme {
color: $color-dark-55; color: $color-dark-55;
} }
// Module: Conversation List Item
.module-conversation-list-item {
&:hover {
background-color: $color-dark-70;
}
}
.module-conversation-list-item--has-unread {
border-left: 4px solid $color-signal-blue;
}
.module-conversation-list-item--is-selected {
background-color: $color-dark-70;
}
.module-conversation-list-item__default-avatar {
color: white;
background-color: $color-conversation-grey;
}
.module-conversation-list-item__default-avatar--blue {
background-color: $color-conversation-blue;
}
.module-conversation-list-item__default-avatar--cyan {
background-color: $color-conversation-cyan;
}
.module-conversation-list-item__default-avatar--deep_orange {
background-color: $color-conversation-deep_orange;
}
.module-conversation-list-item__default-avatar--green {
background-color: $color-conversation-green;
}
.module-conversation-list-item__default-avatar--indigo {
background-color: $color-conversation-indigo;
}
.module-conversation-list-item__default-avatar--pink {
background-color: $color-conversation-pink;
}
.module-conversation-list-item__default-avatar--purple {
background-color: $color-conversation-purple;
}
.module-conversation-list-item__default-avatar--red {
background-color: $color-conversation-red;
}
.module-conversation-list-item__default-avatar--teal {
background-color: $color-conversation-teal;
}
.module-conversation-list-item__message__status-icon--sending {
@include color-svg('../images/sending.svg', $color-light-35);
}
.module-conversation-list-item__message__status-icon--sent {
@include color-svg('../images/check-circle-outline.svg', $color-light-35);
}
.module-conversation-list-item__message__status-icon--delivered {
@include color-svg('../images/double-check.svg', $color-light-35);
width: 18px;
}
.module-conversation-list-item__message__status-icon--read {
@include color-svg('../images/read.svg', $color-light-35);
width: 18px;
}
// Third-party module: react-contextmenu // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View file

@ -0,0 +1,186 @@
#### With name and profile
```jsx
<ConversationListItem
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
avatarPath={util.gifObjectUrl}
lastUpdated={Date.now() - 5 * 60 * 1000}
lastMessage={{
text: "What's going on?",
status: 'sent',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
```
#### Profile, with name, no avatar
```jsx
<ConversationListItem
phoneNumber="(202) 555-0011"
name="Mr. Fire🔥"
color="green"
lastMessage={{
text: 'Just a second',
status: 'read',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
```
#### With unread
```jsx
<ConversationListItem
phoneNumber="(202) 555-0011"
hasUnread={true}
lastMessage={{
text: 'Hey there!',
status: 'sending',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
```
#### Selected
```jsx
<ConversationListItem
phoneNumber="(202) 555-0011"
isSelected={true}
lastMessage={{
text: 'Hey there!',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
```
#### With emoji/links in message, no status
We don't want Jumbomoji or links.
```jsx
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastMessage={{
text: 'Download at http://signal.org',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastMessage={{
text: '🔥',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
```
#### Long content
We only show one line.
```jsx
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
name="Long contact name. Esquire. The third. And stuff. And more! And more!"
lastMessage={{
text: 'Normal message',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
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"
lastMessage={{
text:
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
status: 'read',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
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.",
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
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.",
status: 'delivered',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
```
#### With various ages
```jsx
<div>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 5 * 60 * 60 * 1000}
lastMessage={{
text: 'Five hours ago',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 24 * 60 * 60 * 1000}
lastMessage={{
text: 'One day ago',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 7 * 24 * 60 * 60 * 1000}
lastMessage={{
text: 'One week ago',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
<ConversationListItem
phoneNumber="(202) 555-0011"
lastUpdated={Date.now() - 365 * 24 * 60 * 60 * 1000}
lastMessage={{
text: 'One year ago',
}}
onClick={() => console.log('onClick')}
i18n={util.i18n}
/>
</div>
```

View file

@ -0,0 +1,159 @@
import React from 'react';
import classNames from 'classnames';
import { MessageBody } from './conversation/MessageBody';
import { Timestamp } from './conversation/Timestamp';
import { ContactName } from './conversation/ContactName';
import { Localizer } from '../types/Util';
interface Props {
phoneNumber: string;
profileName?: string;
name?: string;
color?: string;
avatarPath?: string;
lastUpdated: number;
hasUnread: boolean;
isSelected: boolean;
lastMessage?: {
status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
text: string;
};
i18n: Localizer;
onClick?: () => void;
}
function getInitial(name: string): string {
return name.trim()[0] || '#';
}
export class ConversationListItem extends React.Component<Props> {
public renderAvatar() {
const {
avatarPath,
color,
i18n,
name,
phoneNumber,
profileName,
} = this.props;
if (!avatarPath) {
const initial = getInitial(name || '');
return (
<div
className={classNames(
'module-conversation-list-item__avatar',
'module-conversation-list-item__default-avatar',
`module-conversation-list-item__default-avatar--${color}`
)}
>
{initial}
</div>
);
}
const title = `${name || phoneNumber}${
!name && profileName ? ` ~${profileName}` : ''
}`;
return (
<img
className="module-conversation-list-item__avatar"
alt={i18n('contactAvatarAlt', [title])}
src={avatarPath}
/>
);
}
public renderHeader() {
const { i18n, lastUpdated, name, phoneNumber, profileName } = this.props;
return (
<div className="module-conversation-list-item__header">
<div className="module-conversation-list-item__header__name">
<ContactName
phoneNumber={phoneNumber}
name={name}
profileName={profileName}
i18n={i18n}
/>
</div>
<div className="module-conversation-list-item__header__date">
<Timestamp
timestamp={lastUpdated}
extended={false}
module="module-conversation-list-item__header__timestamp"
i18n={i18n}
/>
</div>
</div>
);
}
public renderMessage() {
const { lastMessage, hasUnread, i18n } = this.props;
if (!lastMessage) {
return null;
}
return (
<div className="module-conversation-list-item__message">
{lastMessage.text ? (
<div
className={classNames(
'module-conversation-list-item__message__text',
hasUnread
? 'module-conversation-list-item__message__text--has-unread'
: null
)}
>
<MessageBody
text={lastMessage.text}
disableJumbomoji={true}
disableLinks={true}
i18n={i18n}
/>
</div>
) : null}
{lastMessage.status ? (
<div
className={classNames(
'module-conversation-list-item__message__status-icon',
`module-conversation-list-item__message__status-icon--${
lastMessage.status
}`
)}
/>
) : null}
</div>
);
}
public render() {
const { hasUnread, onClick, isSelected } = this.props;
return (
<div
role="button"
onClick={onClick}
className={classNames(
'module-conversation-list-item',
hasUnread ? 'module-conversation-list-item--has-unread' : null,
isSelected ? 'module-conversation-list-item--is-selected' : null
)}
>
{this.renderAvatar()}
<div className="module-conversation-list-item__content">
{this.renderHeader()}
{this.renderMessage()}
</div>
</div>
);
}
}

View file

@ -98,8 +98,8 @@ export class GroupNotification extends React.Component<Props> {
return ( return (
<div className="module-group-notification"> <div className="module-group-notification">
{(changes || []).map(change => ( {(changes || []).map((change, index) => (
<div className="module-group-notification__change"> <div key={index} className="module-group-notification__change">
{this.renderChange(change)} {this.renderChange(change)}
</div> </div>
))} ))}

View file

@ -291,6 +291,7 @@ export class Message extends React.Component<Props, State> {
<Timestamp <Timestamp
i18n={i18n} i18n={i18n}
timestamp={timestamp} timestamp={timestamp}
extended={true}
direction={direction} direction={direction}
withImageNoCaption={withImageNoCaption} withImageNoCaption={withImageNoCaption}
module="module-message__metadata__date" module="module-message__metadata__date"

View file

@ -8,9 +8,10 @@ import { Localizer } from '../../types/Util';
interface Props { interface Props {
timestamp: number; timestamp: number;
withImageNoCaption: boolean; extended: boolean;
direction: 'incoming' | 'outgoing';
module?: string; module?: string;
withImageNoCaption?: boolean;
direction?: 'incoming' | 'outgoing';
i18n: Localizer; i18n: Localizer;
} }
@ -47,6 +48,7 @@ export class Timestamp extends React.Component<Props> {
module, module,
timestamp, timestamp,
withImageNoCaption, withImageNoCaption,
extended,
} = this.props; } = this.props;
const moduleName = module || 'module-timestamp'; const moduleName = module || 'module-timestamp';
@ -54,12 +56,12 @@ export class Timestamp extends React.Component<Props> {
<span <span
className={classNames( className={classNames(
moduleName, moduleName,
`${moduleName}--${direction}`, direction ? `${moduleName}--${direction}` : null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null withImageNoCaption ? `${moduleName}--with-image-no-caption` : null
)} )}
title={moment(timestamp).format('llll')} title={moment(timestamp).format('llll')}
> >
{formatRelativeTime(timestamp, { i18n, extended: true })} {formatRelativeTime(timestamp, { i18n, extended })}
</span> </span>
); );
} }

View file

@ -1,127 +0,0 @@
export function messageSelector({ model, view }: { model: any; view: any }) {
// tslint:disable-next-line
console.log({ model, view });
return null;
// const avatar = this.model.getAvatar();
// const avatarPath = avatar && avatar.url;
// const color = avatar && avatar.color;
// const isMe = this.ourNumber === this.model.id;
// const attachments = this.model.get('attachments') || [];
// const loadedAttachmentViews = Promise.all(
// attachments.map(
// attachment =>
// new Promise(async resolve => {
// const attachmentWithData = await loadAttachmentData(attachment);
// const view = new Whisper.AttachmentView({
// model: attachmentWithData,
// timestamp: this.model.get('sent_at'),
// });
// this.listenTo(view, 'update', () => {
// // NOTE: Can we do without `updated` flag now that we use promises?
// view.updated = true;
// resolve(view);
// });
// view.render();
// })
// )
// );
// Wiring up TimerNotification
// this.conversation = this.model.getExpirationTimerUpdateSource();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// this.listenTo(this.model, 'change', this.onChange);
// Wiring up SafetyNumberNotification
// this.conversation = this.model.getModelForKeyChange();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// Wiring up VerificationNotification
// this.conversation = this.model.getModelForVerifiedChange();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// this.contactView = new Whisper.ReactWrapperView({
// className: 'contact-wrapper',
// Component: window.Signal.Components.ContactListItem,
// props: {
// isMe,
// color,
// avatarPath,
// phoneNumber: model.getNumber(),
// name: model.getName(),
// profileName: model.getProfileName(),
// verified: model.isVerified(),
// onClick: showIdentity,
// },
// });
// this.$el.append(this.contactView.el);
}
// We actually don't listen to the model telling us that it's gone if it's disappearing
// onDestroy() {
// if (this.$el.hasClass('expired')) {
// return;
// }
// this.onUnload();
// },
// The backflips required to maintain scroll position when loading images
// Key is only adding the img to the DOM when the image has loaded.
//
// How might we get similar behavior with React?
//
// this.trigger('beforeChangeHeight');
// this.$('.attachments').append(view.el);
// view.setElement(view.el);
// this.trigger('afterChangeHeight');
// Timer code
// if (this.model.isExpired()) {
// return this;
// }
// if (this.model.isExpiring()) {
// this.render();
// const totalTime = this.model.get('expireTimer') * 1000;
// const remainingTime = this.model.msTilExpire();
// const elapsed = (totalTime - remainingTime) / totalTime;
// this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
// this.$el.css('display', 'inline-block');
// this.timeout = setTimeout(
// this.update.bind(this),
// Math.max(totalTime / 100, 500)
// );
// }
// Expiring message
// this.$el.addClass('expired');
// this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
// if (e.target === this.$('.bubble')[0]) {
// this.remove();
// }
// });
// // Failsafe: if in the background, animation events don't fire
// setTimeout(this.remove.bind(this), 1000);
// Retrying a message
// retryMessage() {
// const retrys = _.filter(
// this.model.get('errors'),
// this.model.isReplayableError.bind(this.model)
// );
// _.map(retrys, 'number').forEach(number => {
// this.model.resend(number);
// });
// },

View file

@ -14,10 +14,12 @@ describe('Conversation', () => {
currentLastMessageText: null, currentLastMessageText: null,
currentTimestamp: null, currentTimestamp: null,
lastMessage: null, lastMessage: null,
lastMessageStatus: null,
lastMessageNotificationText: null, lastMessageNotificationText: null,
}; };
const expected = { const expected = {
lastMessage: '', lastMessage: '',
lastMessageStatus: null,
timestamp: null, timestamp: null,
}; };
@ -30,6 +32,7 @@ describe('Conversation', () => {
const input = { const input = {
currentLastMessageText: 'Existing message', currentLastMessageText: 'Existing message',
currentTimestamp: 555, currentTimestamp: 555,
lastMessageStatus: 'read',
lastMessage: { lastMessage: {
type: 'outgoing', type: 'outgoing',
conversationId: 'foo', conversationId: 'foo',
@ -40,6 +43,7 @@ describe('Conversation', () => {
}; };
const expected = { const expected = {
lastMessage: 'New outgoing message', lastMessage: 'New outgoing message',
lastMessageStatus: 'read',
timestamp: 666, timestamp: 666,
}; };
@ -52,6 +56,7 @@ describe('Conversation', () => {
const input = { const input = {
currentLastMessageText: 'bingo', currentLastMessageText: 'bingo',
currentTimestamp: 555, currentTimestamp: 555,
lastMessageStatus: null,
lastMessage: { lastMessage: {
type: 'verified-change', type: 'verified-change',
conversationId: 'foo', conversationId: 'foo',
@ -62,6 +67,7 @@ describe('Conversation', () => {
}; };
const expected = { const expected = {
lastMessage: 'bingo', lastMessage: 'bingo',
lastMessageStatus: null,
timestamp: 555, timestamp: 555,
}; };
@ -75,6 +81,7 @@ describe('Conversation', () => {
const input = { const input = {
currentLastMessageText: 'I am expired', currentLastMessageText: 'I am expired',
currentTimestamp: 555, currentTimestamp: 555,
lastMessageStatus: null,
lastMessage: { lastMessage: {
type: 'incoming', type: 'incoming',
conversationId: 'foo', conversationId: 'foo',
@ -90,6 +97,7 @@ describe('Conversation', () => {
}; };
const expected = { const expected = {
lastMessage: 'Last message before expired', lastMessage: 'Last message before expired',
lastMessageStatus: null,
timestamp: 555, timestamp: 555,
}; };

View file

@ -3,6 +3,7 @@ import { Message } from './Message';
interface ConversationLastMessageUpdate { interface ConversationLastMessageUpdate {
lastMessage: string | null; lastMessage: string | null;
lastMessageStatus: string | null;
timestamp: number | null; timestamp: number | null;
} }
@ -10,16 +11,19 @@ export const createLastMessageUpdate = ({
currentLastMessageText, currentLastMessageText,
currentTimestamp, currentTimestamp,
lastMessage, lastMessage,
lastMessageStatus,
lastMessageNotificationText, lastMessageNotificationText,
}: { }: {
currentLastMessageText: string | null; currentLastMessageText: string | null;
currentTimestamp: number | null; currentTimestamp: number | null;
lastMessage: Message | null; lastMessage: Message | null;
lastMessageStatus: string | null;
lastMessageNotificationText: string | null; lastMessageNotificationText: string | null;
}): ConversationLastMessageUpdate => { }): ConversationLastMessageUpdate => {
if (lastMessage === null) { if (lastMessage === null) {
return { return {
lastMessage: '', lastMessage: '',
lastMessageStatus: null,
timestamp: null, timestamp: null,
}; };
} }
@ -40,6 +44,7 @@ export const createLastMessageUpdate = ({
return { return {
lastMessage: newLastMessageText, lastMessage: newLastMessageText,
lastMessageStatus,
timestamp: newTimestamp, timestamp: newTimestamp,
}; };
}; };