Virtualize Messages List - only render what's visible

This commit is contained in:
Scott Nonnenberg
2019-05-31 15:42:01 -07:00
parent a976cfe6b6
commit 5ebd8bc690
73 changed files with 4717 additions and 2745 deletions
+79 -79
View File
@@ -23,7 +23,7 @@ const contact = {
signalAccount: '+12025550000',
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -31,8 +31,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -41,8 +41,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -51,8 +51,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -62,7 +62,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -89,7 +89,7 @@ const contact = {
signalAccount: '+12025550000',
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -97,8 +97,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -107,7 +107,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -131,15 +131,15 @@ const contact = {
},
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
contact={contact}/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -147,7 +147,7 @@ const contact = {
i18n={util.i18n}
timestamp={Date.now()}
contact={contact}/>
</li>
</div>
</util.ConversationContext>;
```
@@ -171,8 +171,8 @@ const contact = {
},
signalAccount: '+12025550000',
};
<util.ConversationContext theme={util.theme} type="group" ios={util.ios}>
<li>
<util.ConversationContext theme={util.theme} ios={util.ios}>
<div className="module-message-container">
<Message
authorColor="green"
conversationType="group"
@@ -183,8 +183,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -195,8 +195,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -207,7 +207,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -231,7 +231,7 @@ const contact = {
},
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -239,8 +239,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -249,8 +249,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -259,8 +259,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -270,7 +270,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -292,7 +292,7 @@ const contact = {
},
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -300,8 +300,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -310,8 +310,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -320,8 +320,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -331,7 +331,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -356,7 +356,7 @@ const contact = {
signalAccount: '+12025551000',
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -364,8 +364,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -374,8 +374,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -384,8 +384,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -395,7 +395,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -414,7 +414,7 @@ const contact = {
],
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -422,8 +422,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -432,8 +432,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -442,8 +442,8 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -453,7 +453,7 @@ const contact = {
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -462,7 +462,7 @@ const contact = {
```jsx
const contact = {};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -470,8 +470,8 @@ const contact = {};
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -480,8 +480,8 @@ const contact = {};
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="incoming"
@@ -490,8 +490,8 @@ const contact = {};
timestamp={Date.now()}
contact={contact}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="green"
direction="outgoing"
@@ -501,7 +501,7 @@ const contact = {};
timestamp={Date.now()}
contact={contact}
/>
</li>
</div>
</util.ConversationContext>;
```
@@ -542,7 +542,7 @@ const contactWithoutAccount = {
},
};
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -551,8 +551,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -562,8 +562,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -572,8 +572,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -583,8 +583,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -594,8 +594,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithoutAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -606,8 +606,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithoutAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -617,8 +617,8 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithoutAccount}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
text="I want to introduce you to Someone..."
authorColor="green"
@@ -629,6 +629,6 @@ const contactWithoutAccount = {
timestamp={Date.now()}
contact={contactWithoutAccount}
/>
</li>
</div>
</util.ConversationContext>;
```
+36 -36
View File
@@ -2,7 +2,7 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -13,8 +13,8 @@
expirationLength={10 * 1000}
expirationTimestamp={Date.now() + 10 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="outgoing"
status="delivered"
@@ -25,8 +25,8 @@
expirationLength={30 * 1000}
expirationTimestamp={Date.now() + 30 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -37,8 +37,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -49,7 +49,7 @@
expirationLength={5 * 60 * 1000}
expirationTimestamp={Date.now() + 5 * 60 * 1000}
/>
</li>
</div>
</util.ConversationContext>
```
@@ -57,7 +57,7 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -67,8 +67,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 60 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -79,8 +79,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 60 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -90,8 +90,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -102,8 +102,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 55 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -113,8 +113,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 30 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -125,8 +125,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 30 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -136,8 +136,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 5 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -148,8 +148,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 5 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -159,8 +159,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now()}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -171,8 +171,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now()}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -182,8 +182,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 120 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -194,8 +194,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() + 120 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="incoming"
@@ -205,8 +205,8 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() - 20 * 1000}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
authorColor="blue"
direction="outgoing"
@@ -217,6 +217,6 @@
expirationLength={60 * 1000}
expirationTimestamp={Date.now() - 20 * 1000}
/>
</li>
</div>
</util.ConversationContext>
```
+5 -2
View File
@@ -15,6 +15,7 @@ interface Props {
overlayText?: string;
isSelected?: boolean;
noBorder?: boolean;
noBackground?: boolean;
bottomOverlay?: boolean;
@@ -51,6 +52,7 @@ export class Image extends React.Component<Props> {
darkOverlay,
height,
i18n,
isSelected,
noBackground,
noBorder,
onClick,
@@ -118,7 +120,7 @@ export class Image extends React.Component<Props> {
alt={i18n('imageCaptionIconAlt')}
/>
) : null}
{!noBorder ? (
{!noBorder || isSelected ? (
<div
className={classNames(
'module-image__border-overlay',
@@ -128,7 +130,8 @@ export class Image extends React.Component<Props> {
curveBottomRight ? 'module-image--curved-bottom-right' : null,
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null
darkOverlay ? 'module-image__border-overlay--dark' : null,
isSelected ? 'module-image__border-overlay--selected' : null
)}
/>
) : null}
+3
View File
@@ -21,6 +21,7 @@ interface Props {
withContentBelow?: boolean;
bottomOverlay?: boolean;
isSticker?: boolean;
isSelected?: boolean;
stickerSize?: number;
i18n: LocalizerType;
@@ -37,6 +38,7 @@ export class ImageGrid extends React.Component<Props> {
bottomOverlay,
i18n,
isSticker,
isSelected,
stickerSize,
onError,
onClick,
@@ -83,6 +85,7 @@ export class ImageGrid extends React.Component<Props> {
curveBottomRight={curveBottomRight}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
isSelected={isSelected}
height={finalHeight}
width={finalWidth}
url={getUrl(attachments[0])}
File diff suppressed because it is too large Load Diff
+71 -8
View File
@@ -40,6 +40,7 @@ interface Trigger {
// Same as MIN_WIDTH in ImageGrid.tsx
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
const STICKER_SIZE = 128;
const SELECTED_TIMEOUT = 1000;
interface LinkPreviewType {
title: string;
@@ -54,6 +55,8 @@ export type PropsData = {
text?: string;
textPending?: boolean;
isSticker: boolean;
isSelected: boolean;
isSelectedCounter: number;
direction: 'incoming' | 'outgoing';
timestamp: number;
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
@@ -97,6 +100,8 @@ type PropsHousekeeping = {
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
replyToMessage: (id: string) => void;
retrySend: (id: string) => void;
deleteMessage: (id: string) => void;
@@ -120,11 +125,10 @@ export type PropsActions = {
displayTapToViewMessage: (messageId: string) => unknown;
openLink: (url: string) => void;
scrollToMessage: (
scrollToQuotedMessage: (
options: {
author: string;
sentAt: number;
referencedMessageNotFound: boolean;
}
) => void;
};
@@ -135,6 +139,9 @@ interface State {
expiring: boolean;
expired: boolean;
imageBroken: boolean;
isSelected: boolean;
prevSelectedCounter: number;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
@@ -148,6 +155,7 @@ export class Message extends React.PureComponent<Props, State> {
public menuTriggerRef: Trigger | undefined;
public expirationCheckInterval: any;
public expiredTimeout: any;
public selectedTimeout: any;
public constructor(props: Props) {
super(props);
@@ -160,10 +168,30 @@ export class Message extends React.PureComponent<Props, State> {
expiring: false,
expired: false,
imageBroken: false,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
}
public static getDerivedStateFromProps(props: Props, state: State): State {
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
) {
return {
...state,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
};
}
return state;
}
public componentDidMount() {
this.startSelectedTimer();
const { expirationLength } = this.props;
if (!expirationLength) {
return;
@@ -180,6 +208,9 @@ export class Message extends React.PureComponent<Props, State> {
}
public componentWillUnmount() {
if (this.selectedTimeout) {
clearInterval(this.selectedTimeout);
}
if (this.expirationCheckInterval) {
clearInterval(this.expirationCheckInterval);
}
@@ -189,9 +220,26 @@ export class Message extends React.PureComponent<Props, State> {
}
public componentDidUpdate() {
this.startSelectedTimer();
this.checkExpired();
}
public startSelectedTimer() {
const { isSelected } = this.state;
if (!isSelected) {
return;
}
if (!this.selectedTimeout) {
this.selectedTimeout = setTimeout(() => {
this.selectedTimeout = undefined;
this.setState({ isSelected: false });
this.props.clearSelectedMessage();
}, SELECTED_TIMEOUT);
}
}
public checkExpired() {
const now = Date.now();
const { isExpired, expirationTimestamp, expirationLength } = this.props;
@@ -379,7 +427,7 @@ export class Message extends React.PureComponent<Props, State> {
isSticker,
text,
} = this.props;
const { imageBroken } = this.state;
const { imageBroken, isSelected } = this.state;
if (!attachments || !attachments[0]) {
return null;
@@ -422,6 +470,7 @@ export class Message extends React.PureComponent<Props, State> {
withContentAbove={isSticker || withContentAbove}
withContentBelow={isSticker || withContentBelow}
isSticker={isSticker}
isSelected={isSticker && isSelected}
stickerSize={STICKER_SIZE}
bottomOverlay={bottomOverlay}
i18n={i18n}
@@ -622,7 +671,7 @@ export class Message extends React.PureComponent<Props, State> {
disableScroll,
i18n,
quote,
scrollToMessage,
scrollToQuotedMessage,
} = this.props;
if (!quote) {
@@ -633,15 +682,14 @@ export class Message extends React.PureComponent<Props, State> {
conversationType === 'group' && direction === 'incoming';
const quoteColor =
direction === 'incoming' ? authorColor : quote.authorColor;
const { referencedMessageNotFound } = quote;
const clickHandler = disableScroll
? undefined
: () => {
scrollToMessage({
scrollToQuotedMessage({
author: quote.authorId,
sentAt: quote.sentAt,
referencedMessageNotFound,
});
};
@@ -1195,12 +1243,24 @@ export class Message extends React.PureComponent<Props, State> {
);
}
public renderSelectionHighlight() {
const { isSticker } = this.props;
const { isSelected } = this.state;
if (!isSelected || isSticker) {
return;
}
return <div className="module-message__container__selection" />;
}
// tslint:disable-next-line cyclomatic-complexity
public render() {
const {
authorPhoneNumber,
authorColor,
attachments,
conversationType,
direction,
displayTapToViewMessage,
id,
@@ -1211,6 +1271,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp,
} = this.props;
const { expired, expiring, imageBroken } = this.state;
const isAttachmentPending = this.isAttachmentPending();
const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending;
@@ -1236,7 +1297,8 @@ export class Message extends React.PureComponent<Props, State> {
className={classNames(
'module-message',
`module-message--${direction}`,
expiring ? 'module-message--expired' : null
expiring ? 'module-message--expired' : null,
conversationType === 'group' ? 'module-message--group' : null
)}
>
{this.renderError(direction === 'incoming')}
@@ -1271,6 +1333,7 @@ export class Message extends React.PureComponent<Props, State> {
>
{this.renderAuthor()}
{this.renderContents()}
{this.renderSelectionHighlight()}
{this.renderAvatar()}
</div>
{this.renderError(direction === 'outgoing')}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import React from 'react';
// import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
+4 -17
View File
@@ -1,9 +1,9 @@
### None
### No new messages
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={0}
withNewMessages={false}
conversationId="id-1"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
@@ -11,28 +11,15 @@
</util.ConversationContext>
```
### One
### With new messages
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={1}
withNewMessages={true}
conversationId="id-2"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### More than one
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={2}
conversationId="id-3"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
/>
</util.ConversationContext>
```
@@ -4,7 +4,7 @@ import classNames from 'classnames';
import { LocalizerType } from '../../types/Util';
type Props = {
count: number;
withNewMessages: boolean;
conversationId: string;
scrollDown: (conversationId: string) => void;
@@ -14,21 +14,17 @@ type Props = {
export class ScrollDownButton extends React.Component<Props> {
public render() {
const { conversationId, count, i18n, scrollDown } = this.props;
let altText = i18n('scrollDown');
if (count > 1) {
altText = i18n('messagesBelow');
} else if (count === 1) {
altText = i18n('messageBelow');
}
const { conversationId, withNewMessages, i18n, scrollDown } = this.props;
const altText = withNewMessages
? i18n('messagesBelow')
: i18n('scrollDown');
return (
<div className="module-scroll-down">
<button
className={classNames(
'module-scroll-down__button',
count > 0 ? 'module-scroll-down__button--new-messages' : null
withNewMessages ? 'module-scroll-down__button--new-messages' : null
)}
onClick={() => {
scrollDown(conversationId);
+187 -12
View File
@@ -1,5 +1,7 @@
```javascript
const itemLookup = {
## With oldest and newest
```jsx
window.itemLookup = {
'id-1': {
type: 'message',
data: {
@@ -15,12 +17,24 @@ const itemLookup = {
type: 'message',
data: {
id: 'id-2',
conversationType: 'group',
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'green',
text: 'Hello there from the new world! http://somewhere.com',
},
},
'id-2.5': {
type: 'unsupportedMessage',
data: {
id: 'id-2.5',
canProcessNow: false,
contact: {
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Pig',
},
},
},
'id-3': {
type: 'message',
data: {
@@ -155,25 +169,186 @@ const itemLookup = {
},
};
const actions = {
window.actions = {
// For messages
downloadAttachment: options => console.log('onDownload', options),
replyToitem: id => console.log('onReply', id),
showMessageDetail: id => console.log('onShowDetail', id),
deleteMessage: id => console.log('onDelete', id),
downloadNewVersion: () => console.log('downloadNewVersion'),
// For Timeline
clearChangedMessages: (...args) => console.log('clearChangedMessages', args),
setLoadCountdownStart: (...args) =>
console.log('setLoadCountdownStart', args),
loadAndScroll: (...args) => console.log('loadAndScroll', args),
loadOlderMessages: (...args) => console.log('loadOlderMessages', args),
loadNewerMessages: (...args) => console.log('loadNewerMessages', args),
loadNewestMessages: (...args) => console.log('loadNewestMessages', args),
markMessageRead: (...args) => console.log('markMessageRead', args),
};
const items = util._.keys(itemLookup);
const renderItem = id => {
const item = itemLookup[id];
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 0,
// Because we can't use ...item syntax
return React.createElement(
TimelineItem,
util._.merge({ item, i18n: util.i18n }, actions)
);
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
};
<div style={{ height: '300px' }}>
<Timeline items={items} renderItem={renderItem} i18n={util.i18n} />
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## With last seen indicator
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: 2,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 2,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
renderLastSeenIndicator: () => (
<LastSeenIndicator count={2} i18n={util.i18n} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## With target index = 0
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: 0,
scrollToIndexCounter: 0,
totalUnread: 0,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## With typing indicator
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 0,
typingContact: true,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
renderTypingBubble: () => (
<TypingBubble color="red" conversationType="direct" phoneNumber="+18005552222" i18n={util.i18n} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## Without newest message
```
const props = {
id: 'conversationId-1',
haveNewest: false,
haveOldest: true,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: 3,
scrollToIndexCounter: 0,
totalUnread: 0,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
## Without oldest message
```
const props = {
id: 'conversationId-1',
haveNewest: true,
haveOldest: false,
isLoadingMessages: false,
items: util._.keys(window.itemLookup),
messagesHaveChanged: false,
oldestUnreadIndex: null,
resetCounter: 0,
scrollToIndex: null,
scrollToIndexCounter: 0,
totalUnread: 0,
renderItem: id => (
<TimelineItem item={window.itemLookup[id]} i18n={util.i18n} {...actions} />
),
renderLoadingRow: () => (
<TimelineLoadingRow state="idle" />
),
};
<div style={{ height: '300px' }}>
<Timeline {...props} {...window.actions} i18n={util.i18n} />
</div>;
```
+775 -34
View File
@@ -1,3 +1,4 @@
import { debounce, isNumber } from 'lodash';
import React from 'react';
import {
AutoSizer,
@@ -6,24 +7,64 @@ import {
List,
} from 'react-virtualized';
import { ScrollDownButton } from './ScrollDownButton';
import { LocalizerType } from '../../types/Util';
import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
type PropsData = {
const AT_BOTTOM_THRESHOLD = 1;
const NEAR_BOTTOM_THRESHOLD = 15;
const AT_TOP_THRESHOLD = 10;
const LOAD_MORE_THRESHOLD = 30;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
export const LOAD_COUNTDOWN = 2 * 1000;
export type PropsDataType = {
haveNewest: boolean;
haveOldest: boolean;
isLoadingMessages: boolean;
items: Array<string>;
renderItem: (id: string) => JSX.Element;
loadCountdownStart?: number;
messageHeightChanges: boolean;
oldestUnreadIndex?: number;
resetCounter: number;
scrollToIndex?: number;
scrollToIndexCounter: number;
totalUnread: number;
};
type PropsHousekeeping = {
type PropsHousekeepingType = {
id: string;
unreadCount?: number;
typingContact?: Object;
i18n: LocalizerType;
renderItem: (id: string, actions: Object) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
renderLoadingRow: (id: string) => JSX.Element;
renderTypingBubble: (id: string) => JSX.Element;
};
type PropsActions = MessageActionsType & SafetyNumberActionsType;
type PropsActionsType = {
clearChangedMessages: (conversationId: string) => unknown;
setLoadCountdownStart: (
conversationId: string,
loadCountdownStart?: number
) => unknown;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
type Props = PropsData & PropsHousekeeping & PropsActions;
loadAndScroll: (messageId: string) => unknown;
loadOlderMessages: (messageId: string) => unknown;
loadNewerMessages: (messageId: string) => unknown;
loadNewestMessages: (messageId: string) => unknown;
markMessageRead: (messageId: string) => unknown;
} & MessageActionsType &
SafetyNumberActionsType;
type Props = PropsDataType & PropsHousekeepingType & PropsActionsType;
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
type RowRendererParamsType = {
@@ -34,37 +75,407 @@ type RowRendererParamsType = {
parent: Object;
style: Object;
};
type OnScrollParamsType = {
scrollTop: number;
clientHeight: number;
scrollHeight: number;
export class Timeline extends React.PureComponent<Props> {
clientWidth: number;
scrollWidth?: number;
scrollLeft?: number;
scrollToColumn?: number;
_hasScrolledToColumnTarget?: boolean;
scrollToRow?: number;
_hasScrolledToRowTarget?: boolean;
};
type VisibleRowsType = {
newest?: {
id: string;
offsetTop: number;
row: number;
};
oldest?: {
id: string;
offsetTop: number;
row: number;
};
};
type State = {
atBottom: boolean;
atTop: boolean;
oneTimeScrollRow?: number;
prevPropScrollToIndex?: number;
prevPropScrollToIndexCounter?: number;
propScrollToIndex?: number;
shouldShowScrollDownButton: boolean;
areUnreadBelowCurrentPosition: boolean;
};
export class Timeline extends React.PureComponent<Props, State> {
public cellSizeCache = new CellMeasurerCache({
defaultHeight: 85,
defaultHeight: 64,
fixedWidth: true,
});
public mostRecentWidth = 0;
public mostRecentHeight = 0;
public offsetFromBottom: number | undefined = 0;
public resizeAllFlag = false;
public listRef = React.createRef<any>();
public visibleRows: VisibleRowsType | undefined;
public loadCountdownTimeout: any;
public componentDidUpdate(prevProps: Props) {
if (this.resizeAllFlag) {
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
this.recomputeRowHeights();
} else if (this.props.items !== prevProps.items) {
const index = prevProps.items.length;
this.cellSizeCache.clear(index, 0);
this.recomputeRowHeights(index);
}
constructor(props: Props) {
super(props);
const { scrollToIndex } = this.props;
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
this.state = {
atBottom: true,
atTop: false,
oneTimeScrollRow,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false,
};
}
public resizeAll = () => {
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
public static getDerivedStateFromProps(props: Props, state: State): State {
if (
isNumber(props.scrollToIndex) &&
(props.scrollToIndex !== state.prevPropScrollToIndex ||
props.scrollToIndexCounter !== state.prevPropScrollToIndexCounter)
) {
return {
...state,
propScrollToIndex: props.scrollToIndex,
prevPropScrollToIndex: props.scrollToIndex,
prevPropScrollToIndexCounter: props.scrollToIndexCounter,
};
}
return state;
}
public getList = () => {
if (!this.listRef) {
return;
}
const { current } = this.listRef;
return current;
};
public recomputeRowHeights = (index?: number) => {
if (this.listRef && this.listRef) {
this.listRef.current.recomputeRowHeights(index);
public getGrid = () => {
const list = this.getList();
if (!list) {
return;
}
return list.Grid;
};
public getScrollContainer = () => {
const grid = this.getGrid();
if (!grid) {
return;
}
return grid._scrollingContainer as HTMLDivElement;
};
public scrollToRow = (row: number) => {
const list = this.getList();
if (!list) {
return;
}
list.scrollToRow(row);
};
public recomputeRowHeights = (row?: number) => {
const list = this.getList();
if (!list) {
return;
}
list.recomputeRowHeights(row);
};
public onHeightOnlyChange = () => {
const grid = this.getGrid();
const scrollContainer = this.getScrollContainer();
if (!grid || !scrollContainer) {
return;
}
if (!isNumber(this.offsetFromBottom)) {
return;
}
const { clientHeight, scrollHeight, scrollTop } = scrollContainer;
const newOffsetFromBottom = Math.max(
0,
scrollHeight - clientHeight - scrollTop
);
const delta = newOffsetFromBottom - this.offsetFromBottom;
grid.scrollToPosition({ scrollTop: scrollContainer.scrollTop + delta });
};
public resizeAll = () => {
this.offsetFromBottom = undefined;
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
const rowCount = this.getRowCount();
this.recomputeRowHeights(rowCount - 1);
};
public onScroll = (data: OnScrollParamsType) => {
// Ignore scroll events generated as react-virtualized recursively scrolls and
// re-measures to get us where we want to go.
if (
isNumber(data.scrollToRow) &&
data.scrollToRow >= 0 &&
!data._hasScrolledToRowTarget
) {
return;
}
// Sometimes react-virtualized ends up with some incorrect math - we've scrolled below
// what should be possible. In this case, we leave everything the same and ask
// react-virtualized to try again. Without this, we'll set atBottom to true and
// pop the user back down to the bottom.
const { clientHeight, scrollHeight, scrollTop } = data;
if (scrollTop + clientHeight > scrollHeight) {
this.resizeAll();
return;
}
this.updateScrollMetrics(data);
this.updateWithVisibleRows();
};
// tslint:disable-next-line member-ordering
public updateScrollMetrics = debounce(
(data: OnScrollParamsType) => {
const { clientHeight, clientWidth, scrollHeight, scrollTop } = data;
if (clientHeight <= 0 || scrollHeight <= 0) {
return;
}
const {
haveNewest,
haveOldest,
id,
setIsNearBottom,
setLoadCountdownStart,
} = this.props;
if (
this.mostRecentHeight &&
clientHeight !== this.mostRecentHeight &&
this.mostRecentWidth &&
clientWidth === this.mostRecentWidth
) {
this.onHeightOnlyChange();
}
// If we've scrolled, we want to reset these
const oneTimeScrollRow = undefined;
const propScrollToIndex = undefined;
this.offsetFromBottom = Math.max(
0,
scrollHeight - clientHeight - scrollTop
);
const atBottom =
haveNewest && this.offsetFromBottom <= AT_BOTTOM_THRESHOLD;
const isNearBottom =
haveNewest && this.offsetFromBottom <= NEAR_BOTTOM_THRESHOLD;
const atTop = scrollTop <= AT_TOP_THRESHOLD;
const loadCountdownStart = atTop && !haveOldest ? Date.now() : undefined;
if (this.loadCountdownTimeout) {
clearTimeout(this.loadCountdownTimeout);
this.loadCountdownTimeout = null;
}
if (isNumber(loadCountdownStart)) {
this.loadCountdownTimeout = setTimeout(
this.loadOlderMessages,
LOAD_COUNTDOWN
);
}
if (loadCountdownStart !== this.props.loadCountdownStart) {
setLoadCountdownStart(id, loadCountdownStart);
}
setIsNearBottom(id, isNearBottom);
this.setState({
atBottom,
atTop,
oneTimeScrollRow,
propScrollToIndex,
});
},
50,
{ maxWait: 50 }
);
public updateVisibleRows = () => {
let newest;
let oldest;
const scrollContainer = this.getScrollContainer();
if (!scrollContainer) {
return;
}
if (scrollContainer.clientHeight === 0) {
return;
}
const visibleTop = scrollContainer.scrollTop;
const visibleBottom = visibleTop + scrollContainer.clientHeight;
const innerScrollContainer = scrollContainer.children[0];
if (!innerScrollContainer) {
return;
}
const { children } = innerScrollContainer;
for (let i = children.length - 1; i >= 0; i -= 1) {
const child = children[i] as HTMLDivElement;
const { id, offsetTop, offsetHeight } = child;
if (!id) {
continue;
}
const bottom = offsetTop + offsetHeight;
if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) {
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
newest = { offsetTop, row, id };
break;
}
}
const max = children.length;
for (let i = 0; i < max; i += 1) {
const child = children[i] as HTMLDivElement;
const { offsetTop, id } = child;
if (!id) {
continue;
}
if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) {
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
oldest = { offsetTop, row, id };
break;
}
}
this.visibleRows = { newest, oldest };
};
// tslint:disable-next-line member-ordering cyclomatic-complexity
public updateWithVisibleRows = debounce(
() => {
const {
unreadCount,
haveNewest,
isLoadingMessages,
items,
loadNewerMessages,
markMessageRead,
} = this.props;
if (!items || items.length < 1) {
return;
}
this.updateVisibleRows();
if (!this.visibleRows) {
return;
}
const { newest } = this.visibleRows;
if (!newest || !newest.id) {
return;
}
markMessageRead(newest.id);
const rowCount = this.getRowCount();
const lastId = items[items.length - 1];
if (
!isLoadingMessages &&
!haveNewest &&
newest.row > rowCount - LOAD_MORE_THRESHOLD
) {
loadNewerMessages(lastId);
}
const lastIndex = items.length - 1;
const lastItemRow = this.fromItemIndexToRow(lastIndex);
const areUnreadBelowCurrentPosition = Boolean(
isNumber(unreadCount) &&
unreadCount > 0 &&
(!haveNewest || newest.row < lastItemRow)
);
const shouldShowScrollDownButton = Boolean(
!haveNewest ||
areUnreadBelowCurrentPosition ||
newest.row < rowCount - SCROLL_DOWN_BUTTON_THRESHOLD
);
this.setState({
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
});
},
500,
{ maxWait: 500 }
);
public loadOlderMessages = () => {
const {
haveOldest,
isLoadingMessages,
items,
loadOlderMessages,
} = this.props;
if (this.loadCountdownTimeout) {
clearTimeout(this.loadCountdownTimeout);
this.loadCountdownTimeout = null;
}
if (isLoadingMessages || haveOldest || !items || items.length < 1) {
return;
}
const oldestId = items[0];
loadOlderMessages(oldestId);
};
public rowRenderer = ({
@@ -73,8 +484,62 @@ export class Timeline extends React.PureComponent<Props> {
parent,
style,
}: RowRendererParamsType) => {
const { items, renderItem } = this.props;
const messageId = items[index];
const {
id,
haveOldest,
items,
renderItem,
renderLoadingRow,
renderLastSeenIndicator,
renderTypingBubble,
} = this.props;
const row = index;
const oldestUnreadRow = this.getLastSeenIndicatorRow();
const typingBubbleRow = this.getTypingBubbleRow();
let rowContents;
if (!haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={style}>
{renderLoadingRow(id)}
</div>
);
} else if (oldestUnreadRow === row) {
rowContents = (
<div data-row={row} style={style}>
{renderLastSeenIndicator(id)}
</div>
);
} else if (typingBubbleRow === row) {
rowContents = (
<div
data-row={row}
className="module-timeline__message-container"
style={style}
>
{renderTypingBubble(id)}
</div>
);
} else {
const itemIndex = this.fromRowToItemIndex(row);
if (typeof itemIndex !== 'number') {
throw new Error(
`Attempted to render item with undefined index - row ${row}`
);
}
const messageId = items[itemIndex];
rowContents = (
<div
id={messageId}
data-row={row}
className="module-timeline__message-container"
style={style}
>
{renderItem(messageId, this.props)}
</div>
);
}
return (
<CellMeasurer
@@ -85,16 +550,277 @@ export class Timeline extends React.PureComponent<Props> {
rowIndex={index}
width={this.mostRecentWidth}
>
<div className="module-timeline__message-container" style={style}>
{renderItem(messageId)}
</div>
{rowContents}
</CellMeasurer>
);
};
public render() {
public fromItemIndexToRow(index: number) {
const { haveOldest, oldestUnreadIndex } = this.props;
let addition = 0;
if (!haveOldest) {
addition += 1;
}
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
addition += 1;
}
return index + addition;
}
public getRowCount() {
const { haveOldest, oldestUnreadIndex, typingContact } = this.props;
const { items } = this.props;
if (!items || items.length < 1) {
return 0;
}
let extraRows = 0;
if (!haveOldest) {
extraRows += 1;
}
if (isNumber(oldestUnreadIndex)) {
extraRows += 1;
}
if (typingContact) {
extraRows += 1;
}
return items.length + extraRows;
}
public fromRowToItemIndex(row: number): number | undefined {
const { haveOldest, items } = this.props;
let subtraction = 0;
if (!haveOldest) {
subtraction += 1;
}
const oldestUnreadRow = this.getLastSeenIndicatorRow();
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {
subtraction += 1;
}
const index = row - subtraction;
if (index < 0 || index >= items.length) {
return;
}
return index;
}
public getLastSeenIndicatorRow() {
const { oldestUnreadIndex } = this.props;
if (!isNumber(oldestUnreadIndex)) {
return;
}
return this.fromItemIndexToRow(oldestUnreadIndex) - 1;
}
public getTypingBubbleRow() {
const { items } = this.props;
if (!items || items.length < 0) {
return;
}
const last = items.length - 1;
return this.fromItemIndexToRow(last) + 1;
}
public onScrollToMessage = (messageId: string) => {
const { isLoadingMessages, items, loadAndScroll } = this.props;
const index = items.findIndex(item => item === messageId);
if (index >= 0) {
const row = this.fromItemIndexToRow(index);
this.setState({
oneTimeScrollRow: row,
});
}
if (!isLoadingMessages) {
loadAndScroll(messageId);
}
};
public scrollToBottom = () => {
this.setState({
propScrollToIndex: undefined,
oneTimeScrollRow: undefined,
atBottom: true,
});
};
public onClickScrollDownButton = () => {
const {
haveNewest,
isLoadingMessages,
items,
loadNewestMessages,
} = this.props;
const lastId = items[items.length - 1];
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
if (!this.visibleRows) {
if (haveNewest) {
this.scrollToBottom();
} else if (!isLoadingMessages) {
loadNewestMessages(lastId);
}
return;
}
const { newest } = this.visibleRows;
if (
newest &&
isNumber(lastSeenIndicatorRow) &&
newest.row < lastSeenIndicatorRow
) {
this.setState({
oneTimeScrollRow: lastSeenIndicatorRow,
});
} else if (haveNewest) {
this.scrollToBottom();
} else if (!isLoadingMessages) {
loadNewestMessages(lastId);
}
};
public componentDidUpdate(prevProps: Props) {
const {
id,
clearChangedMessages,
items,
messageHeightChanges,
oldestUnreadIndex,
resetCounter,
scrollToIndex,
typingContact,
} = this.props;
// There are a number of situations which can necessitate that we drop our row height
// cache and start over. It can cause the scroll position to do weird things, so we
// try to minimize those situations. In some cases we could reset a smaller set
// of cached row data, but we currently don't have an API for that. We'd need to
// create it.
if (
!prevProps.items ||
prevProps.items.length === 0 ||
resetCounter !== prevProps.resetCounter
) {
const oneTimeScrollRow = this.getLastSeenIndicatorRow();
this.setState({
oneTimeScrollRow,
atBottom: true,
propScrollToIndex: scrollToIndex,
prevPropScrollToIndex: scrollToIndex,
});
if (prevProps.items && prevProps.items.length > 0) {
this.resizeAll();
}
return;
} else if (!typingContact && prevProps.typingContact) {
this.resizeAll();
} else if (oldestUnreadIndex !== prevProps.oldestUnreadIndex) {
this.resizeAll();
} else if (
items &&
items.length > 0 &&
prevProps.items &&
prevProps.items.length > 0 &&
items !== prevProps.items
) {
if (this.state.atTop) {
const oldFirstIndex = 0;
const oldFirstId = prevProps.items[oldFirstIndex];
const newIndex = items.findIndex(item => item === oldFirstId);
if (newIndex < 0) {
this.resizeAll();
return;
}
const newRow = this.fromItemIndexToRow(newIndex);
this.resizeAll();
this.setState({ oneTimeScrollRow: newRow });
} else {
const oldLastIndex = prevProps.items.length - 1;
const oldLastId = prevProps.items[oldLastIndex];
const newIndex = items.findIndex(item => item === oldLastId);
if (newIndex < 0) {
this.resizeAll();
return;
}
const indexDelta = newIndex - oldLastIndex;
// If we've just added to the end of the list, then the index of the last id's
// index won't have changed, and we can rely on List's detection that items is
// different for the necessary re-render.
if (indexDelta !== 0) {
this.resizeAll();
}
}
} else if (messageHeightChanges) {
this.resizeAll();
clearChangedMessages(id);
} else if (this.resizeAllFlag) {
this.resizeAll();
}
}
public getScrollTarget = () => {
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
const rowCount = this.getRowCount();
const targetMessage = isNumber(propScrollToIndex)
? this.fromItemIndexToRow(propScrollToIndex)
: undefined;
const scrollToBottom = atBottom ? rowCount - 1 : undefined;
if (isNumber(targetMessage)) {
return targetMessage;
}
if (isNumber(oneTimeScrollRow)) {
return oneTimeScrollRow;
}
return scrollToBottom;
};
public render() {
const { i18n, id, items } = this.props;
const {
shouldShowScrollDownButton,
areUnreadBelowCurrentPosition,
} = this.state;
if (!items || items.length < 1) {
return null;
}
const rowCount = this.getRowCount();
const scrollToIndex = this.getScrollTarget();
return (
<div className="module-timeline">
<AutoSizer>
@@ -103,26 +829,41 @@ export class Timeline extends React.PureComponent<Props> {
this.resizeAllFlag = true;
setTimeout(this.resizeAll, 0);
} else if (
this.mostRecentHeight &&
this.mostRecentHeight !== height
) {
setTimeout(this.onHeightOnlyChange, 0);
}
this.mostRecentWidth = width;
this.mostRecentHeight = height;
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// This also registers us with parent InfiniteLoader
// onRowsRendered={onRowsRendered}
overscanRowCount={0}
onScroll={this.onScroll as any}
overscanRowCount={10}
ref={this.listRef}
rowCount={items.length}
rowCount={rowCount}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
scrollToAlignment="start"
scrollToIndex={scrollToIndex}
width={width}
/>
);
}}
</AutoSizer>
{shouldShowScrollDownButton ? (
<ScrollDownButton
conversationId={id}
withNewMessages={areUnreadBelowCurrentPosition}
scrollDown={this.onClickScrollDownButton}
i18n={i18n}
/>
) : null}
</div>
);
}
+49 -1
View File
@@ -1,3 +1,51 @@
### A plain message
```jsx
const item = {} < TimelineItem;
const item = {
type: 'message',
data: {
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
text: '🔥',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### A notification
```jsx
const item = {
type: 'timerNotification',
data: {
type: 'fromOther',
phoneNumber: '(202) 555-0000',
timespan: '1 hour',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### Unknown type
```jsx
const item = {
type: 'random',
data: {
somethin: 'somethin',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### Missing itme
```jsx
<TimelineItem item={null} i18n={util.i18n} />
```
+28 -9
View File
@@ -6,6 +6,11 @@ import {
PropsActions as MessageActionsType,
PropsData as MessageProps,
} from './Message';
import {
PropsActions as UnsupportedMessageActionsType,
PropsData as UnsupportedMessageProps,
UnsupportedMessage,
} from './UnsupportedMessage';
import {
PropsData as TimerNotificationProps,
TimerNotification,
@@ -29,6 +34,10 @@ type MessageType = {
type: 'message';
data: MessageProps;
};
type UnsupportedMessageType = {
type: 'unsupportedMessage';
data: UnsupportedMessageProps;
};
type TimerNotificationType = {
type: 'timerNotification';
data: TimerNotificationProps;
@@ -49,22 +58,26 @@ type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
};
export type TimelineItemType =
| MessageType
| UnsupportedMessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
type PropsData = {
item:
| MessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
item?: TimelineItemType;
};
type PropsHousekeeping = {
i18n: LocalizerType;
};
type PropsActions = MessageActionsType & SafetyNumberActionsType;
type PropsActions = MessageActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
type Props = PropsData & PropsHousekeeping & PropsActions;
@@ -73,12 +86,18 @@ export class TimelineItem extends React.PureComponent<Props> {
const { item, i18n } = this.props;
if (!item) {
throw new Error('TimelineItem: Item was not provided!');
// tslint:disable-next-line:no-console
console.warn('TimelineItem: item provided was falsey');
return null;
}
if (item.type === 'message') {
return <Message {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'unsupportedMessage') {
return <UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'timerNotification') {
return <TimerNotification {...this.props} {...item.data} i18n={i18n} />;
}
@@ -0,0 +1,28 @@
### Idle
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<TimelineLoadingRow state="idle" />
</util.ConversationContext>
```
### Countdown
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<TimelineLoadingRow
state="countdown"
duration={30000}
expiresAt={Date.now() + 20000}
onComplete={() => console.log('onComplete')}
/>
</util.ConversationContext>
```
### Loading
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<TimelineLoadingRow state="loading" />
</util.ConversationContext>
```
@@ -0,0 +1,48 @@
import React from 'react';
import { isNumber } from 'lodash';
import { Countdown } from '../Countdown';
import { Spinner } from '../Spinner';
export type STATE_ENUM = 'idle' | 'countdown' | 'loading';
type Props = {
state: STATE_ENUM;
duration?: number;
expiresAt?: number;
onComplete?: () => unknown;
};
const FAKE_DURATION = 1000;
export class TimelineLoadingRow extends React.PureComponent<Props> {
public renderContents() {
const { state, duration, expiresAt, onComplete } = this.props;
if (state === 'idle') {
const fakeExpiresAt = Date.now() - FAKE_DURATION;
return <Countdown duration={FAKE_DURATION} expiresAt={fakeExpiresAt} />;
} else if (
state === 'countdown' &&
isNumber(duration) &&
isNumber(expiresAt)
) {
return (
<Countdown
duration={duration}
expiresAt={expiresAt}
onComplete={onComplete}
/>
);
}
return <Spinner size="24" svgSize="small" direction="on-background" />;
}
public render() {
return (
<div className="module-timeline-loading-row">{this.renderContents()}</div>
);
}
}
@@ -5,8 +5,6 @@ import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
export type PropsData = {
type: 'fromOther' | 'fromMe' | 'fromSync';
phoneNumber: string;
@@ -63,7 +61,9 @@ export class TimerNotification extends React.Component<Props> {
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
default:
throw missingCaseError(type);
console.warn('TimerNotification: unsupported type provided:', type);
return null;
}
}
+32 -32
View File
@@ -19,7 +19,7 @@ function getDecember1159() {
}
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -28,8 +28,8 @@ function getDecember1159() {
text="500ms ago - all below 1 minute are 'now'"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -38,8 +38,8 @@ function getDecember1159() {
text="Five seconds ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -48,8 +48,8 @@ function getDecember1159() {
text="30 seconds ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -58,8 +58,8 @@ function getDecember1159() {
text="One minute ago - in minutes"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -68,8 +68,8 @@ function getDecember1159() {
text="30 minutes ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -78,8 +78,8 @@ function getDecember1159() {
text="45 minutes ago (used to round up to 1 hour with moment)"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -88,8 +88,8 @@ function getDecember1159() {
text="One hour ago - in hours"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -98,8 +98,8 @@ function getDecember1159() {
text="12:01am today"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -108,8 +108,8 @@ function getDecember1159() {
text="11:59pm yesterday - adds day name"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -118,8 +118,8 @@ function getDecember1159() {
text="24 hours ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -128,8 +128,8 @@ function getDecember1159() {
text="Two days ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -138,8 +138,8 @@ function getDecember1159() {
text="Seven days ago - adds month"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -148,8 +148,8 @@ function getDecember1159() {
text="Thirty days ago"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -158,8 +158,8 @@ function getDecember1159() {
text="January 1st at 12:01am"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -168,8 +168,8 @@ function getDecember1159() {
text="December 31st at 11:59pm - adds year"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<Message
direction="incoming"
status="delivered"
@@ -178,6 +178,6 @@ function getDecember1159() {
text="One year ago"
i18n={util.i18n}
/>
</li>
</div>
</util.ConversationContext>;
```
+10 -10
View File
@@ -2,12 +2,12 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<TypingBubble conversationType="direct" i18n={util.i18n} />
</li>
<li>
</div>
<div className="module-message-container">
<TypingBubble color="teal" conversationType="direct" i18n={util.i18n} />
</li>
</div>
</util.ConversationContext>
```
@@ -15,24 +15,24 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<div className="module-message-container">
<TypingBubble color="red" conversationType="group" i18n={util.i18n} />
</li>
<li>
</div>
<div className="module-message-container">
<TypingBubble
color="purple"
authorName="First Last"
conversationType="group"
i18n={util.i18n}
/>
</li>
<li>
</div>
<div className="module-message-container">
<TypingBubble
avatarPath={util.gifObjectUrl}
color="blue"
conversationType="group"
i18n={util.i18n}
/>
</li>
</div>
</util.ConversationContext>
```
+13 -6
View File
@@ -9,14 +9,14 @@ import { LocalizerType } from '../../types/Util';
interface Props {
avatarPath?: string;
color: string;
name: string;
name?: string;
phoneNumber: string;
profileName: string;
conversationType: string;
profileName?: string;
conversationType: 'group' | 'direct';
i18n: LocalizerType;
}
export class TypingBubble extends React.Component<Props> {
export class TypingBubble extends React.PureComponent<Props> {
public renderAvatar() {
const {
avatarPath,
@@ -49,10 +49,17 @@ export class TypingBubble extends React.Component<Props> {
}
public render() {
const { i18n, color } = this.props;
const { i18n, color, conversationType } = this.props;
const isGroup = conversationType === 'group';
return (
<div className={classNames('module-message', 'module-message--incoming')}>
<div
className={classNames(
'module-message',
'module-message--incoming',
isGroup ? 'module-message--group' : null
)}
>
<div
className={classNames(
'module-message__container',
@@ -18,14 +18,14 @@ export type PropsData = {
contact: ContactType;
};
type PropsHousekeeping = {
i18n: LocalizerType;
};
export type PropsActions = {
downloadNewVersion: () => unknown;
};
type PropsHousekeeping = {
i18n: LocalizerType;
};
type Props = PropsData & PropsHousekeeping & PropsActions;
export class UnsupportedMessage extends React.Component<Props> {
+1 -1
View File
@@ -18,7 +18,7 @@ export function renderAvatar({
contact: ContactType;
i18n: LocalizerType;
size: number;
direction?: string;
direction?: 'outgoing' | 'incoming';
}) {
const { avatar } = contact;