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.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) {
if (!this._initialFetchComplete) {
throw new Error(

View File

@ -26,6 +26,7 @@
Errors,
Message,
VisualAttachment,
PhoneNumber,
} = window.Signal.Types;
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
@ -110,6 +111,17 @@
this.messageCollection.on('change:errors', this.handleMessageError, 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:profileAvatar', this.updateAvatarUrl);
this.on('change:profileKey', this.onChangeProfileKey);
@ -119,6 +131,7 @@
this.on('newmessage', this.addSingleMessage);
this.on('delivered', this.updateMessage);
this.on('read', this.updateMessage);
this.on('sent', this.updateLastMessage);
this.on('expired', this.onExpired);
this.listenTo(
this.messageCollection,
@ -158,6 +171,7 @@
// Used to update existing messages when updated from out-of-band db access,
// like read and delivery receipts.
updateMessage(message) {
this.updateLastMessage();
this.messageCollection.add(message, { merge: true });
},
@ -168,6 +182,43 @@
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() {
this.updateVerified();
},
@ -850,6 +901,7 @@
active_at: now,
timestamp: now,
lastMessage: message.getNotificationText(),
lastMessageStatus: 'sending',
});
const conversationType = this.get('type');
@ -889,10 +941,14 @@
const lastMessage = collection.at(0);
const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null;
const lastMessageStatus = lastMessage
? lastMessage.getMessagePropStatus()
: null;
const lastMessageUpdate = Conversation.createLastMessageUpdate({
currentLastMessageText: this.get('lastMessage') || null,
currentTimestamp: this.get('timestamp') || null,
lastMessage: lastMessageJSON,
lastMessageStatus,
lastMessageNotificationText: lastMessage
? lastMessage.getNotificationText()
: null,

View File

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

View File

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

View File

@ -1,4 +1,4 @@
/* global Whisper, _, extension, Backbone, Mustache */
/* global Whisper, Signal, Backbone */
// eslint-disable-next-line func-names
(function() {
@ -13,118 +13,38 @@
return `conversation-list-item contact ${this.model.cid}`;
},
templateName: 'conversation-preview',
events: {
click: 'select',
},
initialize() {
// auto update
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.listenTo(this.model, 'destroy', this.remove);
this.model.updateLastMessage();
},
markSelected() {
this.$el
.addClass('selected')
.siblings('.selected')
.removeClass('selected');
},
select() {
this.markSelected();
this.$el.trigger('select', this.model);
},
remove() {
if (this.nameView) {
this.nameView.remove();
this.nameView = null;
}
if (this.bodyView) {
this.bodyView.remove();
this.bodyView = null;
if (this.childView) {
this.childView.remove();
this.childView = null;
}
Backbone.View.prototype.remove.call(this);
},
render() {
const lastMessage = this.model.get('lastMessage');
this.$el.html(
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;
if (this.childView) {
this.childView.remove();
this.childView = null;
}
this.nameView = new Whisper.ReactWrapperView({
className: 'name-wrapper',
Component: window.Signal.Components.ContactName,
props: {
phoneNumber: this.model.getNumber(),
name: this.model.getName(),
profileName: this.model.getProfileName(),
},
const props = this.model.getPropsForListItem();
this.childView = new Whisper.ReactWrapperView({
className: 'list-item-wrapper',
Component: Signal.Components.ConversationListItem,
props,
});
this.$('.name').append(this.nameView.el);
if (lastMessage) {
if (this.bodyView) {
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 update = () =>
this.childView.update(this.model.getPropsForListItem());
const unread = this.model.get('unreadCount');
if (unread > 0) {
this.$el.addClass('unread');
} else {
this.$el.removeClass('unread');
}
this.listenTo(this.model, 'change', update);
this.$el.append(this.childView.el);
return this;
},

View File

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

View File

@ -207,53 +207,6 @@ $avatar-size: 44px;
$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 {
// what's the right color?
background-color: $blue_l;

View File

@ -1823,6 +1823,183 @@
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
.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 {
// what's the right color?
background-color: $blue_l;
@ -739,7 +725,7 @@ body.dark-theme {
}
.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 {
@ -1238,7 +1224,7 @@ body.dark-theme {
}
.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 {
@ -1295,6 +1281,71 @@ body.dark-theme {
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
.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 (
<div className="module-group-notification">
{(changes || []).map(change => (
<div className="module-group-notification__change">
{(changes || []).map((change, index) => (
<div key={index} className="module-group-notification__change">
{this.renderChange(change)}
</div>
))}

View File

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

View File

@ -8,9 +8,10 @@ import { Localizer } from '../../types/Util';
interface Props {
timestamp: number;
withImageNoCaption: boolean;
direction: 'incoming' | 'outgoing';
extended: boolean;
module?: string;
withImageNoCaption?: boolean;
direction?: 'incoming' | 'outgoing';
i18n: Localizer;
}
@ -47,6 +48,7 @@ export class Timestamp extends React.Component<Props> {
module,
timestamp,
withImageNoCaption,
extended,
} = this.props;
const moduleName = module || 'module-timestamp';
@ -54,12 +56,12 @@ export class Timestamp extends React.Component<Props> {
<span
className={classNames(
moduleName,
`${moduleName}--${direction}`,
direction ? `${moduleName}--${direction}` : null,
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null
)}
title={moment(timestamp).format('llll')}
>
{formatRelativeTime(timestamp, { i18n, extended: true })}
{formatRelativeTime(timestamp, { i18n, extended })}
</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,
currentTimestamp: null,
lastMessage: null,
lastMessageStatus: null,
lastMessageNotificationText: null,
};
const expected = {
lastMessage: '',
lastMessageStatus: null,
timestamp: null,
};
@ -30,6 +32,7 @@ describe('Conversation', () => {
const input = {
currentLastMessageText: 'Existing message',
currentTimestamp: 555,
lastMessageStatus: 'read',
lastMessage: {
type: 'outgoing',
conversationId: 'foo',
@ -40,6 +43,7 @@ describe('Conversation', () => {
};
const expected = {
lastMessage: 'New outgoing message',
lastMessageStatus: 'read',
timestamp: 666,
};
@ -52,6 +56,7 @@ describe('Conversation', () => {
const input = {
currentLastMessageText: 'bingo',
currentTimestamp: 555,
lastMessageStatus: null,
lastMessage: {
type: 'verified-change',
conversationId: 'foo',
@ -62,6 +67,7 @@ describe('Conversation', () => {
};
const expected = {
lastMessage: 'bingo',
lastMessageStatus: null,
timestamp: 555,
};
@ -75,6 +81,7 @@ describe('Conversation', () => {
const input = {
currentLastMessageText: 'I am expired',
currentTimestamp: 555,
lastMessageStatus: null,
lastMessage: {
type: 'incoming',
conversationId: 'foo',
@ -90,6 +97,7 @@ describe('Conversation', () => {
};
const expected = {
lastMessage: 'Last message before expired',
lastMessageStatus: null,
timestamp: 555,
};

View File

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