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

@ -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,
};
};