Move left pane entirely to React
This commit is contained in:
parent
bf904ddd12
commit
b3ac1373fa
142 changed files with 5016 additions and 3428 deletions
|
@ -2,7 +2,7 @@ export const show = (element: HTMLElement): void => {
|
|||
const container: HTMLDivElement | null = document.querySelector(
|
||||
'.lightbox-container'
|
||||
);
|
||||
if (container === null) {
|
||||
if (!container) {
|
||||
throw new TypeError("'.lightbox-container' is required");
|
||||
}
|
||||
// tslint:disable-next-line:no-inner-html
|
||||
|
@ -15,7 +15,7 @@ export const hide = (): void => {
|
|||
const container: HTMLDivElement | null = document.querySelector(
|
||||
'.lightbox-container'
|
||||
);
|
||||
if (container === null) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
// tslint:disable-next-line:no-inner-html
|
||||
|
|
|
@ -2,13 +2,13 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import { getInitials } from '../util/getInitials';
|
||||
import { Localizer } from '../types/Util';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
interface Props {
|
||||
avatarPath?: string;
|
||||
color?: string;
|
||||
conversationType: 'group' | 'direct';
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
noteToSelf?: boolean;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
|
@ -44,9 +44,8 @@ export class Avatar extends React.Component<Props, State> {
|
|||
public renderImage() {
|
||||
const { avatarPath, i18n, name, phoneNumber, profileName } = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
const hasImage = avatarPath && !imageBroken;
|
||||
|
||||
if (!hasImage) {
|
||||
if (!avatarPath || imageBroken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
import React from 'react';
|
||||
import * as GoogleChrome from '../util/GoogleChrome';
|
||||
|
||||
import { AttachmentType } from './conversation/types';
|
||||
import { AttachmentType } from '../types/Attachment';
|
||||
|
||||
import { Localizer } from '../types/Util';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
interface Props {
|
||||
attachment: AttachmentType;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
url: string;
|
||||
caption?: string;
|
||||
onSave?: (caption: string) => void;
|
||||
|
@ -21,15 +21,15 @@ interface State {
|
|||
}
|
||||
|
||||
export class CaptionEditor extends React.Component<Props, State> {
|
||||
private handleKeyUpBound: (
|
||||
private readonly handleKeyUpBound: (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => void;
|
||||
private setFocusBound: () => void;
|
||||
// TypeScript doesn't like our React.Ref typing here, so we omit it
|
||||
private captureRefBound: () => void;
|
||||
private onChangeBound: () => void;
|
||||
private onSaveBound: () => void;
|
||||
private inputRef: React.Ref<HTMLInputElement> | null;
|
||||
private readonly setFocusBound: () => void;
|
||||
private readonly onChangeBound: (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => void;
|
||||
private readonly onSaveBound: () => void;
|
||||
private readonly inputRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
@ -41,10 +41,16 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
|
||||
this.handleKeyUpBound = this.handleKeyUp.bind(this);
|
||||
this.setFocusBound = this.setFocus.bind(this);
|
||||
this.captureRefBound = this.captureRef.bind(this);
|
||||
this.onChangeBound = this.onChange.bind(this);
|
||||
this.onSaveBound = this.onSave.bind(this);
|
||||
this.inputRef = null;
|
||||
this.inputRef = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// Forcing focus after a delay due to some focus contention with ConversationView
|
||||
setTimeout(() => {
|
||||
this.setFocus();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
|
@ -61,21 +67,11 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
public setFocus() {
|
||||
if (this.inputRef) {
|
||||
// @ts-ignore
|
||||
this.inputRef.focus();
|
||||
if (this.inputRef.current) {
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public captureRef(ref: React.Ref<HTMLInputElement>) {
|
||||
this.inputRef = ref;
|
||||
|
||||
// Forcing focus after a delay due to some focus contention with ConversationView
|
||||
setTimeout(() => {
|
||||
this.setFocus();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
public onSave() {
|
||||
const { onSave } = this.props;
|
||||
const { caption } = this.state;
|
||||
|
@ -124,6 +120,7 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
public render() {
|
||||
const { i18n, close } = this.props;
|
||||
const { caption } = this.state;
|
||||
const onKeyUp = close ? this.handleKeyUpBound : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -143,12 +140,12 @@ export class CaptionEditor extends React.Component<Props, State> {
|
|||
<div className="module-caption-editor__input-container">
|
||||
<input
|
||||
type="text"
|
||||
ref={this.captureRefBound}
|
||||
ref={this.inputRef}
|
||||
value={caption}
|
||||
maxLength={200}
|
||||
placeholder={i18n('addACaption')}
|
||||
className="module-caption-editor__caption-input"
|
||||
onKeyUp={close ? this.handleKeyUpBound : undefined}
|
||||
onKeyUp={onKeyUp}
|
||||
onChange={this.onChangeBound}
|
||||
/>
|
||||
{caption ? (
|
||||
|
|
|
@ -4,17 +4,17 @@ import classNames from 'classnames';
|
|||
import { Avatar } from './Avatar';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
|
||||
import { Localizer } from '../types/Util';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
interface Props {
|
||||
phoneNumber: string;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
color?: string;
|
||||
color: string;
|
||||
verified: boolean;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
name="Someone 🔥 Somewhere"
|
||||
conversationType={'direct'}
|
||||
phoneNumber="(202) 555-0011"
|
||||
|
@ -12,7 +13,7 @@
|
|||
text: "What's going on?",
|
||||
status: 'sent',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
|
@ -23,6 +24,7 @@
|
|||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Mr. Fire🔥"
|
||||
|
@ -32,7 +34,7 @@
|
|||
text: 'Just a second',
|
||||
status: 'read',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
|
@ -43,6 +45,7 @@
|
|||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
isMe={true}
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
|
@ -53,7 +56,7 @@
|
|||
text: 'Just a second',
|
||||
status: 'read',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
|
@ -65,6 +68,7 @@
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Mr. Fire🔥"
|
||||
|
@ -74,10 +78,11 @@
|
|||
text: 'Sending',
|
||||
status: 'sending',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Mr. Fire🔥"
|
||||
|
@ -87,10 +92,11 @@
|
|||
text: 'Sent',
|
||||
status: 'sent',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId3"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Mr. Fire🔥"
|
||||
|
@ -100,10 +106,11 @@
|
|||
text: 'Delivered',
|
||||
status: 'delivered',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId4"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Mr. Fire🔥"
|
||||
|
@ -113,10 +120,11 @@
|
|||
text: 'Read',
|
||||
status: 'read',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId5"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Mr. Fire🔥"
|
||||
|
@ -126,7 +134,7 @@
|
|||
text: 'Error',
|
||||
status: 'error',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -139,17 +147,19 @@
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
unreadCount={4}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
isTyping={true}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
unreadCount={4}
|
||||
|
@ -158,7 +168,7 @@
|
|||
lastMessage={{
|
||||
status: 'read',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -173,6 +183,7 @@
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
unreadCount={4}
|
||||
|
@ -180,10 +191,11 @@
|
|||
lastMessage={{
|
||||
text: 'Hey there!',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
unreadCount={10}
|
||||
|
@ -191,10 +203,11 @@
|
|||
lastMessage={{
|
||||
text: 'Hey there!',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId3"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
unreadCount={250}
|
||||
|
@ -202,7 +215,7 @@
|
|||
lastMessage={{
|
||||
text: 'Hey there!',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -214,6 +227,7 @@
|
|||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
isSelected={true}
|
||||
|
@ -221,7 +235,7 @@
|
|||
lastMessage={{
|
||||
text: 'Hey there!',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
|
@ -235,23 +249,25 @@ We don't want Jumbomoji or links.
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: 'Download at http://signal.org',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: '🔥',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -266,6 +282,7 @@ We only show one line.
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Long contact name. Esquire. The third. And stuff. And more! And more!"
|
||||
|
@ -273,10 +290,11 @@ We only show one line.
|
|||
lastMessage={{
|
||||
text: 'Normal message',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -284,10 +302,11 @@ We only show one line.
|
|||
text:
|
||||
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId3"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -296,11 +315,12 @@ We only show one line.
|
|||
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
|
||||
status: 'read',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
|
||||
<ConversationListItem
|
||||
id="conversationId4"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -309,10 +329,11 @@ We only show one line.
|
|||
text:
|
||||
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId5"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -320,10 +341,11 @@ We only show one line.
|
|||
text:
|
||||
"Many lines. This is a many-line message.\nLine 2 is really exciting but it shouldn't be seen.\nLine three is even better.\nLine 4, well.",
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId6"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -332,7 +354,7 @@ We only show one line.
|
|||
"Many lines. This is a many-line message.\nLine 2 is really exciting but it shouldn't be seen.\nLine three is even better.\nLine 4, well.",
|
||||
status: 'delivered',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -347,6 +369,7 @@ On platforms that show scrollbars all the time, this is true all the time.
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div style={{ width: '280px' }}>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
name="Long contact name. Esquire. The third. And stuff. And more! And more!"
|
||||
|
@ -354,10 +377,11 @@ On platforms that show scrollbars all the time, this is true all the time.
|
|||
lastMessage={{
|
||||
text: 'Normal message',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -365,7 +389,7 @@ On platforms that show scrollbars all the time, this is true all the time.
|
|||
text:
|
||||
"Long line. This is a really really really long line. Really really long. Because that's just how it is",
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -378,43 +402,47 @@ On platforms that show scrollbars all the time, this is true all the time.
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: 'Five hours ago',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 24 * 60 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: 'One day ago',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId3"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 7 * 24 * 60 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: 'One week ago',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId4"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 365 * 24 * 60 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: 'One year ago',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
@ -427,26 +455,29 @@ On platforms that show scrollbars all the time, this is true all the time.
|
|||
<util.LeftPaneContext theme={util.theme}>
|
||||
<div>
|
||||
<ConversationListItem
|
||||
id="conversationId1"
|
||||
name="John"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={null}
|
||||
lastMessage={{
|
||||
text: 'Missing last updated',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId2"
|
||||
name="Missing message"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
lastMessage={{
|
||||
text: null,
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<ConversationListItem
|
||||
id="conversationId3"
|
||||
phoneNumber="(202) 555-0011"
|
||||
conversationType={'direct'}
|
||||
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||
|
@ -454,7 +485,7 @@ On platforms that show scrollbars all the time, this is true all the time.
|
|||
text: null,
|
||||
status: 'sent',
|
||||
}}
|
||||
onClick={() => console.log('onClick')}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -7,14 +7,15 @@ import { Timestamp } from './conversation/Timestamp';
|
|||
import { ContactName } from './conversation/ContactName';
|
||||
import { TypingAnimation } from './conversation/TypingAnimation';
|
||||
|
||||
import { Localizer } from '../types/Util';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
interface Props {
|
||||
export type PropsData = {
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
color?: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
conversationType: 'group' | 'direct';
|
||||
type: 'group' | 'direct';
|
||||
avatarPath?: string;
|
||||
isMe: boolean;
|
||||
|
||||
|
@ -27,17 +28,21 @@ interface Props {
|
|||
status: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
|
||||
i18n: Localizer;
|
||||
onClick?: () => void;
|
||||
}
|
||||
type PropsHousekeeping = {
|
||||
i18n: LocalizerType;
|
||||
onClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
export class ConversationListItem extends React.Component<Props> {
|
||||
type Props = PropsData & PropsHousekeeping;
|
||||
|
||||
export class ConversationListItem extends React.PureComponent<Props> {
|
||||
public renderAvatar() {
|
||||
const {
|
||||
avatarPath,
|
||||
color,
|
||||
conversationType,
|
||||
type,
|
||||
i18n,
|
||||
isMe,
|
||||
name,
|
||||
|
@ -51,7 +56,7 @@ export class ConversationListItem extends React.Component<Props> {
|
|||
avatarPath={avatarPath}
|
||||
color={color}
|
||||
noteToSelf={isMe}
|
||||
conversationType={conversationType}
|
||||
conversationType={type}
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
|
@ -130,10 +135,10 @@ export class ConversationListItem extends React.Component<Props> {
|
|||
|
||||
public renderMessage() {
|
||||
const { lastMessage, isTyping, unreadCount, i18n } = this.props;
|
||||
|
||||
if (!lastMessage && !isTyping) {
|
||||
return null;
|
||||
}
|
||||
const text = lastMessage && lastMessage.text ? lastMessage.text : '';
|
||||
|
||||
return (
|
||||
<div className="module-conversation-list-item__message">
|
||||
|
@ -149,7 +154,7 @@ export class ConversationListItem extends React.Component<Props> {
|
|||
<TypingAnimation i18n={i18n} />
|
||||
) : (
|
||||
<MessageBody
|
||||
text={lastMessage && lastMessage.text ? lastMessage.text : ''}
|
||||
text={text}
|
||||
disableJumbomoji={true}
|
||||
disableLinks={true}
|
||||
i18n={i18n}
|
||||
|
@ -171,12 +176,16 @@ export class ConversationListItem extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const { unreadCount, onClick, isSelected } = this.props;
|
||||
const { unreadCount, onClick, id, isSelected } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick(id);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'module-conversation-list-item',
|
||||
unreadCount > 0 ? 'module-conversation-list-item--has-unread' : null,
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Localizer, RenderTextCallback } from '../types/Util';
|
||||
import { LocalizerType, RenderTextCallbackType } from '../types/Util';
|
||||
|
||||
type FullJSX = Array<JSX.Element | string> | JSX.Element | string;
|
||||
|
||||
interface Props {
|
||||
/** The translation string id */
|
||||
id: string;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
components?: Array<FullJSX>;
|
||||
renderText?: RenderTextCallback;
|
||||
renderText?: RenderTextCallbackType;
|
||||
}
|
||||
|
||||
export class Intl extends React.Component<Props> {
|
||||
|
@ -17,7 +17,7 @@ export class Intl extends React.Component<Props> {
|
|||
renderText: ({ text }) => text,
|
||||
};
|
||||
|
||||
public getComponent(index: number): FullJSX | null {
|
||||
public getComponent(index: number): FullJSX | undefined {
|
||||
const { id, components } = this.props;
|
||||
|
||||
if (!components || !components.length || components.length <= index) {
|
||||
|
@ -26,7 +26,7 @@ export class Intl extends React.Component<Props> {
|
|||
`Error: Intl missing provided components for id ${id}, index ${index}`
|
||||
);
|
||||
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return components[index];
|
||||
|
|
168
ts/components/LeftPane.md
Normal file
168
ts/components/LeftPane.md
Normal file
|
@ -0,0 +1,168 @@
|
|||
#### With search results
|
||||
|
||||
```jsx
|
||||
window.searchResults = {};
|
||||
window.searchResults.conversations = [
|
||||
{
|
||||
id: 'convo1',
|
||||
name: 'Everyone 🌆',
|
||||
conversationType: 'group',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
avatarPath: util.landscapeGreenObjectUrl,
|
||||
lastUpdated: Date.now() - 5 * 60 * 1000,
|
||||
lastMessage: {
|
||||
text: 'The rabbit hopped silently in the night.',
|
||||
status: 'sent',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'convo2',
|
||||
name: 'Everyone Else 🔥',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0012',
|
||||
avatarPath: util.landscapePurpleObjectUrl,
|
||||
lastUpdated: Date.now() - 5 * 60 * 1000,
|
||||
lastMessage: {
|
||||
text: "What's going on?",
|
||||
status: 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'convo3',
|
||||
name: 'John the Turtle',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0021',
|
||||
lastUpdated: Date.now() - 24 * 60 * 60 * 1000,
|
||||
lastMessage: {
|
||||
text: 'I dunno',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'convo4',
|
||||
name: 'The Fly',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0022',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
lastUpdated: Date.now(),
|
||||
lastMessage: {
|
||||
text: 'Gimme!',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
window.searchResults.contacts = [
|
||||
{
|
||||
id: 'contact1',
|
||||
name: 'The one Everyone',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0013',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
{
|
||||
id: 'contact2',
|
||||
e: 'No likey everyone',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0014',
|
||||
color: 'red',
|
||||
},
|
||||
];
|
||||
|
||||
window.searchResults.messages = [
|
||||
{
|
||||
from: {
|
||||
isMe: true,
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
to: {
|
||||
name: 'Mr. Fire 🔥',
|
||||
phoneNumber: '(202) 555-0015',
|
||||
},
|
||||
id: '1-guid-guid-guid-guid-guid',
|
||||
conversationId: '(202) 555-0015',
|
||||
receivedAt: Date.now() - 5 * 60 * 1000,
|
||||
snippet: '<<left>>Everyone<<right>>! Get in!',
|
||||
},
|
||||
{
|
||||
from: {
|
||||
name: 'Jon ❄️',
|
||||
phoneNumber: '(202) 555-0016',
|
||||
color: 'green',
|
||||
},
|
||||
to: {
|
||||
isMe: true,
|
||||
},
|
||||
id: '2-guid-guid-guid-guid-guid',
|
||||
conversationId: '(202) 555-0016',
|
||||
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
|
||||
receivedAt: Date.now() - 20 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
from: {
|
||||
name: 'Someone',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
color: 'green',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
},
|
||||
to: {
|
||||
name: "Y'all 🌆",
|
||||
},
|
||||
id: '3-guid-guid-guid-guid-guid',
|
||||
conversationId: 'EveryoneGroupID',
|
||||
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
|
||||
receivedAt: Date.now() - 24 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
from: {
|
||||
isMe: true,
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
to: {
|
||||
name: "Y'all 🌆",
|
||||
},
|
||||
id: '4-guid-guid-guid-guid-guid',
|
||||
conversationId: 'EveryoneGroupID',
|
||||
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
|
||||
receivedAt: Date.now() - 24 * 60 * 1000,
|
||||
},
|
||||
];
|
||||
|
||||
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
|
||||
<LeftPane
|
||||
searchResults={window.searchResults}
|
||||
openConversation={result => console.log('openConversation', result)}
|
||||
openMessage={result => console.log('onClickMessage', result)}
|
||||
renderMainHeader={() => (
|
||||
<MainHeader
|
||||
searchTerm="Hi there!"
|
||||
search={result => console.log('search', result)}
|
||||
updateSearch={result => console.log('updateSearch', result)}
|
||||
clearSearch={result => console.log('clearSearch', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>;
|
||||
```
|
||||
|
||||
#### With just conversations
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme} style={{ height: '200px' }}>
|
||||
<LeftPane
|
||||
conversations={window.searchResults.conversations}
|
||||
openConversation={result => console.log('openConversation', result)}
|
||||
openMessage={result => console.log('onClickMessage', result)}
|
||||
renderMainHeader={() => (
|
||||
<MainHeader
|
||||
searchTerm="Hi there!"
|
||||
search={result => console.log('search', result)}
|
||||
updateSearch={result => console.log('updateSearch', result)}
|
||||
clearSearch={result => console.log('clearSearch', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
71
ts/components/LeftPane.tsx
Normal file
71
ts/components/LeftPane.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
|
||||
import {
|
||||
ConversationListItem,
|
||||
PropsData as ConversationListItemPropsType,
|
||||
} from './ConversationListItem';
|
||||
import {
|
||||
PropsData as SearchResultsProps,
|
||||
SearchResults,
|
||||
} from './SearchResults';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export interface Props {
|
||||
conversations?: Array<ConversationListItemPropsType>;
|
||||
searchResults?: SearchResultsProps;
|
||||
i18n: LocalizerType;
|
||||
|
||||
// Action Creators
|
||||
startNewConversation: () => void;
|
||||
openConversationInternal: (id: string, messageId?: string) => void;
|
||||
|
||||
// Render Props
|
||||
renderMainHeader: () => JSX.Element;
|
||||
}
|
||||
|
||||
export class LeftPane extends React.Component<Props> {
|
||||
public renderList() {
|
||||
const {
|
||||
conversations,
|
||||
i18n,
|
||||
openConversationInternal,
|
||||
startNewConversation,
|
||||
searchResults,
|
||||
} = this.props;
|
||||
|
||||
if (searchResults) {
|
||||
return (
|
||||
<SearchResults
|
||||
{...searchResults}
|
||||
openConversation={openConversationInternal}
|
||||
startNewConversation={startNewConversation}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-left-pane__list">
|
||||
{(conversations || []).map(conversation => (
|
||||
<ConversationListItem
|
||||
key={conversation.phoneNumber}
|
||||
{...conversation}
|
||||
onClick={openConversationInternal}
|
||||
i18n={i18n}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { renderMainHeader } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-left-pane">
|
||||
<div className="module-left-pane__header">{renderMainHeader()}</div>
|
||||
{this.renderList()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ import is from '@sindresorhus/is';
|
|||
import * as GoogleChrome from '../util/GoogleChrome';
|
||||
import * as MIME from '../types/MIME';
|
||||
|
||||
import { Localizer } from '../types/Util';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
const Colors = {
|
||||
TEXT_SECONDARY: '#bbb',
|
||||
|
@ -26,7 +26,7 @@ const colorSVG = (url: string, color: string) => {
|
|||
interface Props {
|
||||
close: () => void;
|
||||
contentType: MIME.MIMEType | undefined;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
objectURL: string;
|
||||
caption?: string;
|
||||
onNext?: () => void;
|
||||
|
@ -164,17 +164,17 @@ const Icon = ({
|
|||
);
|
||||
|
||||
export class Lightbox extends React.Component<Props> {
|
||||
private containerRef: HTMLDivElement | null = null;
|
||||
private videoRef: HTMLVideoElement | null = null;
|
||||
|
||||
private captureVideoBound: (element: HTMLVideoElement) => void;
|
||||
private playVideoBound: () => void;
|
||||
private readonly containerRef: React.RefObject<HTMLDivElement>;
|
||||
private readonly videoRef: React.RefObject<HTMLVideoElement>;
|
||||
private readonly playVideoBound: () => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.captureVideoBound = this.captureVideo.bind(this);
|
||||
this.playVideoBound = this.playVideo.bind(this);
|
||||
|
||||
this.videoRef = React.createRef();
|
||||
this.containerRef = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
|
@ -189,20 +189,21 @@ export class Lightbox extends React.Component<Props> {
|
|||
document.removeEventListener('keyup', this.onKeyUp, useCapture);
|
||||
}
|
||||
|
||||
public captureVideo(element: HTMLVideoElement) {
|
||||
this.videoRef = element;
|
||||
}
|
||||
|
||||
public playVideo() {
|
||||
if (!this.videoRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.videoRef.paused) {
|
||||
const { current } = this.videoRef;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.paused) {
|
||||
// tslint:disable-next-line no-floating-promises
|
||||
this.videoRef.play();
|
||||
current.play();
|
||||
} else {
|
||||
this.videoRef.pause();
|
||||
current.pause();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -221,7 +222,7 @@ export class Lightbox extends React.Component<Props> {
|
|||
<div
|
||||
style={styles.container}
|
||||
onClick={this.onContainerClick}
|
||||
ref={this.setContainerRef}
|
||||
ref={this.containerRef}
|
||||
role="dialog"
|
||||
>
|
||||
<div style={styles.mainContainer}>
|
||||
|
@ -259,14 +260,14 @@ export class Lightbox extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderObject = ({
|
||||
private readonly renderObject = ({
|
||||
objectURL,
|
||||
contentType,
|
||||
i18n,
|
||||
}: {
|
||||
objectURL: string;
|
||||
contentType: MIME.MIMEType;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}) => {
|
||||
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
|
||||
if (isImageTypeSupported) {
|
||||
|
@ -285,7 +286,7 @@ export class Lightbox extends React.Component<Props> {
|
|||
return (
|
||||
<video
|
||||
role="button"
|
||||
ref={this.captureVideoBound}
|
||||
ref={this.videoRef}
|
||||
onClick={this.playVideoBound}
|
||||
controls={true}
|
||||
style={styles.object}
|
||||
|
@ -301,12 +302,11 @@ export class Lightbox extends React.Component<Props> {
|
|||
const isUnsupportedVideoType =
|
||||
!isVideoTypeSupported && MIME.isVideo(contentType);
|
||||
if (isUnsupportedImageType || isUnsupportedVideoType) {
|
||||
return (
|
||||
<Icon
|
||||
url={isUnsupportedVideoType ? 'images/video.svg' : 'images/image.svg'}
|
||||
onClick={this.onObjectClick}
|
||||
/>
|
||||
);
|
||||
const iconUrl = isUnsupportedVideoType
|
||||
? 'images/video.svg'
|
||||
: 'images/image.svg';
|
||||
|
||||
return <Icon url={iconUrl} onClick={this.onObjectClick} />;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-console
|
||||
|
@ -315,11 +315,7 @@ export class Lightbox extends React.Component<Props> {
|
|||
return <Icon onClick={this.onObjectClick} url="images/file.svg" />;
|
||||
};
|
||||
|
||||
private setContainerRef = (value: HTMLDivElement) => {
|
||||
this.containerRef = value;
|
||||
};
|
||||
|
||||
private onClose = () => {
|
||||
private readonly onClose = () => {
|
||||
const { close } = this.props;
|
||||
if (!close) {
|
||||
return;
|
||||
|
@ -328,7 +324,7 @@ export class Lightbox extends React.Component<Props> {
|
|||
close();
|
||||
};
|
||||
|
||||
private onKeyUp = (event: KeyboardEvent) => {
|
||||
private readonly onKeyUp = (event: KeyboardEvent) => {
|
||||
const { onNext, onPrevious } = this.props;
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
|
@ -351,14 +347,16 @@ export class Lightbox extends React.Component<Props> {
|
|||
}
|
||||
};
|
||||
|
||||
private onContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target !== this.containerRef) {
|
||||
private readonly onContainerClick = (
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (this.containerRef && event.target !== this.containerRef.current) {
|
||||
return;
|
||||
}
|
||||
this.onClose();
|
||||
};
|
||||
|
||||
private onObjectClick = (
|
||||
private readonly onObjectClick = (
|
||||
event: React.MouseEvent<HTMLImageElement | HTMLDivElement>
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
|
|
|
@ -6,9 +6,9 @@ import React from 'react';
|
|||
import * as MIME from '../types/MIME';
|
||||
import { Lightbox } from './Lightbox';
|
||||
import { Message } from './conversation/media-gallery/types/Message';
|
||||
import { AttachmentType } from './conversation/types';
|
||||
|
||||
import { Localizer } from '../types/Util';
|
||||
import { AttachmentType } from '../types/Attachment';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export interface MediaItemType {
|
||||
objectURL?: string;
|
||||
|
@ -21,7 +21,7 @@ export interface MediaItemType {
|
|||
|
||||
interface Props {
|
||||
close: () => void;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
media: Array<MediaItemType>;
|
||||
onSave?: (
|
||||
options: { attachment: AttachmentType; message: Message; index: number }
|
||||
|
@ -61,27 +61,30 @@ export class LightboxGallery extends React.Component<Props, State> {
|
|||
const objectURL = selectedMedia.objectURL || 'images/alert-outline.svg';
|
||||
const { attachment } = selectedMedia;
|
||||
|
||||
const saveCallback = onSave ? this.handleSave : undefined;
|
||||
const captionCallback = attachment ? attachment.caption : undefined;
|
||||
|
||||
return (
|
||||
<Lightbox
|
||||
close={close}
|
||||
onPrevious={onPrevious}
|
||||
onNext={onNext}
|
||||
onSave={onSave ? this.handleSave : undefined}
|
||||
onSave={saveCallback}
|
||||
objectURL={objectURL}
|
||||
caption={attachment ? attachment.caption : undefined}
|
||||
caption={captionCallback}
|
||||
contentType={selectedMedia.contentType}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private handlePrevious = () => {
|
||||
private readonly handlePrevious = () => {
|
||||
this.setState(prevState => ({
|
||||
selectedIndex: Math.max(prevState.selectedIndex - 1, 0),
|
||||
}));
|
||||
};
|
||||
|
||||
private handleNext = () => {
|
||||
private readonly handleNext = () => {
|
||||
this.setState((prevState, props) => ({
|
||||
selectedIndex: Math.min(
|
||||
prevState.selectedIndex + 1,
|
||||
|
@ -90,7 +93,7 @@ export class LightboxGallery extends React.Component<Props, State> {
|
|||
}));
|
||||
};
|
||||
|
||||
private handleSave = () => {
|
||||
private readonly handleSave = () => {
|
||||
const { media, onSave } = this.props;
|
||||
if (!onSave) {
|
||||
return;
|
||||
|
|
|
@ -1,11 +1,65 @@
|
|||
Note that this component is controlled, so the text in the search box will only update
|
||||
if the parent of this component feeds the updated `searchTerm` back.
|
||||
|
||||
#### With image
|
||||
|
||||
```jsx
|
||||
<MainHeader avatarPath={util.gifObjectUrl} i18n={util.i18n} />
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MainHeader
|
||||
searchTerm=""
|
||||
avatarPath={util.gifObjectUrl}
|
||||
search={text => console.log('search', text)}
|
||||
updateSearchTerm={text => console.log('updateSearchTerm', text)}
|
||||
clearSearch={() => console.log('clearSearch')}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### Just name
|
||||
|
||||
```jsx
|
||||
<MainHeader name="John Smith" color="purple" i18n={util.i18n} />
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MainHeader
|
||||
searchTerm=""
|
||||
name="John Smith"
|
||||
color="purple"
|
||||
search={text => console.log('search', text)}
|
||||
updateSearchTerm={text => console.log('updateSearchTerm', text)}
|
||||
clearSearch={() => console.log('clearSearch')}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### Just phone number
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MainHeader
|
||||
searchTerm=""
|
||||
phoneNumber="+15553004000"
|
||||
color="green"
|
||||
search={text => console.log('search', text)}
|
||||
updateSearchTerm={text => console.log('updateSearchTerm', text)}
|
||||
clearSearch={() => console.log('clearSearch')}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### Starting with a search term
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MainHeader
|
||||
name="John Smith"
|
||||
color="purple"
|
||||
searchTerm="Hewwo?"
|
||||
search={text => console.log('search', text)}
|
||||
updateSearchTerm={text => console.log('updateSearchTerm', text)}
|
||||
clearSearch={() => console.log('clearSearch')}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
|
|
@ -1,24 +1,126 @@
|
|||
import React from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
import { Localizer } from '../types/Util';
|
||||
import { cleanSearchTerm } from '../util/cleanSearchTerm';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
searchTerm: string;
|
||||
|
||||
// To be used as an ID
|
||||
ourNumber: string;
|
||||
regionCode: string;
|
||||
|
||||
// For display
|
||||
phoneNumber: string;
|
||||
isMe?: boolean;
|
||||
isMe: boolean;
|
||||
name?: string;
|
||||
color?: string;
|
||||
color: string;
|
||||
verified: boolean;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
i18n: Localizer;
|
||||
onClick?: () => void;
|
||||
|
||||
i18n: LocalizerType;
|
||||
updateSearchTerm: (searchTerm: string) => void;
|
||||
search: (
|
||||
query: string,
|
||||
options: {
|
||||
regionCode: string;
|
||||
ourNumber: string;
|
||||
noteToSelf: string;
|
||||
}
|
||||
) => void;
|
||||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
export class MainHeader extends React.Component<Props> {
|
||||
private readonly updateSearchBound: (
|
||||
event: React.FormEvent<HTMLInputElement>
|
||||
) => void;
|
||||
private readonly clearSearchBound: () => void;
|
||||
private readonly handleKeyUpBound: (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => void;
|
||||
private readonly setFocusBound: () => void;
|
||||
private readonly inputRef: React.RefObject<HTMLInputElement>;
|
||||
private readonly debouncedSearch: (searchTerm: string) => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.updateSearchBound = this.updateSearch.bind(this);
|
||||
this.clearSearchBound = this.clearSearch.bind(this);
|
||||
this.handleKeyUpBound = this.handleKeyUp.bind(this);
|
||||
this.setFocusBound = this.setFocus.bind(this);
|
||||
this.inputRef = React.createRef();
|
||||
|
||||
this.debouncedSearch = debounce(this.search.bind(this), 20);
|
||||
}
|
||||
|
||||
public search() {
|
||||
const { searchTerm, search, i18n, ourNumber, regionCode } = this.props;
|
||||
if (search) {
|
||||
search(searchTerm, {
|
||||
noteToSelf: i18n('noteToSelf').toLowerCase(),
|
||||
ourNumber,
|
||||
regionCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public updateSearch(event: React.FormEvent<HTMLInputElement>) {
|
||||
const { updateSearchTerm, clearSearch } = this.props;
|
||||
const searchTerm = event.currentTarget.value;
|
||||
|
||||
if (!searchTerm) {
|
||||
clearSearch();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateSearchTerm) {
|
||||
updateSearchTerm(searchTerm);
|
||||
}
|
||||
|
||||
if (searchTerm.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedTerm = cleanSearchTerm(searchTerm);
|
||||
if (!cleanedTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.debouncedSearch(cleanedTerm);
|
||||
}
|
||||
|
||||
public clearSearch() {
|
||||
const { clearSearch } = this.props;
|
||||
|
||||
clearSearch();
|
||||
this.setFocus();
|
||||
}
|
||||
|
||||
public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
const { clearSearch } = this.props;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
clearSearch();
|
||||
}
|
||||
}
|
||||
|
||||
public setFocus() {
|
||||
if (this.inputRef.current) {
|
||||
// @ts-ignore
|
||||
this.inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
searchTerm,
|
||||
avatarPath,
|
||||
i18n,
|
||||
color,
|
||||
|
@ -39,7 +141,30 @@ export class MainHeader extends React.Component<Props> {
|
|||
profileName={profileName}
|
||||
size={28}
|
||||
/>
|
||||
<div className="module-main-header__app-name">Signal</div>
|
||||
<div className="module-main-header__search">
|
||||
<div
|
||||
role="button"
|
||||
className="module-main-header__search__icon"
|
||||
onClick={this.setFocusBound}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
className="module-main-header__search__input"
|
||||
placeholder={i18n('search')}
|
||||
dir="auto"
|
||||
onKeyUp={this.handleKeyUpBound}
|
||||
value={searchTerm}
|
||||
onChange={this.updateSearchBound}
|
||||
/>
|
||||
{searchTerm ? (
|
||||
<div
|
||||
role="button"
|
||||
className="module-main-header__search__cancel-icon"
|
||||
onClick={this.clearSearchBound}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
41
ts/components/MessageBodyHighlight.md
Normal file
41
ts/components/MessageBodyHighlight.md
Normal file
|
@ -0,0 +1,41 @@
|
|||
Basic replacement
|
||||
|
||||
```jsx
|
||||
<MessageBodyHighlight
|
||||
text="This is before <<left>>Inside<<right>> This is after."
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
With no replacement
|
||||
|
||||
```jsx
|
||||
<MessageBodyHighlight
|
||||
text="All\nplain\ntext 🔥 http://somewhere.com"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
With two replacements
|
||||
|
||||
```jsx
|
||||
<MessageBodyHighlight
|
||||
text="Begin <<left>>Inside #1<<right>> This is between the two <<left>>Inside #2<<right>> End."
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
With emoji, newlines, and URLs
|
||||
|
||||
```jsx
|
||||
<MessageBodyHighlight
|
||||
text="http://somewhere.com\n\n🔥 Before -- <<left>>A 🔥 inside<<right>> -- After 🔥"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
No jumbomoji
|
||||
|
||||
```jsx
|
||||
<MessageBodyHighlight text="🔥" i18n={util.i18n} />
|
||||
```
|
111
ts/components/MessageBodyHighlight.tsx
Normal file
111
ts/components/MessageBodyHighlight.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
import React from 'react';
|
||||
|
||||
import { MessageBody } from './conversation/MessageBody';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import { AddNewLines } from './conversation/AddNewLines';
|
||||
|
||||
import { SizeClassType } from '../util/emoji';
|
||||
|
||||
import { LocalizerType, RenderTextCallbackType } from '../types/Util';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
const renderNewLines: RenderTextCallbackType = ({ text, key }) => (
|
||||
<AddNewLines key={key} text={text} />
|
||||
);
|
||||
|
||||
const renderEmoji = ({
|
||||
i18n,
|
||||
text,
|
||||
key,
|
||||
sizeClass,
|
||||
renderNonEmoji,
|
||||
}: {
|
||||
i18n: LocalizerType;
|
||||
text: string;
|
||||
key: number;
|
||||
sizeClass?: SizeClassType;
|
||||
renderNonEmoji: RenderTextCallbackType;
|
||||
}) => (
|
||||
<Emojify
|
||||
i18n={i18n}
|
||||
key={key}
|
||||
text={text}
|
||||
sizeClass={sizeClass}
|
||||
renderNonEmoji={renderNonEmoji}
|
||||
/>
|
||||
);
|
||||
|
||||
export class MessageBodyHighlight extends React.Component<Props> {
|
||||
public render() {
|
||||
const { text, i18n } = this.props;
|
||||
const results: Array<any> = [];
|
||||
const FIND_BEGIN_END = /<<left>>(.+?)<<right>>/g;
|
||||
|
||||
let match = FIND_BEGIN_END.exec(text);
|
||||
let last = 0;
|
||||
let count = 1;
|
||||
|
||||
if (!match) {
|
||||
return (
|
||||
<MessageBody
|
||||
disableJumbomoji={true}
|
||||
disableLinks={true}
|
||||
text={text}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sizeClass = '';
|
||||
|
||||
while (match) {
|
||||
if (last < match.index) {
|
||||
const beforeText = text.slice(last, match.index);
|
||||
results.push(
|
||||
renderEmoji({
|
||||
text: beforeText,
|
||||
sizeClass,
|
||||
key: count++,
|
||||
i18n,
|
||||
renderNonEmoji: renderNewLines,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const [, toHighlight] = match;
|
||||
results.push(
|
||||
<span className="module-message-body__highlight" key={count++}>
|
||||
{renderEmoji({
|
||||
text: toHighlight,
|
||||
sizeClass,
|
||||
key: count++,
|
||||
i18n,
|
||||
renderNonEmoji: renderNewLines,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
last = FIND_BEGIN_END.lastIndex;
|
||||
match = FIND_BEGIN_END.exec(text);
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
results.push(
|
||||
renderEmoji({
|
||||
text: text.slice(last),
|
||||
sizeClass,
|
||||
key: count++,
|
||||
i18n,
|
||||
renderNonEmoji: renderNewLines,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
191
ts/components/MessageSearchResult.md
Normal file
191
ts/components/MessageSearchResult.md
Normal file
|
@ -0,0 +1,191 @@
|
|||
#### With name and profile
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
name: 'Someone 🔥 Somewhere',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
}}
|
||||
to={{
|
||||
isMe: true,
|
||||
}}
|
||||
snippet="What's <<left>>going<<right>> on?"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 24 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### Selected
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
name: 'Someone 🔥 Somewhere',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
}}
|
||||
to={{
|
||||
isMe: true,
|
||||
}}
|
||||
isSelected={true}
|
||||
snippet="What's <<left>>going<<right>> on?"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 4 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### From you
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
isMe: true,
|
||||
}}
|
||||
to={{
|
||||
name: 'Mr. Smith',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
}}
|
||||
snippet="What's <<left>>going<<right>> on?"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 3 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
isMe: true,
|
||||
}}
|
||||
to={{
|
||||
name: 'Everyone 🔥',
|
||||
}}
|
||||
snippet="How is everyone? <<left>>Going<<right>> well?"
|
||||
id="messageId2"
|
||||
conversationId="conversationId2"
|
||||
receivedAt={Date.now() - 27 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### From you and to you
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
isMe: true,
|
||||
}}
|
||||
to={{
|
||||
isMe: true,
|
||||
}}
|
||||
snippet="Tuesday: Ate two <<left>>apple<<right>>s"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 3 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### Profile, with name, no avatar
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
name: 'Mr. Fire🔥',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
color: 'green',
|
||||
}}
|
||||
to={{
|
||||
isMe: true,
|
||||
}}
|
||||
snippet="<<left>>Just<<right>> a second"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 7 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With Group
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
name: 'Jon ❄️',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
color: 'green',
|
||||
}}
|
||||
to={{
|
||||
name: 'My Crew',
|
||||
}}
|
||||
snippet="I'm pretty <<left>>excited<<right>>!"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 30 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### Longer search results
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
name: 'Penny J',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
color: 'purple',
|
||||
avatarPath: util.pngImagePath,
|
||||
}}
|
||||
to={{
|
||||
name: 'Softball 🥎',
|
||||
}}
|
||||
snippet="This is a really <<left>>detail<<right>>ed long line which will wrap and only be cut off after it gets to three lines. So maybe this will make it in as well?"
|
||||
id="messageId1"
|
||||
conversationId="conversationId1"
|
||||
receivedAt={Date.now() - 17 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<MessageSearchResult
|
||||
from={{
|
||||
name: 'Tim Smith',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
color: 'red',
|
||||
avatarPath: util.pngImagePath,
|
||||
}}
|
||||
to={{
|
||||
name: 'Maple 🍁',
|
||||
}}
|
||||
snippet="Okay, here are the <<left>>detail<<right>>s:\n\n1355 Ridge Way\nCode: 234\n\nI'm excited!"
|
||||
id="messageId2"
|
||||
conversationId="conversationId2"
|
||||
receivedAt={Date.now() - 10 * 60 * 60 * 1000}
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
166
ts/components/MessageSearchResult.tsx
Normal file
166
ts/components/MessageSearchResult.tsx
Normal file
|
@ -0,0 +1,166 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
import { MessageBodyHighlight } from './MessageBodyHighlight';
|
||||
import { Timestamp } from './conversation/Timestamp';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type PropsData = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
receivedAt: number;
|
||||
|
||||
snippet: string;
|
||||
|
||||
from: {
|
||||
phoneNumber: string;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
color?: string;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
};
|
||||
|
||||
to: {
|
||||
groupName?: string;
|
||||
phoneNumber: string;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PropsHousekeeping = {
|
||||
isSelected?: boolean;
|
||||
|
||||
i18n: LocalizerType;
|
||||
onClick: (conversationId: string, messageId?: string) => void;
|
||||
};
|
||||
|
||||
type Props = PropsData & PropsHousekeeping;
|
||||
|
||||
export class MessageSearchResult extends React.PureComponent<Props> {
|
||||
public renderFromName() {
|
||||
const { from, i18n, to } = this.props;
|
||||
|
||||
if (from.isMe && to.isMe) {
|
||||
return (
|
||||
<span className="module-message-search-result__header__name">
|
||||
{i18n('noteToSelf')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (from.isMe) {
|
||||
return (
|
||||
<span className="module-message-search-result__header__name">
|
||||
{i18n('you')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContactName
|
||||
phoneNumber={from.phoneNumber}
|
||||
name={from.name}
|
||||
profileName={from.profileName}
|
||||
i18n={i18n}
|
||||
module="module-message-search-result__header__name"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderFrom() {
|
||||
const { i18n, to } = this.props;
|
||||
const fromName = this.renderFromName();
|
||||
|
||||
if (!to.isMe) {
|
||||
return (
|
||||
<div className="module-message-search-result__header__from">
|
||||
{fromName} {i18n('to')}{' '}
|
||||
<span className="module-mesages-search-result__header__group">
|
||||
<ContactName
|
||||
phoneNumber={to.phoneNumber}
|
||||
name={to.name}
|
||||
profileName={to.profileName}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-message-search-result__header__from">
|
||||
{fromName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderAvatar() {
|
||||
const { from, i18n, to } = this.props;
|
||||
const isNoteToSelf = from.isMe && to.isMe;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
avatarPath={from.avatarPath}
|
||||
color={from.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
noteToSelf={isNoteToSelf}
|
||||
phoneNumber={from.phoneNumber}
|
||||
profileName={from.profileName}
|
||||
size={48}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
from,
|
||||
i18n,
|
||||
id,
|
||||
isSelected,
|
||||
conversationId,
|
||||
onClick,
|
||||
receivedAt,
|
||||
snippet,
|
||||
to,
|
||||
} = this.props;
|
||||
|
||||
if (!from || !to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick(conversationId, id);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'module-message-search-result',
|
||||
isSelected ? 'module-message-search-result--is-selected' : null
|
||||
)}
|
||||
>
|
||||
{this.renderAvatar()}
|
||||
<div className="module-message-search-result__text">
|
||||
<div className="module-message-search-result__header">
|
||||
{this.renderFrom()}
|
||||
<div className="module-message-search-result__header__timestamp">
|
||||
<Timestamp timestamp={receivedAt} i18n={i18n} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-message-search-result__body">
|
||||
<MessageBodyHighlight text={snippet} i18n={i18n} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
259
ts/components/SearchResults.md
Normal file
259
ts/components/SearchResults.md
Normal file
|
@ -0,0 +1,259 @@
|
|||
#### With all result types
|
||||
|
||||
```jsx
|
||||
window.searchResults = {};
|
||||
window.searchResults.conversations = [
|
||||
{
|
||||
name: 'Everyone 🌆',
|
||||
conversationType: 'group',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
avatarPath: util.landscapeGreenObjectUrl,
|
||||
lastUpdated: Date.now() - 5 * 60 * 1000,
|
||||
lastMessage: {
|
||||
text: 'The rabbit hopped silently in the night.',
|
||||
status: 'sent',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Everyone Else 🔥',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0012',
|
||||
avatarPath: util.landscapePurpleObjectUrl,
|
||||
lastUpdated: Date.now() - 5 * 60 * 1000,
|
||||
lastMessage: {
|
||||
text: "What's going on?",
|
||||
status: 'sent',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
window.searchResults.contacts = [
|
||||
{
|
||||
name: 'The one Everyone',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0013',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
{
|
||||
name: 'No likey everyone',
|
||||
conversationType: 'direct',
|
||||
phoneNumber: '(202) 555-0014',
|
||||
color: 'red',
|
||||
},
|
||||
];
|
||||
|
||||
window.searchResults.messages = [
|
||||
{
|
||||
from: {
|
||||
isMe: true,
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
to: {
|
||||
name: 'Mr. Fire 🔥',
|
||||
phoneNumber: '(202) 555-0015',
|
||||
},
|
||||
id: '1-guid-guid-guid-guid-guid',
|
||||
conversationId: '(202) 555-0015',
|
||||
receivedAt: Date.now() - 5 * 60 * 1000,
|
||||
snippet: '<<left>>Everyone<<right>>! Get in!',
|
||||
onClick: () => console.log('onClick'),
|
||||
},
|
||||
{
|
||||
from: {
|
||||
name: 'Jon ❄️',
|
||||
phoneNumber: '(202) 555-0016',
|
||||
color: 'green',
|
||||
},
|
||||
to: {
|
||||
isMe: true,
|
||||
},
|
||||
id: '2-guid-guid-guid-guid-guid',
|
||||
conversationId: '(202) 555-0016',
|
||||
snippet: 'Why is <<left>>everyone<<right>> so frustrated?',
|
||||
receivedAt: Date.now() - 20 * 60 * 1000,
|
||||
onClick: () => console.log('onClick'),
|
||||
},
|
||||
{
|
||||
from: {
|
||||
name: 'Someone',
|
||||
phoneNumber: '(202) 555-0011',
|
||||
color: 'green',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
},
|
||||
to: {
|
||||
name: "Y'all 🌆",
|
||||
},
|
||||
id: '3-guid-guid-guid-guid-guid',
|
||||
conversationId: 'EveryoneGroupID',
|
||||
snippet: 'Hello, <<left>>everyone<<right>>! Woohooo!',
|
||||
receivedAt: Date.now() - 24 * 60 * 1000,
|
||||
onClick: () => console.log('onClick'),
|
||||
},
|
||||
{
|
||||
from: {
|
||||
isMe: true,
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
to: {
|
||||
name: "Y'all 🌆",
|
||||
},
|
||||
id: '4-guid-guid-guid-guid-guid',
|
||||
conversationId: 'EveryoneGroupID',
|
||||
snippet: 'Well, <<left>>everyone<<right>>, happy new year!',
|
||||
receivedAt: Date.now() - 24 * 60 * 1000,
|
||||
onClick: () => console.log('onClick'),
|
||||
},
|
||||
];
|
||||
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={window.searchResults.conversations}
|
||||
contacts={window.searchResults.contacts}
|
||||
messages={window.searchResults.messages}
|
||||
i18n={util.i18n}
|
||||
onClickMessage={id => console.log('onClickMessage', id)}
|
||||
onClickConversation={id => console.log('onClickConversation', id)}
|
||||
onStartNewConversation={() => console.log('onStartNewConversation')}
|
||||
/>
|
||||
</util.LeftPaneContext>;
|
||||
```
|
||||
|
||||
#### With 'start new conversation'
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={window.searchResults.conversations}
|
||||
contacts={window.searchResults.contacts}
|
||||
messages={window.searchResults.messages}
|
||||
showStartNewConversation={true}
|
||||
searchTerm="(555) 100-2000"
|
||||
i18n={util.i18n}
|
||||
onClickMessage={id => console.log('onClickMessage', id)}
|
||||
onClickConversation={id => console.log('onClickConversation', id)}
|
||||
onStartNewConversation={() => console.log('onStartNewConversation')}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With no conversations
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={null}
|
||||
contacts={window.searchResults.contacts}
|
||||
messages={window.searchResults.messages}
|
||||
i18n={util.i18n}
|
||||
onClickMessage={id => console.log('onClickMessage', id)}
|
||||
onClickConversation={id => console.log('onClickConversation', id)}
|
||||
onStartNewConversation={() => console.log('onStartNewConversation')}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With no contacts
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={window.searchResults.conversations}
|
||||
contacts={null}
|
||||
messages={window.searchResults.messages}
|
||||
i18n={util.i18n}
|
||||
onClickMessage={id => console.log('onClickMessage', id)}
|
||||
onClickConversation={id => console.log('onClickConversation', id)}
|
||||
onStartNewConversation={() => console.log('onStartNewConversation')}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With no messages
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={window.searchResults.conversations}
|
||||
contacts={window.searchResults.contacts}
|
||||
messages={null}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With no results at all
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={null}
|
||||
contacts={null}
|
||||
messages={null}
|
||||
searchTerm="dinner plans"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With a lot of results
|
||||
|
||||
```jsx
|
||||
const messages = [];
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
messages.push({
|
||||
from: {
|
||||
name: 'Mr. Fire 🔥',
|
||||
phoneNumber: '(202) 555-0015',
|
||||
avatarPath: util.landscapeGreenObjectUrl,
|
||||
},
|
||||
to: {
|
||||
isMe: true,
|
||||
},
|
||||
id: `${i}-guid-guid-guid-guid-guid`,
|
||||
conversationId: '(202) 555-0015',
|
||||
receivedAt: Date.now() - 5 * 60 * 1000,
|
||||
snippet: `${i} <<left>>Everyone<<right>>! Get in!`,
|
||||
onClick: data => console.log('onClick', data),
|
||||
});
|
||||
}
|
||||
|
||||
<util.LeftPaneContext style={{ height: '500px' }} theme={util.theme}>
|
||||
<SearchResults
|
||||
conversations={null}
|
||||
contacts={null}
|
||||
messages={messages}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>;
|
||||
```
|
||||
|
||||
#### With just messages and no header
|
||||
|
||||
```jsx
|
||||
const messages = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
messages.push({
|
||||
from: {
|
||||
name: 'Mr. Fire 🔥',
|
||||
phoneNumber: '(202) 555-0015',
|
||||
avatarPath: util.landscapeGreenObjectUrl,
|
||||
},
|
||||
to: {
|
||||
isMe: true,
|
||||
},
|
||||
id: `${i}-guid-guid-guid-guid-guid`,
|
||||
conversationId: '(202) 555-0015',
|
||||
receivedAt: Date.now() - 5 * 60 * 1000,
|
||||
snippet: `${i} <<left>>Everyone<<right>>! Get in!`,
|
||||
onClick: data => console.log('onClick', data),
|
||||
});
|
||||
}
|
||||
|
||||
<util.LeftPaneContext style={{ height: '500px' }} theme={util.theme}>
|
||||
<SearchResults
|
||||
hideMessagesHeader={true}
|
||||
messages={messages}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>;
|
||||
```
|
118
ts/components/SearchResults.tsx
Normal file
118
ts/components/SearchResults.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ConversationListItem,
|
||||
PropsData as ConversationListItemPropsType,
|
||||
} from './ConversationListItem';
|
||||
import {
|
||||
MessageSearchResult,
|
||||
PropsData as MessageSearchResultPropsType,
|
||||
} from './MessageSearchResult';
|
||||
import { StartNewConversation } from './StartNewConversation';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export type PropsData = {
|
||||
contacts: Array<ConversationListItemPropsType>;
|
||||
conversations: Array<ConversationListItemPropsType>;
|
||||
hideMessagesHeader: boolean;
|
||||
messages: Array<MessageSearchResultPropsType>;
|
||||
searchTerm: string;
|
||||
showStartNewConversation: boolean;
|
||||
};
|
||||
|
||||
type PropsHousekeeping = {
|
||||
i18n: LocalizerType;
|
||||
openConversation: (id: string, messageId?: string) => void;
|
||||
startNewConversation: (id: string) => void;
|
||||
};
|
||||
|
||||
type Props = PropsData & PropsHousekeeping;
|
||||
|
||||
export class SearchResults extends React.Component<Props> {
|
||||
public render() {
|
||||
const {
|
||||
conversations,
|
||||
contacts,
|
||||
hideMessagesHeader,
|
||||
i18n,
|
||||
messages,
|
||||
openConversation,
|
||||
startNewConversation,
|
||||
searchTerm,
|
||||
showStartNewConversation,
|
||||
} = this.props;
|
||||
|
||||
const haveConversations = conversations && conversations.length;
|
||||
const haveContacts = contacts && contacts.length;
|
||||
const haveMessages = messages && messages.length;
|
||||
const noResults =
|
||||
!showStartNewConversation &&
|
||||
!haveConversations &&
|
||||
!haveContacts &&
|
||||
!haveMessages;
|
||||
|
||||
return (
|
||||
<div className="module-search-results">
|
||||
{noResults ? (
|
||||
<div className="module-search-results__no-results">
|
||||
{i18n('noSearchResults', [searchTerm])}
|
||||
</div>
|
||||
) : null}
|
||||
{showStartNewConversation ? (
|
||||
<StartNewConversation
|
||||
phoneNumber={searchTerm}
|
||||
i18n={i18n}
|
||||
onClick={startNewConversation}
|
||||
/>
|
||||
) : null}
|
||||
{haveConversations ? (
|
||||
<div className="module-search-results__conversations">
|
||||
<div className="module-search-results__conversations-header">
|
||||
{i18n('conversationsHeader')}
|
||||
</div>
|
||||
{conversations.map(conversation => (
|
||||
<ConversationListItem
|
||||
key={conversation.phoneNumber}
|
||||
{...conversation}
|
||||
onClick={openConversation}
|
||||
i18n={i18n}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{haveContacts ? (
|
||||
<div className="module-search-results__contacts">
|
||||
<div className="module-search-results__contacts-header">
|
||||
{i18n('contactsHeader')}
|
||||
</div>
|
||||
{contacts.map(contact => (
|
||||
<ConversationListItem
|
||||
key={contact.phoneNumber}
|
||||
{...contact}
|
||||
onClick={openConversation}
|
||||
i18n={i18n}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{haveMessages ? (
|
||||
<div className="module-search-results__messages">
|
||||
{hideMessagesHeader ? null : (
|
||||
<div className="module-search-results__messages-header">
|
||||
{i18n('messagesHeader')}
|
||||
</div>
|
||||
)}
|
||||
{messages.map(message => (
|
||||
<MessageSearchResult
|
||||
key={message.id}
|
||||
{...message}
|
||||
onClick={openConversation}
|
||||
i18n={i18n}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
23
ts/components/StartNewConversation.md
Normal file
23
ts/components/StartNewConversation.md
Normal file
|
@ -0,0 +1,23 @@
|
|||
#### With full phone number
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<StartNewConversation
|
||||
phoneNumber="(202) 555-0011"
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
||||
|
||||
#### With partial phone number
|
||||
|
||||
```jsx
|
||||
<util.LeftPaneContext theme={util.theme}>
|
||||
<StartNewConversation
|
||||
phoneNumber="202"
|
||||
onClick={result => console.log('onClick', result)}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.LeftPaneContext>
|
||||
```
|
43
ts/components/StartNewConversation.tsx
Normal file
43
ts/components/StartNewConversation.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
export interface Props {
|
||||
phoneNumber: string;
|
||||
i18n: LocalizerType;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export class StartNewConversation extends React.PureComponent<Props> {
|
||||
public render() {
|
||||
const { phoneNumber, i18n, onClick } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
className="module-start-new-conversation"
|
||||
onClick={() => {
|
||||
onClick(phoneNumber);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
color="grey"
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
phoneNumber={phoneNumber}
|
||||
size={48}
|
||||
/>
|
||||
<div className="module-start-new-conversation__content">
|
||||
<div className="module-start-new-conversation__number">
|
||||
{phoneNumber}
|
||||
</div>
|
||||
<div className="module-start-new-conversation__text">
|
||||
{i18n('startConversation')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
|
||||
import { RenderTextCallback } from '../../types/Util';
|
||||
import { RenderTextCallbackType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
|
||||
renderNonNewLine?: RenderTextCallback;
|
||||
renderNonNewLine?: RenderTextCallbackType;
|
||||
}
|
||||
|
||||
export class AddNewLines extends React.Component<Props> {
|
||||
|
|
|
@ -4,16 +4,20 @@ import {
|
|||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../../util/GoogleChrome';
|
||||
import { AttachmentType } from './types';
|
||||
import { Image } from './Image';
|
||||
import { areAllAttachmentsVisual } from './ImageGrid';
|
||||
import { StagedGenericAttachment } from './StagedGenericAttachment';
|
||||
import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import {
|
||||
areAllAttachmentsVisual,
|
||||
AttachmentType,
|
||||
getUrl,
|
||||
isVideoAttachment,
|
||||
} from '../../types/Attachment';
|
||||
|
||||
interface Props {
|
||||
attachments: Array<AttachmentType>;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
// onError: () => void;
|
||||
onClickAttachment: (attachment: AttachmentType) => void;
|
||||
onCloseAttachment: (attachment: AttachmentType) => void;
|
||||
|
@ -60,9 +64,14 @@ export class AttachmentList extends React.Component<Props> {
|
|||
isImageTypeSupported(contentType) ||
|
||||
isVideoTypeSupported(contentType)
|
||||
) {
|
||||
const imageKey =
|
||||
getUrl(attachment) || attachment.fileName || index;
|
||||
const clickCallback =
|
||||
attachments.length > 1 ? onClickAttachment : undefined;
|
||||
|
||||
return (
|
||||
<Image
|
||||
key={getUrl(attachment) || attachment.fileName || index}
|
||||
key={imageKey}
|
||||
alt={i18n('stagedImageAttachment', [
|
||||
getUrl(attachment) || attachment.fileName,
|
||||
])}
|
||||
|
@ -74,17 +83,18 @@ export class AttachmentList extends React.Component<Props> {
|
|||
width={IMAGE_WIDTH}
|
||||
url={getUrl(attachment)}
|
||||
closeButton={true}
|
||||
onClick={
|
||||
attachments.length > 1 ? onClickAttachment : undefined
|
||||
}
|
||||
onClick={clickCallback}
|
||||
onClickClose={onCloseAttachment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const genericKey =
|
||||
getUrl(attachment) || attachment.fileName || index;
|
||||
|
||||
return (
|
||||
<StagedGenericAttachment
|
||||
key={getUrl(attachment) || attachment.fileName || index}
|
||||
key={genericKey}
|
||||
attachment={attachment}
|
||||
i18n={i18n}
|
||||
onClose={onCloseAttachment}
|
||||
|
@ -99,19 +109,3 @@ export class AttachmentList extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment?: AttachmentType) {
|
||||
return (
|
||||
attachment &&
|
||||
attachment.contentType &&
|
||||
isVideoTypeSupported(attachment.contentType)
|
||||
);
|
||||
}
|
||||
|
||||
function getUrl(attachment: AttachmentType) {
|
||||
if (attachment.screenshot) {
|
||||
return attachment.screenshot.url;
|
||||
}
|
||||
|
||||
return attachment.url;
|
||||
}
|
||||
|
|
|
@ -14,18 +14,18 @@ import {
|
|||
renderAvatar,
|
||||
renderContactShorthand,
|
||||
renderName,
|
||||
} from './EmbeddedContact';
|
||||
} from './_contactUtil';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
contact: Contact;
|
||||
hasSignalAccount: boolean;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
onSendMessage: () => void;
|
||||
}
|
||||
|
||||
function getLabelForEmail(method: Email, i18n: Localizer): string {
|
||||
function getLabelForEmail(method: Email, i18n: LocalizerType): string {
|
||||
switch (method.type) {
|
||||
case ContactType.CUSTOM:
|
||||
return method.label || i18n('email');
|
||||
|
@ -40,7 +40,7 @@ function getLabelForEmail(method: Email, i18n: Localizer): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getLabelForPhone(method: Phone, i18n: Localizer): string {
|
||||
function getLabelForPhone(method: Phone, i18n: LocalizerType): string {
|
||||
switch (method.type) {
|
||||
case ContactType.CUSTOM:
|
||||
return method.label || i18n('phone');
|
||||
|
@ -55,7 +55,10 @@ function getLabelForPhone(method: Phone, i18n: Localizer): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getLabelForAddress(address: PostalAddress, i18n: Localizer): string {
|
||||
function getLabelForAddress(
|
||||
address: PostalAddress,
|
||||
i18n: LocalizerType
|
||||
): string {
|
||||
switch (address.type) {
|
||||
case AddressType.CUSTOM:
|
||||
return address.label || i18n('address');
|
||||
|
@ -104,7 +107,7 @@ export class ContactDetail extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderEmail(items: Array<Email> | undefined, i18n: Localizer) {
|
||||
public renderEmail(items: Array<Email> | undefined, i18n: LocalizerType) {
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -124,7 +127,7 @@ export class ContactDetail extends React.Component<Props> {
|
|||
});
|
||||
}
|
||||
|
||||
public renderPhone(items: Array<Phone> | undefined, i18n: Localizer) {
|
||||
public renderPhone(items: Array<Phone> | undefined, i18n: LocalizerType) {
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -152,7 +155,7 @@ export class ContactDetail extends React.Component<Props> {
|
|||
return <div>{value}</div>;
|
||||
}
|
||||
|
||||
public renderPOBox(poBox: string | undefined, i18n: Localizer) {
|
||||
public renderPOBox(poBox: string | undefined, i18n: LocalizerType) {
|
||||
if (!poBox) {
|
||||
return null;
|
||||
}
|
||||
|
@ -178,7 +181,7 @@ export class ContactDetail extends React.Component<Props> {
|
|||
|
||||
public renderAddresses(
|
||||
addresses: Array<PostalAddress> | undefined,
|
||||
i18n: Localizer
|
||||
i18n: LocalizerType
|
||||
) {
|
||||
if (!addresses || addresses.length === 0) {
|
||||
return;
|
||||
|
|
|
@ -2,13 +2,13 @@ import React from 'react';
|
|||
|
||||
import { Emojify } from './Emojify';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
phoneNumber: string;
|
||||
name?: string;
|
||||
profileName?: string;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
module?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
import { Emojify } from './Emojify';
|
||||
import { Avatar } from '../Avatar';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
|
@ -15,12 +15,8 @@ interface TimerOption {
|
|||
value: number;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
isVerified: boolean;
|
||||
name?: string;
|
||||
id: string;
|
||||
|
@ -46,24 +42,19 @@ interface Props {
|
|||
}
|
||||
|
||||
export class ConversationHeader extends React.Component<Props> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public menuTriggerRef: Trigger | null;
|
||||
public menuTriggerRef: React.RefObject<any>;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
||||
this.menuTriggerRef = React.createRef();
|
||||
this.showMenuBound = this.showMenu.bind(this);
|
||||
this.menuTriggerRef = null;
|
||||
}
|
||||
|
||||
public captureMenuTrigger(triggerRef: Trigger) {
|
||||
this.menuTriggerRef = triggerRef;
|
||||
}
|
||||
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (this.menuTriggerRef) {
|
||||
this.menuTriggerRef.handleContextClick(event);
|
||||
if (this.menuTriggerRef.current) {
|
||||
this.menuTriggerRef.current.handleContextClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -134,12 +125,14 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
profileName,
|
||||
} = this.props;
|
||||
|
||||
const conversationType = isGroup ? 'group' : 'direct';
|
||||
|
||||
return (
|
||||
<span className="module-conversation-header__avatar">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={color}
|
||||
conversationType={isGroup ? 'group' : 'direct'}
|
||||
conversationType={conversationType}
|
||||
i18n={i18n}
|
||||
noteToSelf={isMe}
|
||||
name={name}
|
||||
|
@ -176,7 +169,7 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
}
|
||||
|
||||
return (
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
||||
<ContextMenuTrigger id={triggerId} ref={this.menuTriggerRef}>
|
||||
<div
|
||||
role="button"
|
||||
onClick={this.showMenuBound}
|
||||
|
@ -186,7 +179,6 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
/* tslint:disable:jsx-no-lambda react-this-binding-issue */
|
||||
public renderMenu(triggerId: string) {
|
||||
const {
|
||||
i18n,
|
||||
|
@ -235,10 +227,10 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
/* tslint:enable */
|
||||
|
||||
public render() {
|
||||
const { id } = this.props;
|
||||
const triggerId = `conversation-${id}`;
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header">
|
||||
|
@ -250,8 +242,8 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
</div>
|
||||
</div>
|
||||
{this.renderExpirationLength()}
|
||||
{this.renderGear(id)}
|
||||
{this.renderMenu(id)}
|
||||
{this.renderGear(triggerId)}
|
||||
{this.renderMenu(triggerId)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Avatar } from '../Avatar';
|
||||
import { Spinner } from '../Spinner';
|
||||
import { Contact, getName } from '../../types/Contact';
|
||||
import { Contact } from '../../types/Contact';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import {
|
||||
renderAvatar,
|
||||
renderContactShorthand,
|
||||
renderName,
|
||||
} from './_contactUtil';
|
||||
|
||||
interface Props {
|
||||
contact: Contact;
|
||||
hasSignalAccount: boolean;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
isIncoming: boolean;
|
||||
withContentAbove: boolean;
|
||||
withContentBelow: boolean;
|
||||
|
@ -53,88 +56,3 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: putting these below the main component so style guide picks up EmbeddedContact
|
||||
|
||||
export function renderAvatar({
|
||||
contact,
|
||||
i18n,
|
||||
size,
|
||||
direction,
|
||||
}: {
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
size: number;
|
||||
direction?: string;
|
||||
}) {
|
||||
const { avatar } = contact;
|
||||
|
||||
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
||||
const pending = avatar && avatar.avatar && avatar.avatar.pending;
|
||||
const name = getName(contact) || '';
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className="module-embedded-contact__spinner-container">
|
||||
<Spinner small={size < 50} direction={direction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color="grey"
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderName({
|
||||
contact,
|
||||
isIncoming,
|
||||
module,
|
||||
}: {
|
||||
contact: Contact;
|
||||
isIncoming: boolean;
|
||||
module: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`module-${module}__contact-name`,
|
||||
isIncoming ? `module-${module}__contact-name--incoming` : null
|
||||
)}
|
||||
>
|
||||
{getName(contact)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderContactShorthand({
|
||||
contact,
|
||||
isIncoming,
|
||||
module,
|
||||
}: {
|
||||
contact: Contact;
|
||||
isIncoming: boolean;
|
||||
module: string;
|
||||
}) {
|
||||
const { number: phoneNumber, email } = contact;
|
||||
const firstNumber = phoneNumber && phoneNumber[0] && phoneNumber[0].value;
|
||||
const firstEmail = email && email[0] && email[0].value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`module-${module}__contact-method`,
|
||||
isIncoming ? `module-${module}__contact-method--incoming` : null
|
||||
)}
|
||||
>
|
||||
{firstNumber || firstEmail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
SizeClassType,
|
||||
} from '../../util/emoji';
|
||||
|
||||
import { Localizer, RenderTextCallback } from '../../types/Util';
|
||||
import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
|
||||
|
||||
// Some of this logic taken from emoji-js/replacement
|
||||
function getImageTag({
|
||||
|
@ -23,7 +23,7 @@ function getImageTag({
|
|||
match: any;
|
||||
sizeClass?: SizeClassType;
|
||||
key: string | number;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}) {
|
||||
const result = getReplacementData(match[0], match[1], match[2]);
|
||||
|
||||
|
@ -54,8 +54,8 @@ interface Props {
|
|||
/** A class name to be added to the generated emoji images */
|
||||
sizeClass?: SizeClassType;
|
||||
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
|
||||
renderNonEmoji?: RenderTextCallback;
|
||||
i18n: Localizer;
|
||||
renderNonEmoji?: RenderTextCallbackType;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class Emojify extends React.Component<Props> {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { padStart } from 'lodash';
|
||||
import { getIncrement, getTimerBucket } from '../../util/timer';
|
||||
|
||||
interface Props {
|
||||
withImageNoCaption: boolean;
|
||||
|
@ -62,25 +62,3 @@ export class ExpireTimer extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIncrement(length: number): number {
|
||||
if (length < 0) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
return Math.ceil(length / 12);
|
||||
}
|
||||
|
||||
function getTimerBucket(expiration: number, length: number): string {
|
||||
const delta = expiration - Date.now();
|
||||
if (delta < 0) {
|
||||
return '00';
|
||||
}
|
||||
if (delta > length) {
|
||||
return '60';
|
||||
}
|
||||
|
||||
const bucket = Math.round(delta / length * 12);
|
||||
|
||||
return padStart(String(bucket * 5), 2, '0');
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { compact, flatten } from 'lodash';
|
|||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
|
@ -23,7 +23,7 @@ interface Change {
|
|||
|
||||
interface Props {
|
||||
changes: Array<Change>;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class GroupNotification extends React.Component<Props> {
|
||||
|
@ -61,15 +61,10 @@ export class GroupNotification extends React.Component<Props> {
|
|||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={
|
||||
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'
|
||||
}
|
||||
components={[people]}
|
||||
/>
|
||||
);
|
||||
const joinKey =
|
||||
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup';
|
||||
|
||||
return <Intl i18n={i18n} id={joinKey} components={[people]} />;
|
||||
case 'remove':
|
||||
if (isMe) {
|
||||
return i18n('youLeftTheGroup');
|
||||
|
@ -79,13 +74,10 @@ export class GroupNotification extends React.Component<Props> {
|
|||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'}
|
||||
components={[people]}
|
||||
/>
|
||||
);
|
||||
const leftKey =
|
||||
contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup';
|
||||
|
||||
return <Intl i18n={i18n} id={leftKey} components={[people]} />;
|
||||
case 'general':
|
||||
return i18n('updatedTheGroup');
|
||||
default:
|
||||
|
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import { Spinner } from '../Spinner';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { AttachmentType } from './types';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { AttachmentType } from '../../types/Attachment';
|
||||
|
||||
interface Props {
|
||||
alt: string;
|
||||
|
@ -28,7 +28,7 @@ interface Props {
|
|||
playIconOverlay?: boolean;
|
||||
softCorners?: boolean;
|
||||
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
onClick?: (attachment: AttachmentType) => void;
|
||||
onClickClose?: (attachment: AttachmentType) => void;
|
||||
onError?: () => void;
|
||||
|
@ -62,10 +62,11 @@ export class Image extends React.Component<Props> {
|
|||
|
||||
const { caption, pending } = attachment || { caption: null, pending: true };
|
||||
const canClick = onClick && !pending;
|
||||
const role = canClick ? 'button' : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
role={canClick ? 'button' : undefined}
|
||||
role={role}
|
||||
onClick={() => {
|
||||
if (canClick && onClick) {
|
||||
onClick(attachment);
|
||||
|
|
|
@ -2,12 +2,18 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../../util/GoogleChrome';
|
||||
import { AttachmentType } from './types';
|
||||
areAllAttachmentsVisual,
|
||||
AttachmentType,
|
||||
getAlt,
|
||||
getImageDimensions,
|
||||
getThumbnailUrl,
|
||||
getUrl,
|
||||
isVideoAttachment,
|
||||
} from '../../types/Attachment';
|
||||
|
||||
import { Image } from './Image';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
attachments: Array<AttachmentType>;
|
||||
|
@ -15,17 +21,12 @@ interface Props {
|
|||
withContentBelow?: boolean;
|
||||
bottomOverlay?: boolean;
|
||||
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
|
||||
onError: () => void;
|
||||
onClickAttachment?: (attachment: AttachmentType) => void;
|
||||
}
|
||||
|
||||
const MAX_WIDTH = 300;
|
||||
const MAX_HEIGHT = MAX_WIDTH * 1.5;
|
||||
const MIN_WIDTH = 200;
|
||||
const MIN_HEIGHT = 50;
|
||||
|
||||
export class ImageGrid extends React.Component<Props> {
|
||||
// tslint:disable-next-line max-func-body-length */
|
||||
public render() {
|
||||
|
@ -46,6 +47,8 @@ export class ImageGrid extends React.Component<Props> {
|
|||
const curveBottomLeft = curveBottom;
|
||||
const curveBottomRight = curveBottom;
|
||||
|
||||
const withBottomOverlay = Boolean(bottomOverlay && curveBottom);
|
||||
|
||||
if (!attachments || !attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
@ -63,7 +66,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[0], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveTopRight={curveTopRight}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
|
@ -87,7 +90,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
alt={getAlt(attachments[0], i18n)}
|
||||
i18n={i18n}
|
||||
attachment={attachments[0]}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
playIconOverlay={isVideoAttachment(attachments[0])}
|
||||
|
@ -100,7 +103,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[1], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveTopRight={curveTopRight}
|
||||
curveBottomRight={curveBottomRight}
|
||||
playIconOverlay={isVideoAttachment(attachments[1])}
|
||||
|
@ -121,7 +124,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[0], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveTopLeft={curveTopLeft}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
attachment={attachments[0]}
|
||||
|
@ -148,7 +151,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[2], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveBottomRight={curveBottomRight}
|
||||
height={99}
|
||||
width={99}
|
||||
|
@ -197,7 +200,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[2], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
playIconOverlay={isVideoAttachment(attachments[2])}
|
||||
height={149}
|
||||
|
@ -210,7 +213,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[3], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveBottomRight={curveBottomRight}
|
||||
playIconOverlay={isVideoAttachment(attachments[3])}
|
||||
height={149}
|
||||
|
@ -226,6 +229,11 @@ export class ImageGrid extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
const moreMessagesOverlay = attachments.length > 5;
|
||||
const moreMessagesOverlayText = moreMessagesOverlay
|
||||
? `+${attachments.length - 5}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="module-image-grid">
|
||||
<div className="module-image-grid__column">
|
||||
|
@ -259,7 +267,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[2], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveBottomLeft={curveBottomLeft}
|
||||
playIconOverlay={isVideoAttachment(attachments[2])}
|
||||
height={99}
|
||||
|
@ -272,7 +280,7 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[3], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
playIconOverlay={isVideoAttachment(attachments[3])}
|
||||
height={99}
|
||||
width={98}
|
||||
|
@ -284,17 +292,13 @@ export class ImageGrid extends React.Component<Props> {
|
|||
<Image
|
||||
alt={getAlt(attachments[4], i18n)}
|
||||
i18n={i18n}
|
||||
bottomOverlay={bottomOverlay && curveBottom}
|
||||
bottomOverlay={withBottomOverlay}
|
||||
curveBottomRight={curveBottomRight}
|
||||
playIconOverlay={isVideoAttachment(attachments[4])}
|
||||
height={99}
|
||||
width={99}
|
||||
darkOverlay={attachments.length > 5}
|
||||
overlayText={
|
||||
attachments.length > 5
|
||||
? `+${attachments.length - 5}`
|
||||
: undefined
|
||||
}
|
||||
darkOverlay={moreMessagesOverlay}
|
||||
overlayText={moreMessagesOverlayText}
|
||||
attachment={attachments[4]}
|
||||
url={getThumbnailUrl(attachments[4])}
|
||||
onClick={onClickAttachment}
|
||||
|
@ -306,148 +310,3 @@ export class ImageGrid extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getThumbnailUrl(attachment: AttachmentType) {
|
||||
if (attachment.thumbnail) {
|
||||
return attachment.thumbnail.url;
|
||||
}
|
||||
|
||||
return getUrl(attachment);
|
||||
}
|
||||
|
||||
function getUrl(attachment: AttachmentType) {
|
||||
if (attachment.screenshot) {
|
||||
return attachment.screenshot.url;
|
||||
}
|
||||
|
||||
return attachment.url;
|
||||
}
|
||||
|
||||
export function isImage(attachments?: Array<AttachmentType>) {
|
||||
return (
|
||||
attachments &&
|
||||
attachments[0] &&
|
||||
attachments[0].contentType &&
|
||||
isImageTypeSupported(attachments[0].contentType)
|
||||
);
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: AttachmentType) {
|
||||
return (
|
||||
attachment &&
|
||||
attachment.contentType &&
|
||||
isImageTypeSupported(attachment.contentType)
|
||||
);
|
||||
}
|
||||
export function hasImage(attachments?: Array<AttachmentType>) {
|
||||
return (
|
||||
attachments &&
|
||||
attachments[0] &&
|
||||
(attachments[0].url || attachments[0].pending)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVideo(attachments?: Array<AttachmentType>) {
|
||||
return attachments && isVideoAttachment(attachments[0]);
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment?: AttachmentType) {
|
||||
return (
|
||||
attachment &&
|
||||
attachment.contentType &&
|
||||
isVideoTypeSupported(attachment.contentType)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasVideoScreenshot(attachments?: Array<AttachmentType>) {
|
||||
const firstAttachment = attachments ? attachments[0] : null;
|
||||
|
||||
return (
|
||||
firstAttachment &&
|
||||
firstAttachment.screenshot &&
|
||||
firstAttachment.screenshot.url
|
||||
);
|
||||
}
|
||||
|
||||
type DimensionsType = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export function getImageDimensions(attachment: AttachmentType): DimensionsType {
|
||||
const { height, width } = attachment;
|
||||
if (!height || !width) {
|
||||
return {
|
||||
height: MIN_HEIGHT,
|
||||
width: MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
const aspectRatio = height / width;
|
||||
const targetWidth = Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH);
|
||||
const candidateHeight = Math.round(targetWidth * aspectRatio);
|
||||
|
||||
return {
|
||||
width: targetWidth,
|
||||
height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
export function areAllAttachmentsVisual(
|
||||
attachments?: Array<AttachmentType>
|
||||
): boolean {
|
||||
if (!attachments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const max = attachments.length;
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const attachment = attachments[i];
|
||||
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getGridDimensions(
|
||||
attachments?: Array<AttachmentType>
|
||||
): null | DimensionsType {
|
||||
if (!attachments || !attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isImage(attachments) && !isVideo(attachments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attachments.length === 1) {
|
||||
return getImageDimensions(attachments[0]);
|
||||
}
|
||||
|
||||
if (attachments.length === 2) {
|
||||
return {
|
||||
height: 150,
|
||||
width: 300,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments.length === 4) {
|
||||
return {
|
||||
height: 300,
|
||||
width: 300,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
height: 200,
|
||||
width: 300,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAlt(attachment: AttachmentType, i18n: Localizer): string {
|
||||
return isVideoAttachment(attachment)
|
||||
? i18n('videoAttachmentAlt')
|
||||
: i18n('imageAttachmentAlt');
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
|
||||
import LinkifyIt from 'linkify-it';
|
||||
|
||||
import { RenderTextCallback } from '../../types/Util';
|
||||
import { RenderTextCallbackType } from '../../types/Util';
|
||||
import { isLinkSneaky } from '../../../js/modules/link_previews';
|
||||
|
||||
const linkify = LinkifyIt();
|
||||
|
@ -10,7 +10,7 @@ const linkify = LinkifyIt();
|
|||
interface Props {
|
||||
text: string;
|
||||
/** Allows you to customize now non-links are rendered. Simplest is just a <span>. */
|
||||
renderNonLink?: RenderTextCallback;
|
||||
renderNonLink?: RenderTextCallbackType;
|
||||
}
|
||||
|
||||
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
|
||||
|
|
|
@ -4,28 +4,32 @@ import classNames from 'classnames';
|
|||
import { Avatar } from '../Avatar';
|
||||
import { Spinner } from '../Spinner';
|
||||
import { MessageBody } from './MessageBody';
|
||||
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
||||
import {
|
||||
getGridDimensions,
|
||||
getImageDimensions,
|
||||
hasImage,
|
||||
hasVideoScreenshot,
|
||||
ImageGrid,
|
||||
isImage,
|
||||
isImageAttachment,
|
||||
isVideo,
|
||||
} from './ImageGrid';
|
||||
import { ExpireTimer } from './ExpireTimer';
|
||||
import { ImageGrid } from './ImageGrid';
|
||||
import { Image } from './Image';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { ContactName } from './ContactName';
|
||||
import { Quote, QuotedAttachmentType } from './Quote';
|
||||
import { EmbeddedContact } from './EmbeddedContact';
|
||||
import * as MIME from '../../../ts/types/MIME';
|
||||
|
||||
import { AttachmentType } from './types';
|
||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||
import {
|
||||
canDisplayImage,
|
||||
getExtensionForDisplay,
|
||||
getGridDimensions,
|
||||
getImageDimensions,
|
||||
hasImage,
|
||||
hasVideoScreenshot,
|
||||
isAudio,
|
||||
isImage,
|
||||
isImageAttachment,
|
||||
isVideo,
|
||||
} from '../../../ts/types/Attachment';
|
||||
import { AttachmentType } from '../../types/Attachment';
|
||||
import { Contact } from '../../types/Contact';
|
||||
import { Color, Localizer } from '../../types/Util';
|
||||
|
||||
import { getIncrement } from '../../util/timer';
|
||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||
import { ColorType, LocalizerType } from '../../types/Util';
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
||||
|
||||
interface Trigger {
|
||||
|
@ -56,12 +60,12 @@ export interface Props {
|
|||
onSendMessage?: () => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
authorName?: string;
|
||||
authorProfileName?: string;
|
||||
/** Note: this should be formatted for display */
|
||||
authorPhoneNumber: string;
|
||||
authorColor?: Color;
|
||||
authorColor?: ColorType;
|
||||
conversationType: 'group' | 'direct';
|
||||
attachments?: Array<AttachmentType>;
|
||||
quote?: {
|
||||
|
@ -71,7 +75,7 @@ export interface Props {
|
|||
authorPhoneNumber: string;
|
||||
authorProfileName?: string;
|
||||
authorName?: string;
|
||||
authorColor?: Color;
|
||||
authorColor?: ColorType;
|
||||
onClick?: () => void;
|
||||
referencedMessageNotFound: boolean;
|
||||
};
|
||||
|
@ -98,12 +102,12 @@ interface State {
|
|||
const EXPIRATION_CHECK_MINIMUM = 2000;
|
||||
const EXPIRED_DELAY = 600;
|
||||
|
||||
export class Message extends React.Component<Props, State> {
|
||||
export class Message extends React.PureComponent<Props, State> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public handleImageErrorBound: () => void;
|
||||
|
||||
public menuTriggerRef: Trigger | null;
|
||||
public menuTriggerRef: Trigger | undefined;
|
||||
public expirationCheckInterval: any;
|
||||
public expiredTimeout: any;
|
||||
|
||||
|
@ -114,10 +118,6 @@ export class Message extends React.Component<Props, State> {
|
|||
this.showMenuBound = this.showMenu.bind(this);
|
||||
this.handleImageErrorBound = this.handleImageError.bind(this);
|
||||
|
||||
this.menuTriggerRef = null;
|
||||
this.expirationCheckInterval = null;
|
||||
this.expiredTimeout = null;
|
||||
|
||||
this.state = {
|
||||
expiring: false,
|
||||
expired: false,
|
||||
|
@ -366,7 +366,7 @@ export class Message extends React.Component<Props, State> {
|
|||
);
|
||||
} else {
|
||||
const { pending, fileName, fileSize, contentType } = firstAttachment;
|
||||
const extension = getExtension({ contentType, fileName });
|
||||
const extension = getExtensionForDisplay({ contentType, fileName });
|
||||
const isDangerous = isFileDangerous(fileName || '');
|
||||
|
||||
return (
|
||||
|
@ -851,7 +851,7 @@ export class Message extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public getWidth(): Number | undefined {
|
||||
public getWidth(): number | undefined {
|
||||
const { attachments, previews } = this.props;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
|
@ -976,53 +976,3 @@ export class Message extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slash = contentType.indexOf('/');
|
||||
if (slash >= 0) {
|
||||
return contentType.slice(slash + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAudio(attachments?: Array<AttachmentType>) {
|
||||
return (
|
||||
attachments &&
|
||||
attachments[0] &&
|
||||
attachments[0].contentType &&
|
||||
MIME.isAudio(attachments[0].contentType)
|
||||
);
|
||||
}
|
||||
|
||||
function canDisplayImage(attachments?: Array<AttachmentType>) {
|
||||
const { height, width } =
|
||||
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
|
||||
|
||||
return (
|
||||
height &&
|
||||
height > 0 &&
|
||||
height <= 4096 &&
|
||||
width &&
|
||||
width > 0 &&
|
||||
width <= 4096
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Emojify } from './Emojify';
|
|||
import { AddNewLines } from './AddNewLines';
|
||||
import { Linkify } from './Linkify';
|
||||
|
||||
import { Localizer, RenderTextCallback } from '../../types/Util';
|
||||
import { LocalizerType, RenderTextCallbackType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
|
@ -13,10 +13,10 @@ interface Props {
|
|||
disableJumbomoji?: boolean;
|
||||
/** If set, links will be left alone instead of turned into clickable `<a>` tags. */
|
||||
disableLinks?: boolean;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
const renderNewLines: RenderTextCallback = ({
|
||||
const renderNewLines: RenderTextCallbackType = ({
|
||||
text: textWithNewLines,
|
||||
key,
|
||||
}) => <AddNewLines key={key} text={textWithNewLines} />;
|
||||
|
@ -28,11 +28,11 @@ const renderEmoji = ({
|
|||
sizeClass,
|
||||
renderNonEmoji,
|
||||
}: {
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
text: string;
|
||||
key: number;
|
||||
sizeClass?: SizeClassType;
|
||||
renderNonEmoji: RenderTextCallback;
|
||||
renderNonEmoji: RenderTextCallbackType;
|
||||
}) => (
|
||||
<Emojify
|
||||
i18n={i18n}
|
||||
|
|
|
@ -5,7 +5,7 @@ import moment from 'moment';
|
|||
import { Avatar } from '../Avatar';
|
||||
import { ContactName } from './ContactName';
|
||||
import { Message, Props as MessageProps } from './Message';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Contact {
|
||||
status: string;
|
||||
|
@ -31,7 +31,7 @@ interface Props {
|
|||
errors: Array<Error>;
|
||||
contacts: Array<Contact>;
|
||||
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class MessageDetail extends React.Component<Props> {
|
||||
|
|
|
@ -7,7 +7,7 @@ import * as MIME from '../../../ts/types/MIME';
|
|||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
||||
|
||||
import { MessageBody } from './MessageBody';
|
||||
import { Color, Localizer } from '../../types/Util';
|
||||
import { ColorType, LocalizerType } from '../../types/Util';
|
||||
import { ContactName } from './ContactName';
|
||||
|
||||
interface Props {
|
||||
|
@ -15,8 +15,8 @@ interface Props {
|
|||
authorPhoneNumber: string;
|
||||
authorProfileName?: string;
|
||||
authorName?: string;
|
||||
authorColor?: Color;
|
||||
i18n: Localizer;
|
||||
authorColor?: ColorType;
|
||||
i18n: LocalizerType;
|
||||
isFromMe: boolean;
|
||||
isIncoming: boolean;
|
||||
withContentAbove: boolean;
|
||||
|
@ -56,12 +56,12 @@ function validateQuote(quote: Props): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function getObjectUrl(thumbnail: Attachment | undefined): string | null {
|
||||
function getObjectUrl(thumbnail: Attachment | undefined): string | undefined {
|
||||
if (thumbnail && thumbnail.objectUrl) {
|
||||
return thumbnail.objectUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
function getTypeLabel({
|
||||
|
@ -69,10 +69,10 @@ function getTypeLabel({
|
|||
contentType,
|
||||
isVoiceMessage,
|
||||
}: {
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
contentType: MIME.MIMEType;
|
||||
isVoiceMessage: boolean;
|
||||
}): string | null {
|
||||
}): string | undefined {
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
return i18n('video');
|
||||
}
|
||||
|
@ -86,7 +86,7 @@ function getTypeLabel({
|
|||
return i18n('audio');
|
||||
}
|
||||
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
export class Quote extends React.Component<Props, State> {
|
||||
|
@ -110,7 +110,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
});
|
||||
}
|
||||
|
||||
public renderImage(url: string, i18n: Localizer, icon?: string) {
|
||||
public renderImage(url: string, i18n: LocalizerType, icon?: string) {
|
||||
const iconElement = icon ? (
|
||||
<div className="module-quote__icon-container__inner">
|
||||
<div className="module-quote__icon-container__circle-background">
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class ResetSessionNotification extends React.Component<Props> {
|
||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
|
@ -14,20 +14,23 @@ interface Contact {
|
|||
interface Props {
|
||||
isGroup: boolean;
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
onVerify: () => void;
|
||||
}
|
||||
|
||||
export class SafetyNumberNotification extends React.Component<Props> {
|
||||
public render() {
|
||||
const { contact, isGroup, i18n, onVerify } = this.props;
|
||||
const changeKey = isGroup
|
||||
? 'safetyNumberChangedGroup'
|
||||
: 'safetyNumberChanged';
|
||||
|
||||
return (
|
||||
<div className="module-safety-number-notification">
|
||||
<div className="module-safety-number-notification__icon" />
|
||||
<div className="module-safety-number-notification__text">
|
||||
<Intl
|
||||
id={isGroup ? 'safetyNumberChangedGroup' : 'safetyNumberChanged'}
|
||||
id={changeKey}
|
||||
components={[
|
||||
<span
|
||||
key="external-1"
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getExtension } from './Message';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { AttachmentType } from './types';
|
||||
import { AttachmentType, getExtensionForDisplay } from '../../types/Attachment';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
attachment: AttachmentType;
|
||||
onClose: (attachment: AttachmentType) => void;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class StagedGenericAttachment extends React.Component<Props> {
|
||||
public render() {
|
||||
const { attachment, onClose } = this.props;
|
||||
const { fileName, contentType } = attachment;
|
||||
const extension = getExtension({ contentType, fileName });
|
||||
const extension = getExtensionForDisplay({ contentType, fileName });
|
||||
|
||||
return (
|
||||
<div className="module-staged-generic-attachment">
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { isImageAttachment } from './ImageGrid';
|
||||
import { Image } from './Image';
|
||||
import { AttachmentType } from './types';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { AttachmentType, isImageAttachment } from '../../types/Attachment';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
isLoaded: boolean;
|
||||
|
@ -13,7 +12,7 @@ interface Props {
|
|||
domain: string;
|
||||
image?: AttachmentType;
|
||||
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import classNames from 'classnames';
|
|||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
|
@ -14,7 +14,7 @@ interface Props {
|
|||
name?: string;
|
||||
disabled: boolean;
|
||||
timespan: string;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class TimerNotification extends React.Component<Props> {
|
||||
|
@ -28,15 +28,16 @@ export class TimerNotification extends React.Component<Props> {
|
|||
type,
|
||||
disabled,
|
||||
} = this.props;
|
||||
const changeKey = disabled
|
||||
? 'disabledDisappearingMessages'
|
||||
: 'theyChangedTheTimer';
|
||||
|
||||
switch (type) {
|
||||
case 'fromOther':
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={
|
||||
disabled ? 'disabledDisappearingMessages' : 'theyChangedTheTimer'
|
||||
}
|
||||
id={changeKey}
|
||||
components={[
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
|
|
|
@ -4,15 +4,15 @@ import moment from 'moment';
|
|||
|
||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
timestamp: number | null;
|
||||
extended: boolean;
|
||||
timestamp?: number;
|
||||
extended?: boolean;
|
||||
module?: string;
|
||||
withImageNoCaption?: boolean;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
const UPDATE_FREQUENCY = 60 * 1000;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import classNames from 'classnames';
|
|||
import { TypingAnimation } from './TypingAnimation';
|
||||
import { Avatar } from '../Avatar';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
avatarPath?: string;
|
||||
|
@ -13,7 +13,7 @@ interface Props {
|
|||
phoneNumber: string;
|
||||
profileName: string;
|
||||
conversationType: string;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class TypingBubble extends React.Component<Props> {
|
||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
|
@ -17,7 +17,7 @@ interface Props {
|
|||
type: 'markVerified' | 'markNotVerified';
|
||||
isLocal: boolean;
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
export class VerificationNotification extends React.Component<Props> {
|
||||
|
|
93
ts/components/conversation/_contactUtil.tsx
Normal file
93
ts/components/conversation/_contactUtil.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Avatar } from '../Avatar';
|
||||
import { Spinner } from '../Spinner';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { Contact, getName } from '../../types/Contact';
|
||||
|
||||
// This file starts with _ to keep it from showing up in the StyleGuide.
|
||||
|
||||
export function renderAvatar({
|
||||
contact,
|
||||
i18n,
|
||||
size,
|
||||
direction,
|
||||
}: {
|
||||
contact: Contact;
|
||||
i18n: LocalizerType;
|
||||
size: number;
|
||||
direction?: string;
|
||||
}) {
|
||||
const { avatar } = contact;
|
||||
|
||||
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
||||
const pending = avatar && avatar.avatar && avatar.avatar.pending;
|
||||
const name = getName(contact) || '';
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<div className="module-embedded-contact__spinner-container">
|
||||
<Spinner small={size < 50} direction={direction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color="grey"
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderName({
|
||||
contact,
|
||||
isIncoming,
|
||||
module,
|
||||
}: {
|
||||
contact: Contact;
|
||||
isIncoming: boolean;
|
||||
module: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`module-${module}__contact-name`,
|
||||
isIncoming ? `module-${module}__contact-name--incoming` : null
|
||||
)}
|
||||
>
|
||||
{getName(contact)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderContactShorthand({
|
||||
contact,
|
||||
isIncoming,
|
||||
module,
|
||||
}: {
|
||||
contact: Contact;
|
||||
isIncoming: boolean;
|
||||
module: string;
|
||||
}) {
|
||||
const { number: phoneNumber, email } = contact;
|
||||
const firstNumber = phoneNumber && phoneNumber[0] && phoneNumber[0].value;
|
||||
const firstEmail = email && email[0] && email[0].value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`module-${module}__contact-method`,
|
||||
isIncoming ? `module-${module}__contact-method--incoming` : null
|
||||
)}
|
||||
>
|
||||
{firstNumber || firstEmail}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -5,10 +5,10 @@ import { ItemClickEvent } from './types/ItemClickEvent';
|
|||
import { MediaGridItem } from './MediaGridItem';
|
||||
import { MediaItemType } from '../../LightboxGallery';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import { Localizer } from '../../../types/Util';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
header?: string;
|
||||
type: 'media' | 'documents';
|
||||
mediaItems: Array<MediaItemType>;
|
||||
|
@ -64,7 +64,7 @@ export class AttachmentSection extends React.Component<Props> {
|
|||
});
|
||||
}
|
||||
|
||||
private createClickHandler = (mediaItem: MediaItemType) => () => {
|
||||
private readonly createClickHandler = (mediaItem: MediaItemType) => () => {
|
||||
const { onItemClick, type } = this.props;
|
||||
const { message, attachment } = mediaItem;
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ interface Props {
|
|||
timestamp: number;
|
||||
|
||||
// Optional
|
||||
fileName?: string | null;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
onClick?: () => void;
|
||||
shouldShowSeparator?: boolean;
|
||||
|
|
|
@ -8,13 +8,13 @@ import { EmptyState } from './EmptyState';
|
|||
import { groupMediaItemsByDate } from './groupMediaItemsByDate';
|
||||
import { ItemClickEvent } from './types/ItemClickEvent';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import { Localizer } from '../../../types/Util';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
|
||||
import { MediaItemType } from '../../LightboxGallery';
|
||||
|
||||
interface Props {
|
||||
documents: Array<MediaItemType>;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
media: Array<MediaItemType>;
|
||||
onItemClick?: (event: ItemClickEvent) => void;
|
||||
}
|
||||
|
@ -91,7 +91,7 @@ export class MediaGallery extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private handleTabSelect = (event: TabSelectEvent): void => {
|
||||
private readonly handleTabSelect = (event: TabSelectEvent): void => {
|
||||
this.setState({ selectedTab: event.type });
|
||||
};
|
||||
|
||||
|
|
|
@ -5,13 +5,13 @@ import {
|
|||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../../../util/GoogleChrome';
|
||||
import { Localizer } from '../../../types/Util';
|
||||
import { LocalizerType } from '../../../types/Util';
|
||||
import { MediaItemType } from '../../LightboxGallery';
|
||||
|
||||
interface Props {
|
||||
mediaItem: MediaItemType;
|
||||
onClick?: () => void;
|
||||
i18n: Localizer;
|
||||
i18n: LocalizerType;
|
||||
}
|
||||
|
||||
interface State {
|
||||
|
@ -19,7 +19,7 @@ interface State {
|
|||
}
|
||||
|
||||
export class MediaGridItem extends React.Component<Props, State> {
|
||||
private onImageErrorBound: () => void;
|
||||
private readonly onImageErrorBound: () => void;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
|
|
@ -48,15 +48,15 @@ export const groupMediaItemsByDate = (
|
|||
|
||||
const toSection = (
|
||||
messagesWithSection: Array<MediaItemWithSection> | undefined
|
||||
): Section | null => {
|
||||
): Section | undefined => {
|
||||
if (!messagesWithSection || messagesWithSection.length === 0) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const firstMediaItemWithSection: MediaItemWithSection =
|
||||
messagesWithSection[0];
|
||||
if (!firstMediaItemWithSection) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaItems = messagesWithSection.map(
|
||||
|
@ -83,7 +83,7 @@ const toSection = (
|
|||
// error TS2345: Argument of type 'any' is not assignable to parameter
|
||||
// of type 'never'.
|
||||
// return missingCaseError(firstMediaItemWithSection.type);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AttachmentType } from '../../types';
|
||||
import { AttachmentType } from '../../../../types/Attachment';
|
||||
import { Message } from './Message';
|
||||
|
||||
export interface ItemClickEvent {
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
import { MIMEType } from '../../../ts/types/MIME';
|
||||
|
||||
export interface AttachmentType {
|
||||
caption?: string;
|
||||
contentType: 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;
|
||||
size?: number;
|
||||
fileSize?: string;
|
||||
pending?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
screenshot?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIMEType;
|
||||
};
|
||||
thumbnail?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIMEType;
|
||||
};
|
||||
}
|
4
ts/shims/Whisper.ts
Normal file
4
ts/shims/Whisper.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export function getMessageModel(attributes: any) {
|
||||
// @ts-ignore
|
||||
return new window.Whisper.Message(attributes);
|
||||
}
|
4
ts/shims/events.ts
Normal file
4
ts/shims/events.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export function trigger(name: string, param1?: any, param2?: any) {
|
||||
// @ts-ignore
|
||||
window.Whisper.events.trigger(name, param1, param2);
|
||||
}
|
15
ts/state/actions.ts
Normal file
15
ts/state/actions.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
|
||||
import { actions as search } from './ducks/search';
|
||||
import { actions as conversations } from './ducks/conversations';
|
||||
import { actions as user } from './ducks/user';
|
||||
|
||||
const actions = {
|
||||
...search,
|
||||
...conversations,
|
||||
...user,
|
||||
};
|
||||
|
||||
export function mapDispatchToProps(dispatch: Dispatch): Object {
|
||||
return bindActionCreators(actions, dispatch);
|
||||
}
|
33
ts/state/createStore.ts
Normal file
33
ts/state/createStore.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { applyMiddleware, createStore as reduxCreateStore } from 'redux';
|
||||
|
||||
import promise from 'redux-promise-middleware';
|
||||
import { createLogger } from 'redux-logger';
|
||||
|
||||
import { reducer } from './reducer';
|
||||
|
||||
// @ts-ignore
|
||||
const env = window.getEnvironment();
|
||||
|
||||
// So Redux logging doesn't go to disk, and so we can get colors/styles
|
||||
const directConsole = {
|
||||
// @ts-ignore
|
||||
log: console._log,
|
||||
groupCollapsed: console.groupCollapsed,
|
||||
group: console.group,
|
||||
groupEnd: console.groupEnd,
|
||||
warn: console.warn,
|
||||
// tslint:disable-next-line no-console
|
||||
error: console.error,
|
||||
};
|
||||
|
||||
const logger = createLogger({
|
||||
logger: directConsole,
|
||||
});
|
||||
|
||||
// Exclude logger if we're in production mode
|
||||
const middlewareList = env === 'production' ? [promise] : [promise, logger];
|
||||
|
||||
const enhancer = applyMiddleware.apply(null, middlewareList);
|
||||
|
||||
export const createStore = (initialState: any) =>
|
||||
reduxCreateStore(reducer, initialState, enhancer);
|
273
ts/state/ducks/conversations.ts
Normal file
273
ts/state/ducks/conversations.ts
Normal file
|
@ -0,0 +1,273 @@
|
|||
import { omit } from 'lodash';
|
||||
|
||||
import { trigger } from '../../shims/events';
|
||||
import { NoopActionType } from './noop';
|
||||
|
||||
// State
|
||||
|
||||
export type MessageType = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
receivedAt: number;
|
||||
|
||||
snippet: string;
|
||||
|
||||
from: {
|
||||
phoneNumber: string;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
color?: string;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
};
|
||||
|
||||
to: {
|
||||
groupName?: string;
|
||||
phoneNumber: string;
|
||||
isMe?: boolean;
|
||||
name?: string;
|
||||
profileName?: string;
|
||||
};
|
||||
|
||||
isSelected?: boolean;
|
||||
};
|
||||
export type ConversationType = {
|
||||
id: string;
|
||||
name?: string;
|
||||
activeAt?: number;
|
||||
timestamp: number;
|
||||
lastMessage?: {
|
||||
status: 'error' | 'sending' | 'sent' | 'delivered' | 'read';
|
||||
text: string;
|
||||
};
|
||||
phoneNumber: string;
|
||||
type: 'direct' | 'group';
|
||||
isMe: boolean;
|
||||
lastUpdated: number;
|
||||
unreadCount: number;
|
||||
isSelected: boolean;
|
||||
isTyping: boolean;
|
||||
};
|
||||
export type ConversationLookupType = {
|
||||
[key: string]: ConversationType;
|
||||
};
|
||||
|
||||
export type ConversationsStateType = {
|
||||
conversationLookup: ConversationLookupType;
|
||||
selectedConversation?: string;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
type ConversationAddedActionType = {
|
||||
type: 'CONVERSATION_ADDED';
|
||||
payload: {
|
||||
id: string;
|
||||
data: ConversationType;
|
||||
};
|
||||
};
|
||||
type ConversationChangedActionType = {
|
||||
type: 'CONVERSATION_CHANGED';
|
||||
payload: {
|
||||
id: string;
|
||||
data: ConversationType;
|
||||
};
|
||||
};
|
||||
type ConversationRemovedActionType = {
|
||||
type: 'CONVERSATION_REMOVED';
|
||||
payload: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
export type RemoveAllConversationsActionType = {
|
||||
type: 'CONVERSATIONS_REMOVE_ALL';
|
||||
payload: null;
|
||||
};
|
||||
export type MessageExpiredActionType = {
|
||||
type: 'MESSAGE_EXPIRED';
|
||||
payload: {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
};
|
||||
};
|
||||
export type SelectedConversationChangedActionType = {
|
||||
type: 'SELECTED_CONVERSATION_CHANGED';
|
||||
payload: {
|
||||
id: string;
|
||||
messageId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConversationActionType =
|
||||
| ConversationAddedActionType
|
||||
| ConversationChangedActionType
|
||||
| ConversationRemovedActionType
|
||||
| RemoveAllConversationsActionType
|
||||
| MessageExpiredActionType
|
||||
| SelectedConversationChangedActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
conversationAdded,
|
||||
conversationChanged,
|
||||
conversationRemoved,
|
||||
removeAllConversations,
|
||||
messageExpired,
|
||||
openConversationInternal,
|
||||
openConversationExternal,
|
||||
};
|
||||
|
||||
function conversationAdded(
|
||||
id: string,
|
||||
data: ConversationType
|
||||
): ConversationAddedActionType {
|
||||
return {
|
||||
type: 'CONVERSATION_ADDED',
|
||||
payload: {
|
||||
id,
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
function conversationChanged(
|
||||
id: string,
|
||||
data: ConversationType
|
||||
): ConversationChangedActionType {
|
||||
return {
|
||||
type: 'CONVERSATION_CHANGED',
|
||||
payload: {
|
||||
id,
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
function conversationRemoved(id: string): ConversationRemovedActionType {
|
||||
return {
|
||||
type: 'CONVERSATION_REMOVED',
|
||||
payload: {
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
function removeAllConversations(): RemoveAllConversationsActionType {
|
||||
return {
|
||||
type: 'CONVERSATIONS_REMOVE_ALL',
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
function messageExpired(
|
||||
id: string,
|
||||
conversationId: string
|
||||
): MessageExpiredActionType {
|
||||
return {
|
||||
type: 'MESSAGE_EXPIRED',
|
||||
payload: {
|
||||
id,
|
||||
conversationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Note: we need two actions here to simplify. Operations outside of the left pane can
|
||||
// trigger an 'openConversation' so we go through Whisper.events for all conversation
|
||||
// selection.
|
||||
function openConversationInternal(
|
||||
id: string,
|
||||
messageId?: string
|
||||
): NoopActionType {
|
||||
trigger('showConversation', id, messageId);
|
||||
|
||||
return {
|
||||
type: 'NOOP',
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
function openConversationExternal(
|
||||
id: string,
|
||||
messageId?: string
|
||||
): SelectedConversationChangedActionType {
|
||||
return {
|
||||
type: 'SELECTED_CONVERSATION_CHANGED',
|
||||
payload: {
|
||||
id,
|
||||
messageId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
function getEmptyState(): ConversationsStateType {
|
||||
return {
|
||||
conversationLookup: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: ConversationsStateType,
|
||||
action: ConversationActionType
|
||||
): ConversationsStateType {
|
||||
if (!state) {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
if (action.type === 'CONVERSATION_ADDED') {
|
||||
const { payload } = action;
|
||||
const { id, data } = payload;
|
||||
const { conversationLookup } = state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
conversationLookup: {
|
||||
...conversationLookup,
|
||||
[id]: data,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
|
||||
const { payload } = action;
|
||||
const { id } = payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedConversation: id,
|
||||
};
|
||||
}
|
||||
if (action.type === 'CONVERSATION_CHANGED') {
|
||||
const { payload } = action;
|
||||
const { id, data } = payload;
|
||||
const { conversationLookup } = state;
|
||||
|
||||
// In the change case we only modify the lookup if we already had that conversation
|
||||
if (!conversationLookup[id]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
conversationLookup: {
|
||||
...conversationLookup,
|
||||
[id]: data,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === 'CONVERSATION_REMOVED') {
|
||||
const { payload } = action;
|
||||
const { id } = payload;
|
||||
const { conversationLookup } = state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
conversationLookup: omit(conversationLookup, [id]),
|
||||
};
|
||||
}
|
||||
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
|
||||
return getEmptyState();
|
||||
}
|
||||
if (action.type === 'MESSAGE_EXPIRED') {
|
||||
// noop - for now this is only important for search
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
4
ts/state/ducks/noop.ts
Normal file
4
ts/state/ducks/noop.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type NoopActionType = {
|
||||
type: 'NOOP';
|
||||
payload: null;
|
||||
};
|
291
ts/state/ducks/search.ts
Normal file
291
ts/state/ducks/search.ts
Normal file
|
@ -0,0 +1,291 @@
|
|||
import { omit, reject } from 'lodash';
|
||||
|
||||
import { normalize } from '../../types/PhoneNumber';
|
||||
import { trigger } from '../../shims/events';
|
||||
import { getMessageModel } from '../../shims/Whisper';
|
||||
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
|
||||
import { searchConversations, searchMessages } from '../../../js/modules/data';
|
||||
import { makeLookup } from '../../util/makeLookup';
|
||||
|
||||
import {
|
||||
ConversationType,
|
||||
MessageExpiredActionType,
|
||||
MessageType,
|
||||
RemoveAllConversationsActionType,
|
||||
SelectedConversationChangedActionType,
|
||||
} from './conversations';
|
||||
|
||||
// State
|
||||
|
||||
export type SearchStateType = {
|
||||
query: string;
|
||||
normalizedPhoneNumber?: string;
|
||||
// We need to store messages here, because they aren't anywhere else in state
|
||||
messages: Array<MessageType>;
|
||||
selectedMessage?: string;
|
||||
messageLookup: {
|
||||
[key: string]: MessageType;
|
||||
};
|
||||
// For conversations we store just the id, and pull conversation props in the selector
|
||||
conversations: Array<string>;
|
||||
contacts: Array<string>;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
type SearchResultsPayloadType = {
|
||||
query: string;
|
||||
normalizedPhoneNumber?: string;
|
||||
messages: Array<MessageType>;
|
||||
conversations: Array<string>;
|
||||
contacts: Array<string>;
|
||||
};
|
||||
|
||||
type SearchResultsKickoffActionType = {
|
||||
type: 'SEARCH_RESULTS';
|
||||
payload: Promise<SearchResultsPayloadType>;
|
||||
};
|
||||
type SearchResultsFulfilledActionType = {
|
||||
type: 'SEARCH_RESULTS_FULFILLED';
|
||||
payload: SearchResultsPayloadType;
|
||||
};
|
||||
type UpdateSearchTermActionType = {
|
||||
type: 'SEARCH_UPDATE';
|
||||
payload: {
|
||||
query: string;
|
||||
};
|
||||
};
|
||||
type ClearSearchActionType = {
|
||||
type: 'SEARCH_CLEAR';
|
||||
payload: null;
|
||||
};
|
||||
|
||||
export type SEARCH_TYPES =
|
||||
| SearchResultsFulfilledActionType
|
||||
| UpdateSearchTermActionType
|
||||
| ClearSearchActionType
|
||||
| MessageExpiredActionType
|
||||
| RemoveAllConversationsActionType
|
||||
| SelectedConversationChangedActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
search,
|
||||
clearSearch,
|
||||
updateSearchTerm,
|
||||
startNewConversation,
|
||||
};
|
||||
|
||||
function search(
|
||||
query: string,
|
||||
options: { regionCode: string; ourNumber: string; noteToSelf: string }
|
||||
): SearchResultsKickoffActionType {
|
||||
return {
|
||||
type: 'SEARCH_RESULTS',
|
||||
payload: doSearch(query, options),
|
||||
};
|
||||
}
|
||||
|
||||
async function doSearch(
|
||||
query: string,
|
||||
options: {
|
||||
regionCode: string;
|
||||
ourNumber: string;
|
||||
noteToSelf: string;
|
||||
}
|
||||
): Promise<SearchResultsPayloadType> {
|
||||
const { regionCode, ourNumber, noteToSelf } = options;
|
||||
|
||||
const [discussions, messages] = await Promise.all([
|
||||
queryConversationsAndContacts(query, { ourNumber, noteToSelf }),
|
||||
queryMessages(query),
|
||||
]);
|
||||
const { conversations, contacts } = discussions;
|
||||
|
||||
return {
|
||||
query,
|
||||
normalizedPhoneNumber: normalize(query, { regionCode }),
|
||||
conversations,
|
||||
contacts,
|
||||
messages: getMessageProps(messages) || [],
|
||||
};
|
||||
}
|
||||
function clearSearch(): ClearSearchActionType {
|
||||
return {
|
||||
type: 'SEARCH_CLEAR',
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
function updateSearchTerm(query: string): UpdateSearchTermActionType {
|
||||
return {
|
||||
type: 'SEARCH_UPDATE',
|
||||
payload: {
|
||||
query,
|
||||
},
|
||||
};
|
||||
}
|
||||
function startNewConversation(
|
||||
query: string,
|
||||
options: { regionCode: string }
|
||||
): ClearSearchActionType {
|
||||
const { regionCode } = options;
|
||||
const normalized = normalize(query, { regionCode });
|
||||
if (!normalized) {
|
||||
throw new Error('Attempted to start new conversation with invalid number');
|
||||
}
|
||||
trigger('showConversation', normalized);
|
||||
|
||||
return {
|
||||
type: 'SEARCH_CLEAR',
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions for search
|
||||
|
||||
const getMessageProps = (messages: Array<MessageType>) => {
|
||||
if (!messages || !messages.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return messages.map(message => {
|
||||
const model = getMessageModel(message);
|
||||
|
||||
return model.propsForSearchResult;
|
||||
});
|
||||
};
|
||||
|
||||
async function queryMessages(query: string) {
|
||||
try {
|
||||
const normalized = cleanSearchTerm(query);
|
||||
|
||||
return searchMessages(normalized);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function queryConversationsAndContacts(
|
||||
providedQuery: string,
|
||||
options: { ourNumber: string; noteToSelf: string }
|
||||
) {
|
||||
const { ourNumber, noteToSelf } = options;
|
||||
const query = providedQuery.replace(/[+-.()]*/g, '');
|
||||
|
||||
const searchResults: Array<ConversationType> = await searchConversations(
|
||||
query
|
||||
);
|
||||
|
||||
// Split into two groups - active conversations and items just from address book
|
||||
let conversations: Array<string> = [];
|
||||
let contacts: Array<string> = [];
|
||||
const max = searchResults.length;
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const conversation = searchResults[i];
|
||||
|
||||
if (conversation.type === 'direct' && !Boolean(conversation.lastMessage)) {
|
||||
contacts.push(conversation.id);
|
||||
} else {
|
||||
conversations.push(conversation.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject synthetic Note to Self entry if query matches localized 'Note to Self'
|
||||
if (noteToSelf.indexOf(providedQuery.toLowerCase()) !== -1) {
|
||||
// ensure that we don't have duplicates in our results
|
||||
contacts = contacts.filter(id => id !== ourNumber);
|
||||
conversations = conversations.filter(id => id !== ourNumber);
|
||||
|
||||
contacts.unshift(ourNumber);
|
||||
}
|
||||
|
||||
return { conversations, contacts };
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
function getEmptyState(): SearchStateType {
|
||||
return {
|
||||
query: '',
|
||||
messages: [],
|
||||
messageLookup: {},
|
||||
conversations: [],
|
||||
contacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: SearchStateType | undefined,
|
||||
action: SEARCH_TYPES
|
||||
): SearchStateType {
|
||||
if (!state) {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
if (action.type === 'SEARCH_CLEAR') {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
if (action.type === 'SEARCH_UPDATE') {
|
||||
const { payload } = action;
|
||||
const { query } = payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'SEARCH_RESULTS_FULFILLED') {
|
||||
const { payload } = action;
|
||||
const { query, messages } = payload;
|
||||
|
||||
// Reject if the associated query is not the most recent user-provided query
|
||||
if (state.query !== query) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...payload,
|
||||
messageLookup: makeLookup(messages, 'id'),
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'CONVERSATIONS_REMOVE_ALL') {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
if (action.type === 'SELECTED_CONVERSATION_CHANGED') {
|
||||
const { payload } = action;
|
||||
const { messageId } = payload;
|
||||
|
||||
if (!messageId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
selectedMessage: messageId,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'MESSAGE_EXPIRED') {
|
||||
const { messages, messageLookup } = state;
|
||||
if (!messages.length) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { payload } = action;
|
||||
const { id } = payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages: reject(messages, message => id === message.id),
|
||||
messageLookup: omit(messageLookup, ['id']),
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
67
ts/state/ducks/user.ts
Normal file
67
ts/state/ducks/user.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
// State
|
||||
|
||||
export type UserStateType = {
|
||||
ourNumber: string;
|
||||
regionCode: string;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
type UserChangedActionType = {
|
||||
type: 'USER_CHANGED';
|
||||
payload: {
|
||||
ourNumber: string;
|
||||
regionCode: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserActionType = UserChangedActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
userChanged,
|
||||
};
|
||||
|
||||
function userChanged(attributes: {
|
||||
ourNumber: string;
|
||||
regionCode: string;
|
||||
}): UserChangedActionType {
|
||||
return {
|
||||
type: 'USER_CHANGED',
|
||||
payload: attributes,
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
function getEmptyState(): UserStateType {
|
||||
return {
|
||||
ourNumber: 'missing',
|
||||
regionCode: 'missing',
|
||||
i18n: () => 'missing',
|
||||
};
|
||||
}
|
||||
|
||||
export function reducer(
|
||||
state: UserStateType,
|
||||
action: UserActionType
|
||||
): UserStateType {
|
||||
if (!state) {
|
||||
return getEmptyState();
|
||||
}
|
||||
|
||||
if (action.type === 'USER_CHANGED') {
|
||||
const { payload } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
25
ts/state/reducer.ts
Normal file
25
ts/state/reducer.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { combineReducers } from 'redux';
|
||||
|
||||
import { reducer as search, SearchStateType } from './ducks/search';
|
||||
import {
|
||||
ConversationsStateType,
|
||||
reducer as conversations,
|
||||
} from './ducks/conversations';
|
||||
import { reducer as user, UserStateType } from './ducks/user';
|
||||
|
||||
export type StateType = {
|
||||
search: SearchStateType;
|
||||
conversations: ConversationsStateType;
|
||||
user: UserStateType;
|
||||
};
|
||||
|
||||
export const reducers = {
|
||||
search,
|
||||
conversations,
|
||||
user,
|
||||
};
|
||||
|
||||
// Making this work would require that our reducer signature supported AnyAction, not
|
||||
// our restricted actions
|
||||
// @ts-ignore
|
||||
export const reducer = combineReducers(reducers);
|
16
ts/state/roots/createLeftPane.tsx
Normal file
16
ts/state/roots/createLeftPane.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { Store } from 'redux';
|
||||
|
||||
import { SmartLeftPane } from '../smart/LeftPane';
|
||||
|
||||
// Workaround: A react component's required properties are filtering up through connect()
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||
const FilteredLeftPane = SmartLeftPane as any;
|
||||
|
||||
export const createLeftPane = (store: Store) => (
|
||||
<Provider store={store}>
|
||||
<FilteredLeftPane />
|
||||
</Provider>
|
||||
);
|
124
ts/state/selectors/conversations.ts
Normal file
124
ts/state/selectors/conversations.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { compact } from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
import { format } from '../../types/PhoneNumber';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { StateType } from '../reducer';
|
||||
import {
|
||||
ConversationLookupType,
|
||||
ConversationsStateType,
|
||||
ConversationType,
|
||||
} from '../ducks/conversations';
|
||||
|
||||
import { getIntl, getRegionCode, getUserNumber } from './user';
|
||||
|
||||
export const getConversations = (state: StateType): ConversationsStateType =>
|
||||
state.conversations;
|
||||
|
||||
export const getConversationLookup = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): ConversationLookupType => {
|
||||
return state.conversationLookup;
|
||||
}
|
||||
);
|
||||
|
||||
export const getSelectedConversation = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): string | undefined => {
|
||||
return state.selectedConversation;
|
||||
}
|
||||
);
|
||||
|
||||
function getConversationTitle(
|
||||
conversation: ConversationType,
|
||||
options: { i18n: LocalizerType; ourRegionCode: string }
|
||||
): string {
|
||||
if (conversation.name) {
|
||||
return conversation.name;
|
||||
}
|
||||
|
||||
if (conversation.type === 'group') {
|
||||
const { i18n } = options;
|
||||
|
||||
return i18n('unknownGroup');
|
||||
}
|
||||
|
||||
return format(conversation.phoneNumber, options);
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator();
|
||||
|
||||
export const _getConversationComparator = (
|
||||
i18n: LocalizerType,
|
||||
ourRegionCode: string
|
||||
) => {
|
||||
return (left: ConversationType, right: ConversationType): number => {
|
||||
const leftTimestamp = left.timestamp;
|
||||
const rightTimestamp = right.timestamp;
|
||||
if (leftTimestamp && !rightTimestamp) {
|
||||
return -1;
|
||||
}
|
||||
if (rightTimestamp && !leftTimestamp) {
|
||||
return 1;
|
||||
}
|
||||
if (leftTimestamp && rightTimestamp && leftTimestamp !== rightTimestamp) {
|
||||
return rightTimestamp - leftTimestamp;
|
||||
}
|
||||
|
||||
const leftTitle = getConversationTitle(left, {
|
||||
i18n,
|
||||
ourRegionCode,
|
||||
}).toLowerCase();
|
||||
const rightTitle = getConversationTitle(right, {
|
||||
i18n,
|
||||
ourRegionCode,
|
||||
}).toLowerCase();
|
||||
|
||||
return collator.compare(leftTitle, rightTitle);
|
||||
};
|
||||
};
|
||||
export const getConversationComparator = createSelector(
|
||||
getIntl,
|
||||
getRegionCode,
|
||||
_getConversationComparator
|
||||
);
|
||||
|
||||
export const _getLeftPaneList = (
|
||||
lookup: ConversationLookupType,
|
||||
comparator: (left: ConversationType, right: ConversationType) => number,
|
||||
selectedConversation?: string
|
||||
): Array<ConversationType> => {
|
||||
const values = Object.values(lookup);
|
||||
const filtered = compact(
|
||||
values.map(conversation => {
|
||||
if (!conversation.activeAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedConversation === conversation.id) {
|
||||
return {
|
||||
...conversation,
|
||||
isSelected: true,
|
||||
};
|
||||
}
|
||||
|
||||
return conversation;
|
||||
})
|
||||
);
|
||||
|
||||
return filtered.sort(comparator);
|
||||
};
|
||||
|
||||
export const getLeftPaneList = createSelector(
|
||||
getConversationLookup,
|
||||
getConversationComparator,
|
||||
getSelectedConversation,
|
||||
_getLeftPaneList
|
||||
);
|
||||
|
||||
export const getMe = createSelector(
|
||||
[getConversationLookup, getUserNumber],
|
||||
(lookup: ConversationLookupType, ourNumber: string): ConversationType => {
|
||||
return lookup[ourNumber];
|
||||
}
|
||||
);
|
93
ts/state/selectors/search.ts
Normal file
93
ts/state/selectors/search.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { compact } from 'lodash';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { StateType } from '../reducer';
|
||||
import { SearchStateType } from '../ducks/search';
|
||||
|
||||
import {
|
||||
getConversationLookup,
|
||||
getSelectedConversation,
|
||||
} from './conversations';
|
||||
import { ConversationLookupType } from '../ducks/conversations';
|
||||
|
||||
export const getSearch = (state: StateType): SearchStateType => state.search;
|
||||
|
||||
export const getQuery = createSelector(
|
||||
getSearch,
|
||||
(state: SearchStateType): string => state.query
|
||||
);
|
||||
|
||||
export const getSelectedMessage = createSelector(
|
||||
getSearch,
|
||||
(state: SearchStateType): string | undefined => state.selectedMessage
|
||||
);
|
||||
|
||||
export const isSearching = createSelector(
|
||||
getSearch,
|
||||
(state: SearchStateType) => {
|
||||
const { query } = state;
|
||||
|
||||
return query && query.trim().length > 1;
|
||||
}
|
||||
);
|
||||
|
||||
export const getSearchResults = createSelector(
|
||||
[
|
||||
getSearch,
|
||||
getConversationLookup,
|
||||
getSelectedConversation,
|
||||
getSelectedMessage,
|
||||
],
|
||||
(
|
||||
state: SearchStateType,
|
||||
lookup: ConversationLookupType,
|
||||
selectedConversation?: string,
|
||||
selectedMessage?: string
|
||||
) => {
|
||||
return {
|
||||
contacts: compact(
|
||||
state.contacts.map(id => {
|
||||
const value = lookup[id];
|
||||
|
||||
if (value && id === selectedConversation) {
|
||||
return {
|
||||
...value,
|
||||
isSelected: true,
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
),
|
||||
conversations: compact(
|
||||
state.conversations.map(id => {
|
||||
const value = lookup[id];
|
||||
|
||||
if (value && id === selectedConversation) {
|
||||
return {
|
||||
...value,
|
||||
isSelected: true,
|
||||
};
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
),
|
||||
hideMessagesHeader: false,
|
||||
messages: state.messages.map(message => {
|
||||
if (message.id === selectedMessage) {
|
||||
return {
|
||||
...message,
|
||||
isSelected: true,
|
||||
};
|
||||
}
|
||||
|
||||
return message;
|
||||
}),
|
||||
searchTerm: state.query,
|
||||
showStartNewConversation: Boolean(
|
||||
state.normalizedPhoneNumber && !lookup[state.normalizedPhoneNumber]
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
23
ts/state/selectors/user.ts
Normal file
23
ts/state/selectors/user.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { createSelector } from 'reselect';
|
||||
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
|
||||
import { StateType } from '../reducer';
|
||||
import { UserStateType } from '../ducks/user';
|
||||
|
||||
export const getUser = (state: StateType): UserStateType => state.user;
|
||||
|
||||
export const getUserNumber = createSelector(
|
||||
getUser,
|
||||
(state: UserStateType): string => state.ourNumber
|
||||
);
|
||||
|
||||
export const getRegionCode = createSelector(
|
||||
getUser,
|
||||
(state: UserStateType): string => state.regionCode
|
||||
);
|
||||
|
||||
export const getIntl = createSelector(
|
||||
getUser,
|
||||
(state: UserStateType): LocalizerType => state.i18n
|
||||
);
|
32
ts/state/smart/LeftPane.tsx
Normal file
32
ts/state/smart/LeftPane.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import { LeftPane } from '../../components/LeftPane';
|
||||
import { StateType } from '../reducer';
|
||||
|
||||
import { getQuery, getSearchResults, isSearching } from '../selectors/search';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getLeftPaneList, getMe } from '../selectors/conversations';
|
||||
|
||||
import { SmartMainHeader } from './MainHeader';
|
||||
|
||||
// Workaround: A react component's required properties are filtering up through connect()
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||
const FilteredSmartMainHeader = SmartMainHeader as any;
|
||||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
const showSearch = isSearching(state);
|
||||
|
||||
return {
|
||||
i18n: getIntl(state),
|
||||
me: getMe(state),
|
||||
query: getQuery(state),
|
||||
conversations: showSearch ? undefined : getLeftPaneList(state),
|
||||
searchResults: showSearch ? getSearchResults(state) : undefined,
|
||||
renderMainHeader: () => <FilteredSmartMainHeader />,
|
||||
};
|
||||
};
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartLeftPane = smart(LeftPane);
|
23
ts/state/smart/MainHeader.tsx
Normal file
23
ts/state/smart/MainHeader.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
|
||||
import { MainHeader } from '../../components/MainHeader';
|
||||
import { StateType } from '../reducer';
|
||||
|
||||
import { getQuery } from '../selectors/search';
|
||||
import { getIntl, getRegionCode, getUserNumber } from '../selectors/user';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
|
||||
const mapStateToProps = (state: StateType) => {
|
||||
return {
|
||||
searchTerm: getQuery(state),
|
||||
regionCode: getRegionCode(state),
|
||||
ourNumber: getUserNumber(state),
|
||||
...getMe(state),
|
||||
i18n: getIntl(state),
|
||||
};
|
||||
};
|
||||
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartMainHeader = smart(MainHeader);
|
23
ts/state/smart/MessageSearchResult.tsx
Normal file
23
ts/state/smart/MessageSearchResult.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
|
||||
import { StateType } from '../reducer';
|
||||
|
||||
import { MessageSearchResult } from '../../components/MessageSearchResult';
|
||||
|
||||
type SmartProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function mapStateToProps(state: StateType, ourProps: SmartProps) {
|
||||
const { id } = ourProps;
|
||||
const lookup = state.search && state.search.messageLookup;
|
||||
if (!lookup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return lookup[id];
|
||||
}
|
||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export const SmartMessageSearchResult = smart(MessageSearchResult);
|
|
@ -6,6 +6,7 @@ interface Props {
|
|||
* Corresponds to the theme setting in the app, and the class added to the root element.
|
||||
*/
|
||||
theme: 'light-theme' | 'dark-theme';
|
||||
style: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -14,10 +15,10 @@ interface Props {
|
|||
*/
|
||||
export class LeftPaneContext extends React.Component<Props> {
|
||||
public render() {
|
||||
const { theme } = this.props;
|
||||
const { style, theme } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames(theme || 'light-theme')}>
|
||||
<div style={style} className={classNames(theme || 'light-theme')}>
|
||||
<div className="gutter">{this.props.children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -113,7 +113,10 @@ _.noConflict();
|
|||
|
||||
// @ts-ignore
|
||||
window.log = {
|
||||
// tslint:disable-next-line no-console
|
||||
info: console.log,
|
||||
error: console.log,
|
||||
war: console.log,
|
||||
// tslint:disable-next-line no-console
|
||||
error: console.error,
|
||||
// tslint:disable-next-line no-console
|
||||
warn: console.warn,
|
||||
};
|
||||
|
|
96
ts/test/state/selectors/conversations_test.ts
Normal file
96
ts/test/state/selectors/conversations_test.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { assert } from 'chai';
|
||||
|
||||
import { ConversationLookupType } from '../../../state/ducks/conversations';
|
||||
import {
|
||||
_getConversationComparator,
|
||||
_getLeftPaneList,
|
||||
} from '../../../state/selectors/conversations';
|
||||
|
||||
describe('state/selectors/conversations', () => {
|
||||
describe('#getLeftPaneList', () => {
|
||||
it('sorts conversations based on timestamp then by intl-friendly title', () => {
|
||||
const i18n = (key: string) => key;
|
||||
const regionCode = 'US';
|
||||
const conversations: ConversationLookupType = {
|
||||
id1: {
|
||||
id: 'id1',
|
||||
activeAt: Date.now(),
|
||||
name: 'No timestamp',
|
||||
timestamp: 0,
|
||||
phoneNumber: 'notused',
|
||||
|
||||
type: 'direct',
|
||||
isMe: false,
|
||||
lastUpdated: Date.now(),
|
||||
unreadCount: 1,
|
||||
isSelected: false,
|
||||
isTyping: false,
|
||||
},
|
||||
id2: {
|
||||
id: 'id2',
|
||||
activeAt: Date.now(),
|
||||
name: 'B',
|
||||
timestamp: 20,
|
||||
phoneNumber: 'notused',
|
||||
|
||||
type: 'direct',
|
||||
isMe: false,
|
||||
lastUpdated: Date.now(),
|
||||
unreadCount: 1,
|
||||
isSelected: false,
|
||||
isTyping: false,
|
||||
},
|
||||
id3: {
|
||||
id: 'id3',
|
||||
activeAt: Date.now(),
|
||||
name: 'C',
|
||||
timestamp: 20,
|
||||
phoneNumber: 'notused',
|
||||
|
||||
type: 'direct',
|
||||
isMe: false,
|
||||
lastUpdated: Date.now(),
|
||||
unreadCount: 1,
|
||||
isSelected: false,
|
||||
isTyping: false,
|
||||
},
|
||||
id4: {
|
||||
id: 'id4',
|
||||
activeAt: Date.now(),
|
||||
name: 'Á',
|
||||
timestamp: 20,
|
||||
phoneNumber: 'notused',
|
||||
|
||||
type: 'direct',
|
||||
isMe: false,
|
||||
lastUpdated: Date.now(),
|
||||
unreadCount: 1,
|
||||
isSelected: false,
|
||||
isTyping: false,
|
||||
},
|
||||
id5: {
|
||||
id: 'id5',
|
||||
activeAt: Date.now(),
|
||||
name: 'First!',
|
||||
timestamp: 30,
|
||||
phoneNumber: 'notused',
|
||||
|
||||
type: 'direct',
|
||||
isMe: false,
|
||||
lastUpdated: Date.now(),
|
||||
unreadCount: 1,
|
||||
isSelected: false,
|
||||
isTyping: false,
|
||||
},
|
||||
};
|
||||
const comparator = _getConversationComparator(i18n, regionCode);
|
||||
const list = _getLeftPaneList(conversations, comparator);
|
||||
|
||||
assert.strictEqual(list[0].name, 'First!');
|
||||
assert.strictEqual(list[1].name, 'Á');
|
||||
assert.strictEqual(list[2].name, 'B');
|
||||
assert.strictEqual(list[3].name, 'C');
|
||||
assert.strictEqual(list[4].name, 'No timestamp');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -162,7 +162,6 @@ describe('Attachment', () => {
|
|||
|
||||
it('should return true for legacy Android voice message attachment', () => {
|
||||
const attachment: Attachment.Attachment = {
|
||||
fileName: null,
|
||||
data: stringToArrayBuffer('voice message'),
|
||||
contentType: MIME.AUDIO_MP3,
|
||||
};
|
||||
|
|
|
@ -10,17 +10,9 @@ import {
|
|||
describe('Conversation', () => {
|
||||
describe('createLastMessageUpdate', () => {
|
||||
it('should reset last message if conversation has no messages', () => {
|
||||
const input = {
|
||||
currentLastMessageText: null,
|
||||
currentTimestamp: null,
|
||||
lastMessage: null,
|
||||
lastMessageStatus: null,
|
||||
lastMessageNotificationText: null,
|
||||
};
|
||||
const input = {};
|
||||
const expected = {
|
||||
lastMessage: '',
|
||||
lastMessageStatus: null,
|
||||
timestamp: null,
|
||||
};
|
||||
|
||||
const actual = Conversation.createLastMessageUpdate(input);
|
||||
|
@ -56,7 +48,6 @@ describe('Conversation', () => {
|
|||
const input = {
|
||||
currentLastMessageText: 'bingo',
|
||||
currentTimestamp: 555,
|
||||
lastMessageStatus: null,
|
||||
lastMessage: {
|
||||
type: 'verified-change',
|
||||
conversationId: 'foo',
|
||||
|
@ -67,7 +58,7 @@ describe('Conversation', () => {
|
|||
};
|
||||
const expected = {
|
||||
lastMessage: 'bingo',
|
||||
lastMessageStatus: null,
|
||||
lastMessageStatus: undefined,
|
||||
timestamp: 555,
|
||||
};
|
||||
|
||||
|
@ -81,7 +72,6 @@ describe('Conversation', () => {
|
|||
const input = {
|
||||
currentLastMessageText: 'I am expired',
|
||||
currentTimestamp: 555,
|
||||
lastMessageStatus: null,
|
||||
lastMessage: {
|
||||
type: 'incoming',
|
||||
conversationId: 'foo',
|
||||
|
@ -97,7 +87,7 @@ describe('Conversation', () => {
|
|||
};
|
||||
const expected = {
|
||||
lastMessage: 'Last message before expired',
|
||||
lastMessageStatus: null,
|
||||
lastMessageStatus: undefined,
|
||||
timestamp: 555,
|
||||
};
|
||||
|
||||
|
|
|
@ -6,9 +6,250 @@ import * as MIME from './MIME';
|
|||
import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL';
|
||||
import { saveURLAsFile } from '../util/saveURLAsFile';
|
||||
import { SignalService } from '../protobuf';
|
||||
import {
|
||||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../util/GoogleChrome';
|
||||
import { LocalizerType } from './Util';
|
||||
|
||||
const MAX_WIDTH = 300;
|
||||
const MAX_HEIGHT = MAX_WIDTH * 1.5;
|
||||
const MIN_WIDTH = 200;
|
||||
const MIN_HEIGHT = 50;
|
||||
|
||||
// Used for display
|
||||
|
||||
export interface AttachmentType {
|
||||
caption?: string;
|
||||
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;
|
||||
size?: number;
|
||||
fileSize?: string;
|
||||
pending?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
screenshot?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIME.MIMEType;
|
||||
};
|
||||
thumbnail?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIME.MIMEType;
|
||||
};
|
||||
}
|
||||
|
||||
// UI-focused functions
|
||||
|
||||
export function getExtensionForDisplay({
|
||||
fileName,
|
||||
contentType,
|
||||
}: {
|
||||
fileName: string;
|
||||
contentType: MIME.MIMEType;
|
||||
}): string | undefined {
|
||||
if (fileName && fileName.indexOf('.') >= 0) {
|
||||
const lastPeriod = fileName.lastIndexOf('.');
|
||||
const extension = fileName.slice(lastPeriod + 1);
|
||||
if (extension.length) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slash = contentType.indexOf('/');
|
||||
if (slash >= 0) {
|
||||
return contentType.slice(slash + 1);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export function isAudio(attachments?: Array<AttachmentType>) {
|
||||
return (
|
||||
attachments &&
|
||||
attachments[0] &&
|
||||
attachments[0].contentType &&
|
||||
MIME.isAudio(attachments[0].contentType)
|
||||
);
|
||||
}
|
||||
|
||||
export function canDisplayImage(attachments?: Array<AttachmentType>) {
|
||||
const { height, width } =
|
||||
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
|
||||
|
||||
return (
|
||||
height &&
|
||||
height > 0 &&
|
||||
height <= 4096 &&
|
||||
width &&
|
||||
width > 0 &&
|
||||
width <= 4096
|
||||
);
|
||||
}
|
||||
|
||||
export function getThumbnailUrl(attachment: AttachmentType) {
|
||||
if (attachment.thumbnail) {
|
||||
return attachment.thumbnail.url;
|
||||
}
|
||||
|
||||
return getUrl(attachment);
|
||||
}
|
||||
|
||||
export function getUrl(attachment: AttachmentType) {
|
||||
if (attachment.screenshot) {
|
||||
return attachment.screenshot.url;
|
||||
}
|
||||
|
||||
return attachment.url;
|
||||
}
|
||||
|
||||
export function isImage(attachments?: Array<AttachmentType>) {
|
||||
return (
|
||||
attachments &&
|
||||
attachments[0] &&
|
||||
attachments[0].contentType &&
|
||||
isImageTypeSupported(attachments[0].contentType)
|
||||
);
|
||||
}
|
||||
|
||||
export function isImageAttachment(attachment: AttachmentType) {
|
||||
return (
|
||||
attachment &&
|
||||
attachment.contentType &&
|
||||
isImageTypeSupported(attachment.contentType)
|
||||
);
|
||||
}
|
||||
export function hasImage(attachments?: Array<AttachmentType>) {
|
||||
return (
|
||||
attachments &&
|
||||
attachments[0] &&
|
||||
(attachments[0].url || attachments[0].pending)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVideo(attachments?: Array<AttachmentType>) {
|
||||
return attachments && isVideoAttachment(attachments[0]);
|
||||
}
|
||||
|
||||
export function isVideoAttachment(attachment?: AttachmentType) {
|
||||
return (
|
||||
attachment &&
|
||||
attachment.contentType &&
|
||||
isVideoTypeSupported(attachment.contentType)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasVideoScreenshot(attachments?: Array<AttachmentType>) {
|
||||
const firstAttachment = attachments ? attachments[0] : null;
|
||||
|
||||
return (
|
||||
firstAttachment &&
|
||||
firstAttachment.screenshot &&
|
||||
firstAttachment.screenshot.url
|
||||
);
|
||||
}
|
||||
|
||||
type DimensionsType = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export function getImageDimensions(attachment: AttachmentType): DimensionsType {
|
||||
const { height, width } = attachment;
|
||||
if (!height || !width) {
|
||||
return {
|
||||
height: MIN_HEIGHT,
|
||||
width: MIN_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
const aspectRatio = height / width;
|
||||
const targetWidth = Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH);
|
||||
const candidateHeight = Math.round(targetWidth * aspectRatio);
|
||||
|
||||
return {
|
||||
width: targetWidth,
|
||||
height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
export function areAllAttachmentsVisual(
|
||||
attachments?: Array<AttachmentType>
|
||||
): boolean {
|
||||
if (!attachments) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const max = attachments.length;
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const attachment = attachments[i];
|
||||
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getGridDimensions(
|
||||
attachments?: Array<AttachmentType>
|
||||
): null | DimensionsType {
|
||||
if (!attachments || !attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isImage(attachments) && !isVideo(attachments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attachments.length === 1) {
|
||||
return getImageDimensions(attachments[0]);
|
||||
}
|
||||
|
||||
if (attachments.length === 2) {
|
||||
return {
|
||||
height: 150,
|
||||
width: 300,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments.length === 4) {
|
||||
return {
|
||||
height: 300,
|
||||
width: 300,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
height: 200,
|
||||
width: 300,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAlt(
|
||||
attachment: AttachmentType,
|
||||
i18n: LocalizerType
|
||||
): string {
|
||||
return isVideoAttachment(attachment)
|
||||
? i18n('videoAttachmentAlt')
|
||||
: i18n('imageAttachmentAlt');
|
||||
}
|
||||
|
||||
// Migration-related attachment stuff
|
||||
|
||||
export type Attachment = {
|
||||
fileName?: string | null;
|
||||
fileName?: string;
|
||||
flags?: SignalService.AttachmentPointer.Flags;
|
||||
contentType?: MIME.MIMEType;
|
||||
size?: number;
|
||||
|
@ -72,7 +313,7 @@ export const isVoiceMessage = (attachment: Attachment): boolean => {
|
|||
const isLegacyAndroidVoiceMessage =
|
||||
!is.undefined(attachment.contentType) &&
|
||||
MIME.isAudio(attachment.contentType) &&
|
||||
attachment.fileName === null;
|
||||
!attachment.fileName;
|
||||
if (isLegacyAndroidVoiceMessage) {
|
||||
return true;
|
||||
}
|
||||
|
@ -131,9 +372,11 @@ export const getSuggestedFilename = ({
|
|||
return `${prefix}${suffix}${indexSuffix}${extension}`;
|
||||
};
|
||||
|
||||
export const getFileExtension = (attachment: Attachment): string | null => {
|
||||
export const getFileExtension = (
|
||||
attachment: Attachment
|
||||
): string | undefined => {
|
||||
if (!attachment.contentType) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (attachment.contentType) {
|
||||
|
|
|
@ -120,13 +120,13 @@ export function contactSelector(
|
|||
};
|
||||
}
|
||||
|
||||
export function getName(contact: Contact): string | null {
|
||||
export function getName(contact: Contact): string | undefined {
|
||||
const { name, organization } = contact;
|
||||
const displayName = (name && name.displayName) || null;
|
||||
const givenName = (name && name.givenName) || null;
|
||||
const familyName = (name && name.familyName) || null;
|
||||
const displayName = (name && name.displayName) || undefined;
|
||||
const givenName = (name && name.givenName) || undefined;
|
||||
const familyName = (name && name.familyName) || undefined;
|
||||
const backupName =
|
||||
(givenName && familyName && `${givenName} ${familyName}`) || null;
|
||||
(givenName && familyName && `${givenName} ${familyName}`) || undefined;
|
||||
|
||||
return displayName || organization || backupName || givenName || familyName;
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import { Message } from './Message';
|
||||
|
||||
interface ConversationLastMessageUpdate {
|
||||
lastMessage: string | null;
|
||||
lastMessageStatus: string | null;
|
||||
timestamp: number | null;
|
||||
lastMessage: string;
|
||||
lastMessageStatus?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export const createLastMessageUpdate = ({
|
||||
|
@ -13,26 +13,26 @@ export const createLastMessageUpdate = ({
|
|||
lastMessageStatus,
|
||||
lastMessageNotificationText,
|
||||
}: {
|
||||
currentLastMessageText: string | null;
|
||||
currentTimestamp: number | null;
|
||||
lastMessage: Message | null;
|
||||
lastMessageStatus: string | null;
|
||||
lastMessageNotificationText: string | null;
|
||||
currentLastMessageText?: string;
|
||||
currentTimestamp?: number;
|
||||
lastMessage?: Message;
|
||||
lastMessageStatus?: string;
|
||||
lastMessageNotificationText?: string;
|
||||
}): ConversationLastMessageUpdate => {
|
||||
if (lastMessage === null) {
|
||||
if (!lastMessage) {
|
||||
return {
|
||||
lastMessage: '',
|
||||
lastMessageStatus: null,
|
||||
timestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { type, expirationTimerUpdate } = lastMessage;
|
||||
const isVerifiedChangeMessage = type === 'verified-change';
|
||||
const isExpireTimerUpdateFromSync =
|
||||
expirationTimerUpdate && expirationTimerUpdate.fromSync;
|
||||
const shouldUpdateTimestamp =
|
||||
!isVerifiedChangeMessage && !isExpireTimerUpdateFromSync;
|
||||
const isExpireTimerUpdateFromSync = Boolean(
|
||||
expirationTimerUpdate && expirationTimerUpdate.fromSync
|
||||
);
|
||||
const shouldUpdateTimestamp = Boolean(
|
||||
!isVerifiedChangeMessage && !isExpireTimerUpdateFromSync
|
||||
);
|
||||
|
||||
const newTimestamp = shouldUpdateTimestamp
|
||||
? lastMessage.sent_at
|
||||
|
@ -44,7 +44,7 @@ export const createLastMessageUpdate = ({
|
|||
: currentLastMessageText;
|
||||
|
||||
return {
|
||||
lastMessage: newLastMessageText,
|
||||
lastMessage: newLastMessageText || '',
|
||||
lastMessageStatus,
|
||||
timestamp: newTimestamp,
|
||||
};
|
||||
|
|
|
@ -36,3 +36,21 @@ export function parse(
|
|||
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
export function normalize(
|
||||
phoneNumber: string,
|
||||
options: { regionCode: string }
|
||||
): string | undefined {
|
||||
const { regionCode } = options;
|
||||
try {
|
||||
const parsedNumber = instance.parse(phoneNumber, regionCode);
|
||||
|
||||
if (instance.isValidNumber(parsedNumber)) {
|
||||
return instance.format(parsedNumber, PhoneNumberFormat.E164);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
export type RenderTextCallback = (
|
||||
export type RenderTextCallbackType = (
|
||||
options: {
|
||||
text: string;
|
||||
key: number;
|
||||
}
|
||||
) => JSX.Element | string;
|
||||
|
||||
export type Localizer = (key: string, values?: Array<string>) => string;
|
||||
export type LocalizerType = (key: string, values?: Array<string>) => string;
|
||||
|
||||
export type Color =
|
||||
export type ColorType =
|
||||
| 'gray'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
|
|
24
ts/util/cleanSearchTerm.ts
Normal file
24
ts/util/cleanSearchTerm.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
export function cleanSearchTerm(searchTerm: string) {
|
||||
const lowercase = searchTerm.toLowerCase();
|
||||
const withoutSpecialCharacters = lowercase.replace(
|
||||
/([!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~])/g,
|
||||
' '
|
||||
);
|
||||
const whiteSpaceNormalized = withoutSpecialCharacters.replace(/\s+/g, ' ');
|
||||
const byToken = whiteSpaceNormalized.split(' ');
|
||||
const withoutSpecialTokens = byToken.filter(
|
||||
token =>
|
||||
token &&
|
||||
token !== 'and' &&
|
||||
token !== 'or' &&
|
||||
token !== 'not' &&
|
||||
token !== ')' &&
|
||||
token !== '(' &&
|
||||
token !== '+' &&
|
||||
token !== ',' &&
|
||||
token !== 'near'
|
||||
);
|
||||
const withWildcards = withoutSpecialTokens.map(token => `${token}*`);
|
||||
|
||||
return withWildcards.join(' ').trim();
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import moment from 'moment';
|
||||
import { Localizer } from '../types/Util';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
||||
const getExtendedFormats = (i18n: Localizer) => ({
|
||||
const getExtendedFormats = (i18n: LocalizerType) => ({
|
||||
y: 'lll',
|
||||
M: `${i18n('timestampFormat_M') || 'MMM D'} LT`,
|
||||
d: 'ddd LT',
|
||||
});
|
||||
const getShortFormats = (i18n: Localizer) => ({
|
||||
const getShortFormats = (i18n: LocalizerType) => ({
|
||||
y: 'll',
|
||||
M: i18n('timestampFormat_M') || 'MMM D',
|
||||
d: 'ddd',
|
||||
|
@ -28,7 +28,7 @@ function isYear(timestamp: moment.Moment) {
|
|||
|
||||
export function formatRelativeTime(
|
||||
rawTimestamp: number | Date,
|
||||
options: { extended: boolean; i18n: Localizer }
|
||||
options: { extended?: boolean; i18n: LocalizerType }
|
||||
) {
|
||||
const { extended, i18n } = options;
|
||||
|
||||
|
|
|
@ -5,16 +5,16 @@ function removeNonInitials(name: string) {
|
|||
return name.replace(BAD_CHARACTERS, '').replace(WHITESPACE, ' ');
|
||||
}
|
||||
|
||||
export function getInitials(name?: string): string | null {
|
||||
export function getInitials(name?: string): string | undefined {
|
||||
if (!name) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const cleaned = removeNonInitials(name);
|
||||
const parts = cleaned.split(' ');
|
||||
const initials = parts.map(part => part.trim()[0]);
|
||||
if (!initials.length) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return initials.slice(0, 2).join('');
|
||||
|
|
|
@ -3,11 +3,13 @@ import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
|
|||
import { isFileDangerous } from './isFileDangerous';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
import { migrateColor } from './migrateColor';
|
||||
import { makeLookup } from './makeLookup';
|
||||
|
||||
export {
|
||||
arrayBufferToObjectURL,
|
||||
GoogleChrome,
|
||||
isFileDangerous,
|
||||
makeLookup,
|
||||
migrateColor,
|
||||
missingCaseError,
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -73,6 +73,10 @@ const excludedFiles = [
|
|||
'^libtextsecure/test/*',
|
||||
'^test/*',
|
||||
|
||||
// Modules we trust
|
||||
'^node_modules/react/*',
|
||||
'^node_modules/react-dom/*',
|
||||
|
||||
// Modules used only in test/development scenarios
|
||||
'^node_modules/@types/*',
|
||||
'^node_modules/ajv/*',
|
||||
|
@ -226,6 +230,7 @@ forEach(allSourceFiles, file => {
|
|||
|
||||
const exception = exceptionsLookup[exceptionKey];
|
||||
if (exception && (!exception.line || exception.line === line)) {
|
||||
// tslint:disable-next-line no-dynamic-delete
|
||||
delete exceptionsLookup[exceptionKey];
|
||||
|
||||
return;
|
||||
|
|
|
@ -130,6 +130,7 @@
|
|||
"expression": "\\bcreateRef\\(",
|
||||
"reason": "Potential XSS",
|
||||
"excludedModules": [
|
||||
"node_modules/react/",
|
||||
"node_modules/react-dom",
|
||||
"node_modules/tslint-microsoft-contrib",
|
||||
"node_modules/react-error-overlay",
|
||||
|
|
|
@ -46,10 +46,10 @@ export const REASONS = [
|
|||
|
||||
export type RuleType = {
|
||||
name: string;
|
||||
expression: string | null;
|
||||
expression?: string;
|
||||
reason: string;
|
||||
regex: RegExp;
|
||||
excludedModules: Array<string> | null;
|
||||
excludedModules?: Array<string>;
|
||||
};
|
||||
|
||||
export type ExceptionType = {
|
||||
|
|
12
ts/util/makeLookup.ts
Normal file
12
ts/util/makeLookup.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { fromPairs, map } from 'lodash';
|
||||
|
||||
export function makeLookup<T>(
|
||||
items: Array<T>,
|
||||
key: string
|
||||
): { [key: string]: T } {
|
||||
// Yep, we can't index into item without knowing what it is. True. But we want to.
|
||||
// @ts-ignore
|
||||
const pairs = map(items, item => [item[key], item]);
|
||||
|
||||
return fromPairs(pairs);
|
||||
}
|
23
ts/util/timer.ts
Normal file
23
ts/util/timer.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { padStart } from 'lodash';
|
||||
|
||||
export function getIncrement(length: number): number {
|
||||
if (length < 0) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
return Math.ceil(length / 12);
|
||||
}
|
||||
|
||||
export function getTimerBucket(expiration: number, length: number): string {
|
||||
const delta = expiration - Date.now();
|
||||
if (delta < 0) {
|
||||
return '00';
|
||||
}
|
||||
if (delta > length) {
|
||||
return '60';
|
||||
}
|
||||
|
||||
const bucket = Math.round(delta / length * 12);
|
||||
|
||||
return padStart(String(bucket * 5), 2, '0');
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue