Refactor messages model; New timeline react components

This commit is contained in:
Scott Nonnenberg 2019-03-20 10:42:28 -07:00
parent d342b23cbc
commit c41bc53614
31 changed files with 1463 additions and 3395 deletions

View file

@ -70,13 +70,17 @@ export class LeftPane extends React.Component<Props> {
: conversations[index];
return (
<ConversationListItem
<div
key={key}
className="module-left-pane__conversation-container"
style={style}
{...conversation}
onClick={openConversationInternal}
i18n={i18n}
/>
>
<ConversationListItem
{...conversation}
onClick={openConversationInternal}
i18n={i18n}
/>
</div>
);
};

View file

@ -21,10 +21,15 @@ interface Change {
contacts?: Array<Contact>;
}
interface Props {
export type PropsData = {
changes: Array<Change>;
};
type PropsHousekeeping = {
i18n: LocalizerType;
}
};
type Props = PropsData & PropsHousekeeping;
export class GroupNotification extends React.Component<Props> {
public renderChange(change: Change) {

View file

@ -0,0 +1,15 @@
### One
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<LastSeenIndicator count={1} i18n={util.i18n} />
</util.ConversationContext>
```
### More than one
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<LastSeenIndicator count={2} i18n={util.i18n} />
</util.ConversationContext>
```

View file

@ -0,0 +1,26 @@
import React from 'react';
import { LocalizerType } from '../../types/Util';
type Props = {
count: number;
i18n: LocalizerType;
};
export class LastSeenIndicator extends React.Component<Props> {
public render() {
const { count, i18n } = this.props;
const message =
count === 1
? i18n('unreadMessage')
: i18n('unreadMessages', [String(count)]);
return (
<div className="module-last-seen-indicator">
<div className="module-last-seen-indicator__bar" />
<div className="module-last-seen-indicator__text">{message}</div>
</div>
);
}
}

View file

@ -1,4 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { Avatar } from '../Avatar';
@ -46,7 +47,7 @@ interface LinkPreviewType {
image?: AttachmentType;
}
type PropsData = {
export type PropsData = {
id: string;
text?: string;
textPending?: boolean;
@ -863,7 +864,7 @@ export class Message extends React.PureComponent<Props, State> {
const isDangerous = isFileDangerous(fileName || '');
const multipleAttachments = attachments && attachments.length > 1;
return (
const menu = (
<ContextMenu id={triggerId}>
{!multipleAttachments && attachments && attachments[0] ? (
<MenuItem
@ -925,6 +926,8 @@ export class Message extends React.PureComponent<Props, State> {
</MenuItem>
</ContextMenu>
);
return ReactDOM.createPortal(menu, document.body);
}
public getWidth(): number | undefined {

View file

@ -12,7 +12,7 @@ interface ContactType {
name?: string;
}
type PropsData = {
export type PropsData = {
isGroup: boolean;
contact: ContactType;
};

View file

@ -0,0 +1,38 @@
### None
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={0}
conversationId="id-1"
scrollDown={id => console.log('scrollDown', id)}
i18n={util.i18n}
/>
</util.ConversationContext>
```
### One
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<ScrollDownButton
count={1}
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>
```

View file

@ -0,0 +1,43 @@
import React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../../types/Util';
type Props = {
count: number;
conversationId: string;
scrollDown: (conversationId: string) => void;
i18n: LocalizerType;
};
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');
}
return (
<div className="module-scroll-down">
<button
className={classNames(
'module-scroll-down__button',
count > 0 ? 'module-scroll-down__button--new-messages' : null
)}
onClick={() => {
scrollDown(conversationId);
}}
title={altText}
>
<div className="module-scroll-down__icon" />
</button>
</div>
);
}
}

View file

@ -0,0 +1,179 @@
```javascript
const itemLookup = {
'id-1': {
type: 'message',
data: {
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
text: '🔥',
},
},
'id-2': {
type: 'message',
data: {
id: 'id-2',
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'green',
text: 'Hello there from the new world! http://somewhere.com',
},
},
'id-3': {
type: 'message',
data: {
id: 'id-3',
collapseMetadata: true,
direction: 'incoming',
timestamp: Date.now(),
authorColor: 'red',
text: 'Hello there from the new world!',
},
},
'id-4': {
type: 'timerNotification',
data: {
type: 'fromMe',
timespan: '5 minutes',
},
},
'id-5': {
type: 'timerNotification',
data: {
type: 'fromOther',
phoneNumber: '(202) 555-0000',
timespan: '1 hour',
},
},
'id-6': {
type: 'safetyNumberNotification',
data: {
contact: {
id: '+1202555000',
phoneNumber: '(202) 555-0000',
profileName: 'Mr. Fire',
},
},
},
'id-7': {
type: 'verificationNotification',
data: {
contact: {
phoneNumber: '(202) 555-0001',
name: 'Mrs. Ice',
},
isLocal: true,
type: 'markVerified',
},
},
'id-8': {
type: 'groupNotification',
data: {
changes: [
{
type: 'name',
newName: 'Squirrels and their uses',
},
{
type: 'add',
contacts: [
{
phoneNumber: '(202) 555-0002',
},
{
phoneNumber: '(202) 555-0003',
profileName: 'Ms. Water',
},
],
},
],
isMe: false,
},
},
'id-9': {
type: 'resetSessionNotification',
data: null,
},
'id-10': {
type: 'message',
data: {
id: 'id-6',
direction: 'outgoing',
timestamp: Date.now(),
status: 'sent',
authorColor: 'pink',
text: '🔥',
},
},
'id-11': {
type: 'message',
data: {
id: 'id-7',
direction: 'outgoing',
timestamp: Date.now(),
status: 'read',
authorColor: 'pink',
text: 'Hello there from the new world! http://somewhere.com',
},
},
'id-12': {
type: 'message',
data: {
id: 'id-8',
collapseMetadata: true,
direction: 'outgoing',
status: 'sent',
timestamp: Date.now(),
text: 'Hello there from the new world! 🔥',
},
},
'id-13': {
type: 'message',
data: {
id: 'id-9',
direction: 'outgoing',
status: 'sent',
timestamp: Date.now(),
authorColor: 'blue',
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
},
},
'id-14': {
type: 'message',
data: {
id: 'id-10',
direction: 'outgoing',
status: 'read',
timestamp: Date.now(),
collapseMetadata: true,
text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
},
},
};
const actions = {
downloadAttachment: options => console.log('onDownload', options),
replyToitem: id => console.log('onReply', id),
showMessageDetail: id => console.log('onShowDetail', id),
deleteMessage: id => console.log('onDelete', id),
};
const items = util._.keys(itemLookup);
const renderItem = id => {
const item = itemLookup[id];
// Because we can't use ...item syntax
return React.createElement(
TimelineItem,
util._.merge({ item, i18n: util.i18n }, actions)
);
};
<div style={{ height: '300px' }}>
<Timeline items={items} renderItem={renderItem} i18n={util.i18n} />
</div>;
```

View file

@ -0,0 +1,129 @@
import React from 'react';
import {
AutoSizer,
CellMeasurer,
CellMeasurerCache,
List,
} from 'react-virtualized';
import { LocalizerType } from '../../types/Util';
import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
type PropsData = {
items: Array<string>;
renderItem: (id: string) => JSX.Element;
};
type PropsHousekeeping = {
i18n: LocalizerType;
};
type PropsActions = MessageActionsType & SafetyNumberActionsType;
type Props = PropsData & PropsHousekeeping & PropsActions;
// from https://github.com/bvaughn/react-virtualized/blob/fb3484ed5dcc41bffae8eab029126c0fb8f7abc0/source/List/types.js#L5
type RowRendererParamsType = {
index: number;
isScrolling: boolean;
isVisible: boolean;
key: string;
parent: Object;
style: Object;
};
export class Timeline extends React.PureComponent<Props> {
public cellSizeCache = new CellMeasurerCache({
defaultHeight: 85,
fixedWidth: true,
});
public mostRecentWidth = 0;
public resizeAllFlag = false;
public listRef = React.createRef<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);
}
}
public resizeAll = () => {
this.resizeAllFlag = false;
this.cellSizeCache.clearAll();
};
public recomputeRowHeights = (index?: number) => {
if (this.listRef && this.listRef) {
this.listRef.current.recomputeRowHeights(index);
}
};
public rowRenderer = ({
index,
key,
parent,
style,
}: RowRendererParamsType) => {
const { items, renderItem } = this.props;
const messageId = items[index];
return (
<CellMeasurer
cache={this.cellSizeCache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
width={this.mostRecentWidth}
>
<div className="module-timeline__message-container" style={style}>
{renderItem(messageId)}
</div>
</CellMeasurer>
);
};
public render() {
const { items } = this.props;
return (
<div className="module-timeline">
<AutoSizer>
{({ height, width }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeAllFlag = true;
setTimeout(this.resizeAll, 0);
}
this.mostRecentWidth = width;
return (
<List
deferredMeasurementCache={this.cellSizeCache}
height={height}
// This also registers us with parent InfiniteLoader
// onRowsRendered={onRowsRendered}
overscanRowCount={0}
ref={this.listRef}
rowCount={items.length}
rowHeight={this.cellSizeCache.rowHeight}
rowRenderer={this.rowRenderer}
width={width}
/>
);
}}
</AutoSizer>
</div>
);
}
}

View file

@ -0,0 +1,3 @@
```jsx
const item = {} < TimelineItem;
```

View file

@ -0,0 +1,106 @@
import React from 'react';
import { LocalizerType } from '../../types/Util';
import {
Message,
PropsActions as MessageActionsType,
PropsData as MessageProps,
} from './Message';
import {
PropsData as TimerNotificationProps,
TimerNotification,
} from './TimerNotification';
import {
PropsActions as SafetyNumberActionsType,
PropsData as SafetyNumberNotificationProps,
SafetyNumberNotification,
} from './SafetyNumberNotification';
import {
PropsData as VerificationNotificationProps,
VerificationNotification,
} from './VerificationNotification';
import {
GroupNotification,
PropsData as GroupNotificationProps,
} from './GroupNotification';
import { ResetSessionNotification } from './ResetSessionNotification';
type MessageType = {
type: 'message';
data: MessageProps;
};
type TimerNotificationType = {
type: 'timerNotification';
data: TimerNotificationProps;
};
type SafetyNumberNotificationType = {
type: 'safetyNumberNotification';
data: SafetyNumberNotificationProps;
};
type VerificationNotificationType = {
type: 'verificationNotification';
data: VerificationNotificationProps;
};
type GroupNotificationType = {
type: 'groupNotification';
data: GroupNotificationProps;
};
type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
};
type PropsData = {
item:
| MessageType
| TimerNotificationType
| SafetyNumberNotificationType
| VerificationNotificationType
| ResetSessionNotificationType
| GroupNotificationType;
};
type PropsHousekeeping = {
i18n: LocalizerType;
};
type PropsActions = MessageActionsType & SafetyNumberActionsType;
type Props = PropsData & PropsHousekeeping & PropsActions;
export class TimelineItem extends React.PureComponent<Props> {
public render() {
const { item, i18n } = this.props;
if (!item) {
throw new Error('TimelineItem: Item was not provided!');
}
if (item.type === 'message') {
return <Message {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'timerNotification') {
return <TimerNotification {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'safetyNumberNotification') {
return (
<SafetyNumberNotification {...this.props} {...item.data} i18n={i18n} />
);
}
if (item.type === 'verificationNotification') {
return (
<VerificationNotification {...this.props} {...item.data} i18n={i18n} />
);
}
if (item.type === 'groupNotification') {
return <GroupNotification {...this.props} {...item.data} i18n={i18n} />;
}
if (item.type === 'resetSessionNotification') {
return (
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
);
}
throw new Error('TimelineItem: Unknown type!');
}
}

View file

@ -7,15 +7,20 @@ import { LocalizerType } from '../../types/Util';
import { missingCaseError } from '../../util/missingCaseError';
interface Props {
export type PropsData = {
type: 'fromOther' | 'fromMe' | 'fromSync';
phoneNumber: string;
profileName?: string;
name?: string;
disabled: boolean;
timespan: string;
};
type PropsHousekeeping = {
i18n: LocalizerType;
}
};
type Props = PropsData & PropsHousekeeping;
export class TimerNotification extends React.Component<Props> {
public renderContents() {

View file

@ -13,12 +13,17 @@ interface Contact {
name?: string;
}
interface Props {
export type PropsData = {
type: 'markVerified' | 'markNotVerified';
isLocal: boolean;
contact: Contact;
};
type PropsHousekeeping = {
i18n: LocalizerType;
}
};
type Props = PropsData & PropsHousekeeping;
export class VerificationNotification extends React.Component<Props> {
public getStringId() {