Refactor messages model; New timeline react components
This commit is contained in:
parent
d342b23cbc
commit
c41bc53614
31 changed files with 1463 additions and 3395 deletions
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
15
ts/components/conversation/LastSeenIndicator.md
Normal file
15
ts/components/conversation/LastSeenIndicator.md
Normal 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>
|
||||
```
|
26
ts/components/conversation/LastSeenIndicator.tsx
Normal file
26
ts/components/conversation/LastSeenIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -12,7 +12,7 @@ interface ContactType {
|
|||
name?: string;
|
||||
}
|
||||
|
||||
type PropsData = {
|
||||
export type PropsData = {
|
||||
isGroup: boolean;
|
||||
contact: ContactType;
|
||||
};
|
||||
|
|
38
ts/components/conversation/ScrollDownButton.md
Normal file
38
ts/components/conversation/ScrollDownButton.md
Normal 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>
|
||||
```
|
43
ts/components/conversation/ScrollDownButton.tsx
Normal file
43
ts/components/conversation/ScrollDownButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
179
ts/components/conversation/Timeline.md
Normal file
179
ts/components/conversation/Timeline.md
Normal 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>;
|
||||
```
|
129
ts/components/conversation/Timeline.tsx
Normal file
129
ts/components/conversation/Timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
3
ts/components/conversation/TimelineItem.md
Normal file
3
ts/components/conversation/TimelineItem.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
```jsx
|
||||
const item = {} < TimelineItem;
|
||||
```
|
106
ts/components/conversation/TimelineItem.tsx
Normal file
106
ts/components/conversation/TimelineItem.tsx
Normal 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!');
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue