Move left pane entirely to React

This commit is contained in:
Scott Nonnenberg 2019-01-14 13:49:58 -08:00
parent bf904ddd12
commit b3ac1373fa
142 changed files with 5016 additions and 3428 deletions

View file

@ -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;
}

View file

@ -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 ? (

View file

@ -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;
}

View file

@ -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>

View file

@ -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,

View file

@ -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
View 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>
```

View 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>
);
}
}

View file

@ -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();

View file

@ -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;

View file

@ -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>
```

View file

@ -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>
);
}

View 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} />
```

View 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;
}
}

View 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>
```

View 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>
);
}
}

View 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>;
```

View 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>
);
}
}

View 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>
```

View 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>
);
}
}

View file

@ -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> {

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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> {

View file

@ -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');
}

View file

@ -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:

View file

@ -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);

View file

@ -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');
}

View file

@ -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;

View file

@ -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
);
}

View file

@ -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}

View file

@ -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> {

View file

@ -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">

View file

@ -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> {

View file

@ -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"

View file

@ -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">

View file

@ -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;
}

View file

@ -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}

View file

@ -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;

View file

@ -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;
}

View file

@ -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> {

View file

@ -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> {

View 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>
);
}

View file

@ -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;

View file

@ -10,7 +10,7 @@ interface Props {
timestamp: number;
// Optional
fileName?: string | null;
fileName?: string;
fileSize?: number;
onClick?: () => void;
shouldShowSeparator?: boolean;

View file

@ -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 });
};

View file

@ -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);

View file

@ -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;
}
};

View file

@ -1,4 +1,4 @@
import { AttachmentType } from '../../types';
import { AttachmentType } from '../../../../types/Attachment';
import { Message } from './Message';
export interface ItemClickEvent {

View file

@ -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;
};
}