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:
parent
7e2d7b5e60
commit
675e34fc8d
17 changed files with 713 additions and 303 deletions
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
186
ts/components/ConversationListItem.md
Normal file
186
ts/components/ConversationListItem.md
Normal 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>
|
||||
```
|
159
ts/components/ConversationListItem.tsx
Normal file
159
ts/components/ConversationListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
// });
|
||||
// },
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue