2018-04-03 22:56:12 +00:00
|
|
|
import React from 'react';
|
2018-07-07 00:48:14 +00:00
|
|
|
import classNames from 'classnames';
|
2018-06-27 20:53:49 +00:00
|
|
|
import moment from 'moment';
|
|
|
|
import { padStart } from 'lodash';
|
|
|
|
|
|
|
|
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
2018-07-07 00:48:14 +00:00
|
|
|
import {
|
|
|
|
isImageTypeSupported,
|
|
|
|
isVideoTypeSupported,
|
|
|
|
} from '../../util/GoogleChrome';
|
2018-06-27 20:53:49 +00:00
|
|
|
|
|
|
|
import { MessageBody } from './MessageBody';
|
|
|
|
import { Emojify } from './Emojify';
|
|
|
|
import { Quote, QuotedAttachment } from './Quote';
|
|
|
|
import { EmbeddedContact } from './EmbeddedContact';
|
|
|
|
|
|
|
|
import { Contact } from '../../types/Contact';
|
|
|
|
import { Localizer } from '../../types/Util';
|
|
|
|
import * as MIME from '../../../ts/types/MIME';
|
|
|
|
|
|
|
|
interface Attachment {
|
|
|
|
contentType: MIME.MIMEType;
|
|
|
|
fileName: string;
|
|
|
|
/** Not included in protobuf, needs to be pulled from flags */
|
|
|
|
isVoiceMessage: boolean;
|
|
|
|
/** For messages not already on disk, this will be a data url */
|
|
|
|
url: string;
|
|
|
|
fileSize?: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
text?: string;
|
|
|
|
id?: string;
|
|
|
|
collapseMetadata?: boolean;
|
|
|
|
direction: 'incoming' | 'outgoing';
|
|
|
|
timestamp: number;
|
|
|
|
status?: 'sending' | 'sent' | 'delivered' | 'read';
|
|
|
|
contacts?: Array<Contact>;
|
|
|
|
color:
|
|
|
|
| 'gray'
|
|
|
|
| 'blue'
|
|
|
|
| 'cyan'
|
|
|
|
| 'deep-orange'
|
|
|
|
| 'green'
|
|
|
|
| 'indigo'
|
|
|
|
| 'pink'
|
|
|
|
| 'purple'
|
|
|
|
| 'red'
|
|
|
|
| 'teal';
|
|
|
|
i18n: Localizer;
|
|
|
|
authorName?: string;
|
|
|
|
authorProfileName?: string;
|
|
|
|
/** Note: this should be formatted for display */
|
|
|
|
authorPhoneNumber?: string;
|
|
|
|
conversationType: 'group' | 'direct';
|
|
|
|
attachment?: Attachment;
|
|
|
|
quote?: {
|
|
|
|
text: string;
|
|
|
|
attachments: Array<QuotedAttachment>;
|
|
|
|
isFromMe: boolean;
|
|
|
|
authorName?: string;
|
|
|
|
authorPhoneNumber?: string;
|
|
|
|
authorProfileName?: string;
|
|
|
|
};
|
|
|
|
authorAvatarPath?: string;
|
|
|
|
contactHasSignalAccount: boolean;
|
|
|
|
expirationLength?: number;
|
|
|
|
expirationTimestamp?: number;
|
|
|
|
onClickQuote?: () => void;
|
|
|
|
onSendMessageToContact?: () => void;
|
|
|
|
onClickContact?: () => void;
|
|
|
|
onClickAttachment?: () => void;
|
|
|
|
}
|
|
|
|
|
|
|
|
function isImage(attachment?: Attachment) {
|
|
|
|
return (
|
2018-07-07 00:48:14 +00:00
|
|
|
attachment &&
|
|
|
|
attachment.contentType &&
|
|
|
|
isImageTypeSupported(attachment.contentType)
|
2018-06-27 20:53:49 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function isVideo(attachment?: Attachment) {
|
|
|
|
return (
|
2018-07-07 00:48:14 +00:00
|
|
|
attachment &&
|
|
|
|
attachment.contentType &&
|
|
|
|
isVideoTypeSupported(attachment.contentType)
|
2018-06-27 20:53:49 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function isAudio(attachment?: Attachment) {
|
|
|
|
return (
|
|
|
|
attachment && attachment.contentType && MIME.isAudio(attachment.contentType)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getTimerBucket(expiration: number, length: number): string {
|
|
|
|
const delta = expiration - Date.now();
|
|
|
|
if (delta < 0) {
|
|
|
|
return '00';
|
|
|
|
}
|
|
|
|
if (delta > length) {
|
|
|
|
return '60';
|
|
|
|
}
|
|
|
|
|
|
|
|
const increment = Math.round(delta / length * 12);
|
|
|
|
|
|
|
|
return padStart(String(increment * 5), 2, '0');
|
|
|
|
}
|
|
|
|
|
|
|
|
function getExtension({
|
|
|
|
fileName,
|
|
|
|
contentType,
|
|
|
|
}: {
|
|
|
|
fileName: string;
|
|
|
|
contentType: MIME.MIMEType;
|
|
|
|
}): string | null {
|
|
|
|
if (fileName && fileName.indexOf('.') >= 0) {
|
|
|
|
const lastPeriod = fileName.lastIndexOf('.');
|
|
|
|
const extension = fileName.slice(lastPeriod + 1);
|
|
|
|
if (extension.length) {
|
|
|
|
return extension;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const slash = contentType.indexOf('/');
|
|
|
|
if (slash >= 0) {
|
|
|
|
return contentType.slice(slash + 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
export class Message extends React.Component<Props> {
|
|
|
|
public renderTimer() {
|
|
|
|
const {
|
|
|
|
attachment,
|
|
|
|
direction,
|
|
|
|
expirationLength,
|
|
|
|
expirationTimestamp,
|
|
|
|
text,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
if (!expirationLength || !expirationTimestamp) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const withImageNoCaption = !text && isImage(attachment);
|
|
|
|
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
2018-04-03 22:56:12 +00:00
|
|
|
|
|
|
|
return (
|
2018-06-27 20:53:49 +00:00
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__metadata__timer',
|
|
|
|
`module-message__metadata__timer--${bucket}`,
|
|
|
|
`module-message__metadata__timer--${direction}`,
|
|
|
|
withImageNoCaption
|
|
|
|
? 'module-message__metadata__timer--with-image-no-caption'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderMetadata() {
|
|
|
|
const {
|
|
|
|
collapseMetadata,
|
|
|
|
color,
|
|
|
|
direction,
|
|
|
|
i18n,
|
|
|
|
status,
|
|
|
|
timestamp,
|
|
|
|
text,
|
|
|
|
attachment,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
if (collapseMetadata) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
// We're not showing metadata on top of videos since they still have native controls
|
|
|
|
if (!text && isVideo(attachment)) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const withImageNoCaption = !text && isImage(attachment);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__metadata',
|
|
|
|
withImageNoCaption
|
|
|
|
? 'module-message__metadata--with-image-no-caption'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<span
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__metadata__date',
|
|
|
|
`module-message__metadata__date--${direction}`,
|
|
|
|
withImageNoCaption
|
|
|
|
? 'module-message__metadata__date--with-image-no-caption'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
title={moment(timestamp).format('llll')}
|
|
|
|
>
|
|
|
|
{formatRelativeTime(timestamp, { i18n, extended: true })}
|
|
|
|
</span>
|
|
|
|
{this.renderTimer()}
|
|
|
|
<span className="module-message__metadata__spacer" />
|
|
|
|
{direction === 'outgoing' ? (
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__metadata__status-icon',
|
|
|
|
`module-message__metadata__status-icon-${status}`,
|
|
|
|
status === 'read'
|
|
|
|
? `module-message__metadata__status-icon-${color}`
|
|
|
|
: null,
|
|
|
|
withImageNoCaption
|
|
|
|
? 'module-message__metadata__status-icon--with-image-no-caption'
|
|
|
|
: null,
|
|
|
|
withImageNoCaption && status === 'read'
|
|
|
|
? 'module-message__metadata__status-icon--read-with-image-no-caption'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
/>
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderAuthor() {
|
|
|
|
const {
|
|
|
|
authorName,
|
|
|
|
conversationType,
|
|
|
|
direction,
|
|
|
|
i18n,
|
|
|
|
authorPhoneNumber,
|
|
|
|
authorProfileName,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
const title = authorName ? authorName : authorPhoneNumber;
|
|
|
|
|
|
|
|
if (direction !== 'incoming' || conversationType !== 'group' || !title) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const profileElement =
|
|
|
|
authorProfileName && !authorName ? (
|
|
|
|
<span className="module-message__author__profile-name">
|
|
|
|
~<Emojify text={authorProfileName} i18n={i18n} />
|
|
|
|
</span>
|
|
|
|
) : null;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="module-message__author">
|
|
|
|
<Emojify text={title} i18n={i18n} /> {profileElement}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2018-07-07 00:48:14 +00:00
|
|
|
// tslint:disable-next-line max-func-body-length
|
2018-06-27 20:53:49 +00:00
|
|
|
public renderAttachment() {
|
|
|
|
const {
|
|
|
|
i18n,
|
|
|
|
attachment,
|
|
|
|
text,
|
|
|
|
collapseMetadata,
|
|
|
|
conversationType,
|
|
|
|
direction,
|
|
|
|
quote,
|
|
|
|
onClickAttachment,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
if (!attachment) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const withCaption = Boolean(text);
|
|
|
|
// For attachments which aren't full-frame
|
|
|
|
const withContentBelow = withCaption || !collapseMetadata;
|
|
|
|
const withContentAbove =
|
|
|
|
quote || (conversationType === 'group' && direction === 'incoming');
|
|
|
|
|
|
|
|
if (isImage(attachment)) {
|
|
|
|
return (
|
|
|
|
<div className="module-message__attachment-container">
|
|
|
|
<img
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__img-attachment',
|
|
|
|
withCaption
|
|
|
|
? 'module-message__img-attachment--with-content-below'
|
|
|
|
: null,
|
|
|
|
withContentAbove
|
|
|
|
? 'module-message__img-attachment--with-content-above'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
src={attachment.url}
|
|
|
|
alt={i18n('imageAttachmentAlt')}
|
|
|
|
onClick={onClickAttachment}
|
|
|
|
/>
|
|
|
|
{!withCaption && !collapseMetadata ? (
|
|
|
|
<div className="module-message__img-overlay" />
|
|
|
|
) : null}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
} else if (isVideo(attachment)) {
|
|
|
|
return (
|
|
|
|
<video
|
|
|
|
controls={true}
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__img-attachment',
|
|
|
|
withCaption
|
|
|
|
? 'module-message__img-attachment--with-content-below'
|
|
|
|
: null,
|
|
|
|
withContentAbove
|
|
|
|
? 'module-message__img-attachment--with-content-above'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<source src={attachment.url} />
|
|
|
|
</video>
|
|
|
|
);
|
|
|
|
} else if (isAudio(attachment)) {
|
|
|
|
return (
|
|
|
|
<audio
|
|
|
|
controls={true}
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__audio-attachment',
|
|
|
|
withContentBelow
|
|
|
|
? 'module-message__audio-attachment--with-content-below'
|
|
|
|
: null,
|
|
|
|
withContentAbove
|
|
|
|
? 'module-message__audio-attachment--with-content-above'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<source src={attachment.url} />
|
|
|
|
</audio>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
const { fileName, fileSize, contentType } = attachment;
|
|
|
|
const extension = getExtension({ contentType, fileName });
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__generic-attachment',
|
|
|
|
withContentBelow
|
|
|
|
? 'module-message__generic-attachment--with-content-below'
|
|
|
|
: null,
|
|
|
|
withContentAbove
|
|
|
|
? 'module-message__generic-attachment--with-content-above'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<div className="module-message__generic-attachment__icon">
|
|
|
|
{extension ? (
|
|
|
|
<div className="module-message__generic-attachment__icon__extension">
|
|
|
|
{extension}
|
|
|
|
</div>
|
|
|
|
) : null}
|
2018-04-14 00:34:53 +00:00
|
|
|
</div>
|
2018-06-27 20:53:49 +00:00
|
|
|
<div className="module-message__generic-attachment__text">
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__generic-attachment__file-name',
|
|
|
|
`module-message__generic-attachment__file-name--${direction}`
|
|
|
|
)}
|
2018-04-03 22:56:12 +00:00
|
|
|
>
|
2018-06-27 20:53:49 +00:00
|
|
|
{fileName}
|
|
|
|
</div>
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__generic-attachment__file-size',
|
|
|
|
`module-message__generic-attachment__file-size--${direction}`
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
{fileSize}
|
|
|
|
</div>
|
2018-04-03 22:56:12 +00:00
|
|
|
</div>
|
|
|
|
</div>
|
2018-06-27 20:53:49 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderQuote() {
|
|
|
|
const {
|
|
|
|
color,
|
|
|
|
conversationType,
|
|
|
|
direction,
|
|
|
|
i18n,
|
|
|
|
onClickQuote,
|
|
|
|
quote,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
if (!quote) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const authorTitle = quote.authorName
|
|
|
|
? quote.authorName
|
|
|
|
: quote.authorPhoneNumber;
|
|
|
|
const authorProfileName = !quote.authorName
|
|
|
|
? quote.authorProfileName
|
|
|
|
: undefined;
|
|
|
|
const withContentAbove =
|
|
|
|
conversationType === 'group' && direction === 'incoming';
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Quote
|
|
|
|
i18n={i18n}
|
|
|
|
onClick={onClickQuote}
|
|
|
|
color={color}
|
|
|
|
text={quote.text}
|
|
|
|
attachments={quote.attachments}
|
|
|
|
isIncoming={direction === 'incoming'}
|
|
|
|
authorTitle={authorTitle || ''}
|
|
|
|
authorProfileName={authorProfileName}
|
|
|
|
isFromMe={quote.isFromMe}
|
|
|
|
withContentAbove={withContentAbove}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderEmbeddedContact() {
|
|
|
|
const {
|
|
|
|
collapseMetadata,
|
|
|
|
contactHasSignalAccount,
|
|
|
|
contacts,
|
|
|
|
conversationType,
|
|
|
|
direction,
|
|
|
|
i18n,
|
|
|
|
onClickContact,
|
|
|
|
onSendMessageToContact,
|
|
|
|
text,
|
|
|
|
} = this.props;
|
|
|
|
const first = contacts && contacts[0];
|
|
|
|
|
|
|
|
if (!first) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const withCaption = Boolean(text);
|
|
|
|
const withContentAbove =
|
|
|
|
conversationType === 'group' && direction === 'incoming';
|
|
|
|
const withContentBelow = withCaption || !collapseMetadata;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<EmbeddedContact
|
|
|
|
contact={first}
|
|
|
|
hasSignalAccount={contactHasSignalAccount}
|
|
|
|
isIncoming={direction === 'incoming'}
|
|
|
|
i18n={i18n}
|
|
|
|
onSendMessage={onSendMessageToContact}
|
|
|
|
onClickContact={onClickContact}
|
|
|
|
withContentAbove={withContentAbove}
|
|
|
|
withContentBelow={withContentBelow}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderSendMessageButton() {
|
|
|
|
const {
|
|
|
|
contactHasSignalAccount,
|
|
|
|
contacts,
|
|
|
|
i18n,
|
|
|
|
onSendMessageToContact,
|
|
|
|
} = this.props;
|
|
|
|
const first = contacts && contacts[0];
|
|
|
|
|
|
|
|
if (!first || !contactHasSignalAccount) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
role="button"
|
|
|
|
onClick={onSendMessageToContact}
|
|
|
|
className="module-message__send-message-button"
|
|
|
|
>
|
|
|
|
{i18n('sendMessageToContact')}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderAvatar() {
|
|
|
|
const {
|
|
|
|
authorName,
|
|
|
|
authorPhoneNumber,
|
|
|
|
authorProfileName,
|
|
|
|
authorAvatarPath,
|
|
|
|
collapseMetadata,
|
|
|
|
color,
|
|
|
|
conversationType,
|
|
|
|
direction,
|
|
|
|
i18n,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
const title = `${authorName || authorPhoneNumber}${
|
|
|
|
!authorName && authorProfileName ? ` ~${authorProfileName}` : ''
|
|
|
|
}`;
|
|
|
|
|
|
|
|
if (
|
|
|
|
collapseMetadata ||
|
|
|
|
conversationType !== 'group' ||
|
|
|
|
direction === 'outgoing'
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!authorAvatarPath) {
|
|
|
|
return (
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__author-default-avatar',
|
|
|
|
`module-message__author-default-avatar--${color}`
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<div className="module-message__author-default-avatar__label">#</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="module-message__author-avatar">
|
|
|
|
<img alt={i18n('contactAvatarAlt', [title])} src={authorAvatarPath} />
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderText() {
|
|
|
|
const { text, i18n, direction } = this.props;
|
|
|
|
|
|
|
|
if (!text) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message__text',
|
|
|
|
`module-message__text--${direction}`
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<MessageBody text={text || ''} i18n={i18n} />
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
public render() {
|
|
|
|
const {
|
|
|
|
attachment,
|
|
|
|
color,
|
|
|
|
conversationType,
|
|
|
|
direction,
|
|
|
|
id,
|
|
|
|
quote,
|
|
|
|
text,
|
|
|
|
} = this.props;
|
|
|
|
|
|
|
|
const imageAndNothingElse =
|
|
|
|
!text && isImage(attachment) && conversationType !== 'group' && !quote;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<li>
|
|
|
|
<div
|
|
|
|
id={id}
|
2018-07-07 00:48:14 +00:00
|
|
|
className={classNames(
|
2018-06-27 20:53:49 +00:00
|
|
|
'module-message',
|
|
|
|
`module-message--${direction}`,
|
|
|
|
imageAndNothingElse ? 'module-message--with-image-only' : null,
|
|
|
|
direction === 'incoming'
|
|
|
|
? `module-message--incoming-${color}`
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
{this.renderAuthor()}
|
|
|
|
{this.renderQuote()}
|
|
|
|
{this.renderAttachment()}
|
|
|
|
{this.renderEmbeddedContact()}
|
|
|
|
{this.renderText()}
|
|
|
|
{this.renderMetadata()}
|
|
|
|
{this.renderSendMessageButton()}
|
|
|
|
{this.renderAvatar()}
|
|
|
|
</div>
|
2018-04-03 22:56:12 +00:00
|
|
|
</li>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|