Finish new Message component, integrate into application
Also: - New schema version 8 with video/image thumbnails, screenshots, sizes - Upgrade messages not at current schema version when loading messages to show in conversation - New MessageDetail react component - New ConversationHeader react component
This commit is contained in:
parent
69f11c4a7b
commit
3c69886320
102 changed files with 9644 additions and 7381 deletions
61
ts/components/Intl.md
Normal file
61
ts/components/Intl.md
Normal file
|
@ -0,0 +1,61 @@
|
|||
#### No replacements
|
||||
|
||||
```jsx
|
||||
<Intl id="leftTheGroup" i18n={util.i18n} />
|
||||
```
|
||||
|
||||
#### Single string replacement
|
||||
|
||||
```jsx
|
||||
<Intl id="leftTheGroup" i18n={util.i18n} components={['Alice']} />
|
||||
```
|
||||
|
||||
#### Single tag replacement
|
||||
|
||||
```jsx
|
||||
<Intl
|
||||
id="leftTheGroup"
|
||||
i18n={util.i18n}
|
||||
components={[
|
||||
<button
|
||||
key="external-2"
|
||||
style={{ backgroundColor: 'blue', color: 'white' }}
|
||||
>
|
||||
Alice
|
||||
</button>,
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Multiple string replacement
|
||||
|
||||
```jsx
|
||||
<Intl
|
||||
id="changedSinceVerified"
|
||||
i18n={util.i18n}
|
||||
components={['Alice', 'Bob']}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Multiple tag replacement
|
||||
|
||||
```jsx
|
||||
<Intl
|
||||
id="changedSinceVerified"
|
||||
i18n={util.i18n}
|
||||
components={[
|
||||
<button
|
||||
key="external-1"
|
||||
style={{ backgroundColor: 'blue', color: 'white' }}
|
||||
>
|
||||
Alice
|
||||
</button>,
|
||||
<button
|
||||
key="external-2"
|
||||
style={{ backgroundColor: 'black', color: 'white' }}
|
||||
>
|
||||
Bob
|
||||
</button>,
|
||||
]}
|
||||
/>
|
||||
```
|
79
ts/components/Intl.tsx
Normal file
79
ts/components/Intl.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Localizer, RenderTextCallback } from '../types/Util';
|
||||
|
||||
type FullJSX = Array<JSX.Element | string> | JSX.Element | string;
|
||||
|
||||
interface Props {
|
||||
/** The translation string id */
|
||||
id: string;
|
||||
i18n: Localizer;
|
||||
components?: Array<FullJSX>;
|
||||
renderText?: RenderTextCallback;
|
||||
}
|
||||
|
||||
export class Intl extends React.Component<Props> {
|
||||
public static defaultProps: Partial<Props> = {
|
||||
renderText: ({ text }) => text,
|
||||
};
|
||||
|
||||
public getComponent(index: number): FullJSX | null {
|
||||
const { id, components } = this.props;
|
||||
|
||||
if (!components || !components.length || components.length <= index) {
|
||||
// tslint:disable-next-line no-console
|
||||
console.log(
|
||||
`Error: Intl missing provided components for id ${id}, index ${index}`
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return components[index];
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { id, i18n, renderText } = this.props;
|
||||
|
||||
const text = i18n(id);
|
||||
const results: Array<any> = [];
|
||||
const FIND_REPLACEMENTS = /\$[^$]+\$/g;
|
||||
|
||||
// We have to do this, because renderText is not required in our Props object,
|
||||
// but it is always provided via defaultProps.
|
||||
if (!renderText) {
|
||||
return;
|
||||
}
|
||||
|
||||
let componentIndex = 0;
|
||||
let key = 0;
|
||||
let lastTextIndex = 0;
|
||||
let match = FIND_REPLACEMENTS.exec(text);
|
||||
|
||||
if (!match) {
|
||||
return renderText({ text, key: 0 });
|
||||
}
|
||||
|
||||
while (match) {
|
||||
if (lastTextIndex < match.index) {
|
||||
const textWithNoReplacements = text.slice(lastTextIndex, match.index);
|
||||
results.push(renderText({ text: textWithNoReplacements, key: key }));
|
||||
key += 1;
|
||||
}
|
||||
|
||||
results.push(this.getComponent(componentIndex));
|
||||
componentIndex += 1;
|
||||
|
||||
// @ts-ignore
|
||||
lastTextIndex = FIND_REPLACEMENTS.lastIndex;
|
||||
match = FIND_REPLACEMENTS.exec(text);
|
||||
}
|
||||
|
||||
if (lastTextIndex < text.length) {
|
||||
results.push(renderText({ text: text.slice(lastTextIndex), key: key }));
|
||||
key += 1;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
|
@ -3,17 +3,23 @@ const noop = () => {};
|
|||
|
||||
const messages = [
|
||||
{
|
||||
objectURL: 'https://placekitten.com/800/600',
|
||||
objectURL: 'https://placekitten.com/799/600',
|
||||
attachments: [{ contentType: 'image/jpeg' }],
|
||||
},
|
||||
{
|
||||
objectURL: 'https://placekitten.com/900/600',
|
||||
attachments: [{ contentType: 'image/jpeg' }],
|
||||
},
|
||||
// Unsupported image type
|
||||
{
|
||||
objectURL: 'foo.tif',
|
||||
attachments: [{ contentType: 'image/tiff' }],
|
||||
},
|
||||
// Video
|
||||
{
|
||||
objectURL: util.mp4ObjectUrl,
|
||||
attachments: [{ contentType: 'video/mp4' }],
|
||||
},
|
||||
{
|
||||
objectURL: 'https://placekitten.com/980/800',
|
||||
attachments: [{ contentType: 'image/jpeg' }],
|
||||
|
|
|
@ -1,32 +1,26 @@
|
|||
#### With name and profile
|
||||
#### Number, name and profile
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="+12025550011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Profile, no name
|
||||
#### Number and profile, no name
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
phoneNumber="+12025550011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
<ContactName
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
```
|
||||
|
||||
#### No name, no profile
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ContactName i18n={util.i18n} phoneNumber="+12025550011" />
|
||||
</div>
|
||||
<ContactName i18n={util.i18n} phoneNumber="(202) 555-0011" />
|
||||
```
|
||||
|
|
|
@ -9,23 +9,27 @@ interface Props {
|
|||
name?: string;
|
||||
profileName?: string;
|
||||
i18n: Localizer;
|
||||
module?: string;
|
||||
}
|
||||
|
||||
export class ContactName extends React.Component<Props> {
|
||||
public render() {
|
||||
const { phoneNumber, name, profileName, i18n } = this.props;
|
||||
const { phoneNumber, name, profileName, i18n, module } = this.props;
|
||||
const prefix = module ? module : 'module-contact-name';
|
||||
|
||||
const title = name ? name : phoneNumber;
|
||||
const profileElement =
|
||||
profileName && !name ? (
|
||||
<span className="profile-name">
|
||||
~<Emojify text={profileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
const shouldShowProfile = Boolean(profileName && !name);
|
||||
const profileElement = shouldShowProfile ? (
|
||||
<span className={`${prefix}__profile-name`}>
|
||||
~<Emojify text={profileName || ''} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Emojify text={title} i18n={i18n} /> {profileElement}
|
||||
<span className={prefix}>
|
||||
<Emojify text={title} i18n={i18n} />
|
||||
{shouldShowProfile ? ' ' : null}
|
||||
{profileElement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
139
ts/components/conversation/ConversationHeader.md
Normal file
139
ts/components/conversation/ConversationHeader.md
Normal file
|
@ -0,0 +1,139 @@
|
|||
### Name variations, 1:1 conversation
|
||||
|
||||
Note the five items in gear menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'.
|
||||
|
||||
#### With name and profile, verified
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="red"
|
||||
isVerified={true}
|
||||
avatarPath={util.gifObjectUrl}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0001"
|
||||
id="1"
|
||||
profileName="🔥Flames🔥"
|
||||
onSetDisappearingMessages={seconds =>
|
||||
console.log('onSetDisappearingMessages', seconds)
|
||||
}
|
||||
onDeleteMessages={() => console.log('onDeleteMessages')}
|
||||
onResetSession={() => console.log('onResetSession')}
|
||||
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
|
||||
onShowAllMedia={() => console.log('onShowAllMedia')}
|
||||
onShowGroupMembers={() => console.log('onShowGroupMembers')}
|
||||
onGoBack={() => console.log('onGoBack')}
|
||||
/>
|
||||
```
|
||||
|
||||
#### With name, not verified, no avatar
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="blue"
|
||||
isVerified={false}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0002"
|
||||
id="2"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Profile, no name
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="teal"
|
||||
isVerified={false}
|
||||
phoneNumber="(202) 555-0003"
|
||||
id="3"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
```
|
||||
|
||||
#### No name, no profile, no color
|
||||
|
||||
```jsx
|
||||
<ConversationHeader i18n={util.i18n} phoneNumber="(202) 555-0011" id="11" />
|
||||
```
|
||||
|
||||
### With back button
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
showBackButton={true}
|
||||
color="deep_orange"
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0004"
|
||||
id="4"
|
||||
/>
|
||||
```
|
||||
|
||||
### Disappearing messages set
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
color="indigo"
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0005"
|
||||
id="5"
|
||||
expirationSettingName="10 seconds"
|
||||
timerOptions={[
|
||||
{
|
||||
name: 'off',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: '10 seconds',
|
||||
value: 10,
|
||||
},
|
||||
]}
|
||||
onSetDisappearingMessages={seconds =>
|
||||
console.log('onSetDisappearingMessages', seconds)
|
||||
}
|
||||
onDeleteMessages={() => console.log('onDeleteMessages')}
|
||||
onResetSession={() => console.log('onResetSession')}
|
||||
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
|
||||
onShowAllMedia={() => console.log('onShowAllMedia')}
|
||||
onShowGroupMembers={() => console.log('onShowGroupMembers')}
|
||||
onGoBack={() => console.log('onGoBack')}
|
||||
/>
|
||||
```
|
||||
|
||||
### In a group
|
||||
|
||||
Note that the menu should includes 'Show Members' instead of 'Show Safety Number'
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
i18n={util.i18n}
|
||||
color="green"
|
||||
phoneNumber="(202) 555-0006"
|
||||
id="6"
|
||||
isGroup={true}
|
||||
onSetDisappearingMessages={seconds =>
|
||||
console.log('onSetDisappearingMessages', seconds)
|
||||
}
|
||||
onDeleteMessages={() => console.log('onDeleteMessages')}
|
||||
onResetSession={() => console.log('onResetSession')}
|
||||
onShowSafetyNumber={() => console.log('onShowSafetyNumber')}
|
||||
onShowAllMedia={() => console.log('onShowAllMedia')}
|
||||
onShowGroupMembers={() => console.log('onShowGroupMembers')}
|
||||
onGoBack={() => console.log('onGoBack')}
|
||||
/>
|
||||
```
|
||||
|
||||
### In chat with yourself
|
||||
|
||||
Note that the menu should not have a 'Show Safety Number' entry.
|
||||
|
||||
```jsx
|
||||
<ConversationHeader
|
||||
color="cyan"
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0007"
|
||||
id="7"
|
||||
isMe={true}
|
||||
/>
|
||||
```
|
253
ts/components/conversation/ConversationHeader.tsx
Normal file
253
ts/components/conversation/ConversationHeader.tsx
Normal file
|
@ -0,0 +1,253 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Emojify } from './Emojify';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
MenuItem,
|
||||
SubMenu,
|
||||
} from 'react-contextmenu';
|
||||
|
||||
interface TimerOption {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
isVerified: boolean;
|
||||
name?: string;
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
color: string;
|
||||
|
||||
avatarPath?: string;
|
||||
isMe: boolean;
|
||||
isGroup: boolean;
|
||||
expirationSettingName?: string;
|
||||
showBackButton: boolean;
|
||||
timerOptions: Array<TimerOption>;
|
||||
|
||||
onSetDisappearingMessages: (seconds: number) => void;
|
||||
onDeleteMessages: () => void;
|
||||
onResetSession: () => void;
|
||||
|
||||
onShowSafetyNumber: () => void;
|
||||
onShowAllMedia: () => void;
|
||||
onShowGroupMembers: () => void;
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.trim()[0] || '#';
|
||||
}
|
||||
|
||||
export class ConversationHeader extends React.Component<Props> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public menuTriggerRef: Trigger | null;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public renderBackButton() {
|
||||
const { onGoBack, showBackButton } = this.props;
|
||||
|
||||
if (!showBackButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onGoBack}
|
||||
role="button"
|
||||
className="module-conversation-header__back-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderTitle() {
|
||||
const { name, phoneNumber, i18n, profileName, isVerified } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header__title">
|
||||
{name ? <Emojify text={name} i18n={i18n} /> : null}
|
||||
{name && phoneNumber ? ' · ' : null}
|
||||
{phoneNumber ? phoneNumber : null}{' '}
|
||||
{profileName && !name ? (
|
||||
<span className="module-conversation-header__title__profile-name">
|
||||
<Emojify text={profileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null}
|
||||
{isVerified ? ' · ' : null}
|
||||
{isVerified ? (
|
||||
<span>
|
||||
<span className="module-conversation-header__title__verified-icon" />
|
||||
{i18n('verified')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderAvatar() {
|
||||
const {
|
||||
avatarPath,
|
||||
color,
|
||||
i18n,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
} = this.props;
|
||||
|
||||
if (!avatarPath) {
|
||||
const initial = getInitial(name || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-conversation-header___avatar',
|
||||
'module-conversation-header___default-avatar',
|
||||
`module-conversation-header___default-avatar--${color}`
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = `${name || phoneNumber}${
|
||||
!name && profileName ? ` ~${profileName}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
className="module-conversation-header___avatar"
|
||||
alt={i18n('contactAvatarAlt', [title])}
|
||||
src={avatarPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderExpirationLength() {
|
||||
const { expirationSettingName } = this.props;
|
||||
|
||||
if (!expirationSettingName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header__expiration">
|
||||
<div className="module-conversation-header__expiration__clock-icon" />
|
||||
<div className="module-conversation-header__expiration__setting">
|
||||
{expirationSettingName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderGear(triggerId: string) {
|
||||
const { showBackButton } = this.props;
|
||||
|
||||
if (showBackButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
||||
<div
|
||||
role="button"
|
||||
onClick={this.showMenuBound}
|
||||
className="module-conversation-header__gear-icon"
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
/* tslint:disable:jsx-no-lambda react-this-binding-issue */
|
||||
public renderMenu(triggerId: string) {
|
||||
const {
|
||||
i18n,
|
||||
isMe,
|
||||
isGroup,
|
||||
onDeleteMessages,
|
||||
onResetSession,
|
||||
onSetDisappearingMessages,
|
||||
onShowAllMedia,
|
||||
onShowGroupMembers,
|
||||
onShowSafetyNumber,
|
||||
timerOptions,
|
||||
} = this.props;
|
||||
|
||||
const title = i18n('disappearingMessages') as any;
|
||||
|
||||
return (
|
||||
<ContextMenu id={triggerId}>
|
||||
<SubMenu title={title}>
|
||||
{(timerOptions || []).map(item => (
|
||||
<MenuItem
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onSetDisappearingMessages(item.value);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SubMenu>
|
||||
<MenuItem onClick={onShowAllMedia}>{i18n('viewAllMedia')}</MenuItem>
|
||||
{isGroup ? (
|
||||
<MenuItem onClick={onShowGroupMembers}>
|
||||
{i18n('showMembers')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{!isGroup && !isMe ? (
|
||||
<MenuItem onClick={onShowSafetyNumber}>
|
||||
{i18n('showSafetyNumber')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{!isGroup ? (
|
||||
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
/* tslint:enable */
|
||||
|
||||
public render() {
|
||||
const { id } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-conversation-header">
|
||||
{this.renderBackButton()}
|
||||
{this.renderAvatar()}
|
||||
{this.renderTitle()}
|
||||
{this.renderExpirationLength()}
|
||||
{this.renderGear(id)}
|
||||
{this.renderMenu(id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
#### With name and profile, verified
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle
|
||||
i18n={util.i18n}
|
||||
isVerified
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With name, not verified
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle
|
||||
i18n={util.i18n}
|
||||
name="Someone 🔥 Somewhere"
|
||||
phoneNumber="(202) 555-0011"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Profile, no name
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle
|
||||
i18n={util.i18n}
|
||||
phoneNumber="(202) 555-0011"
|
||||
profileName="🔥Flames🔥"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### No name, no profile
|
||||
|
||||
```jsx
|
||||
<div style={{ backgroundColor: 'gray', color: 'white' }}>
|
||||
<ConversationTitle i18n={util.i18n} phoneNumber="(202) 555-0011" />
|
||||
</div>
|
||||
```
|
|
@ -1,42 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Emojify } from './Emojify';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
isVerified: boolean;
|
||||
name?: string;
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
}
|
||||
|
||||
export class ConversationTitle extends React.Component<Props> {
|
||||
public render() {
|
||||
const { name, phoneNumber, i18n, profileName, isVerified } = this.props;
|
||||
|
||||
return (
|
||||
<span className="conversation-title">
|
||||
{name ? (
|
||||
<span className="conversation-name" dir="auto">
|
||||
<Emojify text={name} i18n={i18n} />
|
||||
</span>
|
||||
) : null}
|
||||
{phoneNumber ? (
|
||||
<span className="conversation-number">{phoneNumber}</span>
|
||||
) : null}{' '}
|
||||
{profileName && !name ? (
|
||||
<span className="profileName">
|
||||
<Emojify text={profileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null}
|
||||
{isVerified ? (
|
||||
<span className="verified">
|
||||
<span className="verified-icon" />
|
||||
{i18n('verified')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -3,516 +3,533 @@
|
|||
#### Including all data types
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
onClick: () => console.log('onClick'),
|
||||
onSendMessage: () => console.log('onSendMessage'),
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Really long long data
|
||||
#### Really long data
|
||||
|
||||
```
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
<li><Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
contact={contact}/></li>
|
||||
<li><Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
contact={contact}/></li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### In group conversation
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme} type="group">
|
||||
<Message
|
||||
color="green"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
authorName="Mr. Fire"
|
||||
conversationType="group"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
contactHasSignalAccount
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
authorAvatarPath={util.gifObjectUrl}
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
authorName="Mr. Fire"
|
||||
conversationType="group"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
conversationType="group"
|
||||
authorName="Mr. Fire"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### If contact has no signal account
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### With organization name instead of name
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
organization: 'United Somewheres, Inc.',
|
||||
email: [
|
||||
{
|
||||
value: 'someone@somewheres.com',
|
||||
type: 2,
|
||||
},
|
||||
],
|
||||
const contact = {
|
||||
organization: 'United Somewheres, Inc.',
|
||||
email: [
|
||||
{
|
||||
value: 'someone@somewheres.com',
|
||||
type: 2,
|
||||
},
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### No displayName or organization
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
givenName: 'Someone',
|
||||
const contact = {
|
||||
name: {
|
||||
givenName: 'Someone',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-1000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '+12025551000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Default avatar
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: util.CONTACTS[0].id,
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
const contact = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
];
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-1001',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Empty contact
|
||||
|
||||
```jsx
|
||||
const contacts = [{}];
|
||||
const contact = {};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contact}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
#### Contact with caption (cannot currently be sent)
|
||||
|
||||
```jsx
|
||||
const contacts = [
|
||||
{
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
const contactWithAccount = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
];
|
||||
hasSignalAccount: true,
|
||||
};
|
||||
const contactWithoutAccount = {
|
||||
name: {
|
||||
displayName: 'Someone Somewhere',
|
||||
},
|
||||
number: [
|
||||
{
|
||||
value: '(202) 555-0000',
|
||||
type: 1,
|
||||
},
|
||||
],
|
||||
avatar: {
|
||||
avatar: {
|
||||
path: util.gifObjectUrl,
|
||||
},
|
||||
},
|
||||
hasSignalAccount: false,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
color="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contacts={contacts}
|
||||
onClickContact={() => console.log('onClickContact')}
|
||||
contactHasSignalAccount
|
||||
onSendMessageToContact={() => console.log('onSendMessageToContact')}
|
||||
/>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
authorColor="green"
|
||||
direction="incoming"
|
||||
collapseMetadata
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
text="I want to introduce you to Someone..."
|
||||
direction="outgoing"
|
||||
collapseMetadata
|
||||
status="delivered"
|
||||
i18n={util.i18n}
|
||||
contact={contactWithoutAccount}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
|
|
@ -12,8 +12,7 @@ interface Props {
|
|||
isIncoming: boolean;
|
||||
withContentAbove: boolean;
|
||||
withContentBelow: boolean;
|
||||
onSendMessage?: () => void;
|
||||
onClickContact?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export class EmbeddedContact extends React.Component<Props> {
|
||||
|
@ -22,7 +21,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
contact,
|
||||
i18n,
|
||||
isIncoming,
|
||||
onClickContact,
|
||||
onClick,
|
||||
withContentAbove,
|
||||
withContentBelow,
|
||||
} = this.props;
|
||||
|
@ -40,7 +39,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
|||
: null
|
||||
)}
|
||||
role="button"
|
||||
onClick={onClickContact}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderAvatar({ contact, i18n, module })}
|
||||
<div className="module-embedded-contact__text-container">
|
||||
|
|
|
@ -34,10 +34,13 @@ function getImageTag({
|
|||
const title = getTitle(result.value);
|
||||
|
||||
return (
|
||||
// tslint:disable-next-line react-a11y-img-has-alt
|
||||
<img
|
||||
key={key}
|
||||
src={img.path}
|
||||
alt={i18n('emojiAlt', [title || ''])}
|
||||
// We can't use alt or it will be what is captured when a user copies message
|
||||
// contents ("Emoji of ':1'"). Instead, we want the title to be copied (':+1:').
|
||||
aria-label={i18n('emojiAlt', [title || ''])}
|
||||
className={classNames('emoji', sizeClass)}
|
||||
data-codepoints={img.full_idx}
|
||||
title={`:${title}:`}
|
||||
|
|
193
ts/components/conversation/ExpireTimer.md
Normal file
193
ts/components/conversation/ExpireTimer.md
Normal file
|
@ -0,0 +1,193 @@
|
|||
### Countdown at different rates
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="10 second timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={10 * 1000}
|
||||
expirationTimestamp={Date.now() + 10 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="cyan"
|
||||
text="30 second timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={30 * 1000}
|
||||
expirationTimestamp={Date.now() + 30 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="1 minute timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 55 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="5 minute timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={5 * 60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 60 * 1000}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Timer calculations
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Full timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 60 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Full timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 60 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="55 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 55 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="55 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 55 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="30 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 30 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="30 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 30 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="5 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="5 timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 5 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Expired timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now()}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Expired timer"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now()}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Expiration is too far away"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 120 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Expiration is too far away"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() + 120 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
authorColor="cyan"
|
||||
direction="incoming"
|
||||
text="Already expired"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() - 20 * 1000}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="outgoing"
|
||||
status="delivered"
|
||||
text="Already expired"
|
||||
i18n={util.i18n}
|
||||
expirationLength={60 * 1000}
|
||||
expirationTimestamp={Date.now() - 20 * 1000}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>
|
||||
```
|
86
ts/components/conversation/ExpireTimer.tsx
Normal file
86
ts/components/conversation/ExpireTimer.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { padStart } from 'lodash';
|
||||
|
||||
interface Props {
|
||||
withImageNoCaption: boolean;
|
||||
expirationLength: number;
|
||||
expirationTimestamp: number;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
export class ExpireTimer extends React.Component<Props> {
|
||||
private interval: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const { expirationLength } = this.props;
|
||||
const increment = getIncrement(expirationLength);
|
||||
const updateFrequency = Math.max(increment, 500);
|
||||
|
||||
const update = () => {
|
||||
this.setState({
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
};
|
||||
this.interval = setInterval(update, updateFrequency);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
withImageNoCaption,
|
||||
} = this.props;
|
||||
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-expire-timer',
|
||||
`module-expire-timer--${bucket}`,
|
||||
`module-expire-timer--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-expire-timer--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
171
ts/components/conversation/GroupNotification.md
Normal file
171
ts/components/conversation/GroupNotification.md
Normal file
|
@ -0,0 +1,171 @@
|
|||
### Three changes, all types
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'add',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mrs. Ice',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
name: 'Ms. Earth',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'name',
|
||||
newName: 'New Group Name',
|
||||
},
|
||||
{
|
||||
type: 'remove',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Joined group
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'add',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mrs. Ice',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
name: 'Ms. Earth',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'add',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Left group
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'remove',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mrs. Ice',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
name: 'Ms. Earth',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'remove',
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'remove',
|
||||
isMe: true,
|
||||
contacts: [
|
||||
{
|
||||
phoneNumber: '(202) 555-1000',
|
||||
profileName: 'Mr. Fire',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Title changed
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'name',
|
||||
newName: 'New Group Name',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Generic group update
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<GroupNotification
|
||||
changes={[
|
||||
{
|
||||
type: 'general',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
109
ts/components/conversation/GroupNotification.tsx
Normal file
109
ts/components/conversation/GroupNotification.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
import { compact, flatten } from 'lodash';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Change {
|
||||
type: 'add' | 'remove' | 'name' | 'general';
|
||||
isMe: boolean;
|
||||
newName?: string;
|
||||
contacts?: Array<Contact>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
changes: Array<Change>;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class GroupNotification extends React.Component<Props> {
|
||||
public renderChange(change: Change) {
|
||||
const { isMe, contacts, type, newName } = change;
|
||||
const { i18n } = this.props;
|
||||
|
||||
const people = compact(
|
||||
flatten(
|
||||
(contacts || []).map((contact, index) => {
|
||||
const element = (
|
||||
<span
|
||||
key={`external-${contact.phoneNumber}`}
|
||||
className="module-group-notification__contact"
|
||||
>
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
profileName={contact.profileName}
|
||||
name={contact.name}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
return [index > 0 ? ', ' : null, element];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'name':
|
||||
return i18n('titleIsNow', [newName || '']);
|
||||
case 'add':
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={
|
||||
contacts.length > 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup'
|
||||
}
|
||||
components={[people]}
|
||||
/>
|
||||
);
|
||||
case 'remove':
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
if (isMe) {
|
||||
return i18n('youLeftTheGroup');
|
||||
}
|
||||
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id={contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'}
|
||||
components={[people]}
|
||||
/>
|
||||
);
|
||||
case 'general':
|
||||
return i18n('updatedTheGroup');
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { changes } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-group-notification">
|
||||
{(changes || []).map(change => (
|
||||
<div className="module-group-notification__change">
|
||||
{this.renderChange(change)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,23 +1,28 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import { padStart } from 'lodash';
|
||||
|
||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
||||
import {
|
||||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../../util/GoogleChrome';
|
||||
|
||||
import { MessageBody } from './MessageBody';
|
||||
import { Emojify } from './Emojify';
|
||||
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { ContactName } from './ContactName';
|
||||
import { Quote, QuotedAttachment } from './Quote';
|
||||
import { EmbeddedContact } from './EmbeddedContact';
|
||||
|
||||
import { Contact } from '../../types/Contact';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { Color, Localizer } from '../../types/Util';
|
||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
||||
|
||||
import * as MIME from '../../../ts/types/MIME';
|
||||
|
||||
interface Trigger {
|
||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
contentType: MIME.MIMEType;
|
||||
fileName: string;
|
||||
|
@ -26,50 +31,69 @@ interface Attachment {
|
|||
/** For messages not already on disk, this will be a data url */
|
||||
url: string;
|
||||
fileSize?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
screenshot?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIME.MIMEType;
|
||||
};
|
||||
thumbnail?: {
|
||||
height: number;
|
||||
width: number;
|
||||
url: string;
|
||||
contentType: MIME.MIMEType;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
disableMenu?: boolean;
|
||||
text?: string;
|
||||
id?: string;
|
||||
collapseMetadata?: boolean;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
timestamp: number;
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read';
|
||||
contacts?: Array<Contact>;
|
||||
color:
|
||||
| 'gray'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'deep-orange'
|
||||
| 'green'
|
||||
| 'indigo'
|
||||
| 'pink'
|
||||
| 'purple'
|
||||
| 'red'
|
||||
| 'teal';
|
||||
status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error';
|
||||
// What if changed this over to a single contact like quote, and put the events on it?
|
||||
contact?: Contact & {
|
||||
hasSignalAccount: boolean;
|
||||
onSendMessage?: () => void;
|
||||
onClick?: () => void;
|
||||
};
|
||||
i18n: Localizer;
|
||||
authorName?: string;
|
||||
authorProfileName?: string;
|
||||
/** Note: this should be formatted for display */
|
||||
authorPhoneNumber?: string;
|
||||
authorPhoneNumber: string;
|
||||
authorColor: Color;
|
||||
conversationType: 'group' | 'direct';
|
||||
attachment?: Attachment;
|
||||
quote?: {
|
||||
text: string;
|
||||
attachments: Array<QuotedAttachment>;
|
||||
attachment?: QuotedAttachment;
|
||||
isFromMe: boolean;
|
||||
authorName?: string;
|
||||
authorPhoneNumber?: string;
|
||||
authorPhoneNumber: string;
|
||||
authorProfileName?: string;
|
||||
authorName?: string;
|
||||
authorColor: Color;
|
||||
onClick?: () => void;
|
||||
};
|
||||
authorAvatarPath?: string;
|
||||
contactHasSignalAccount: boolean;
|
||||
expirationLength?: number;
|
||||
expirationTimestamp?: number;
|
||||
onClickQuote?: () => void;
|
||||
onSendMessageToContact?: () => void;
|
||||
onClickContact?: () => void;
|
||||
onClickAttachment?: () => void;
|
||||
onReply?: () => void;
|
||||
onRetrySend?: () => void;
|
||||
onDownload?: () => void;
|
||||
onDelete?: () => void;
|
||||
onShowDetail: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
expiring: boolean;
|
||||
expired: boolean;
|
||||
imageBroken: boolean;
|
||||
}
|
||||
|
||||
function isImage(attachment?: Attachment) {
|
||||
|
@ -80,6 +104,10 @@ function isImage(attachment?: Attachment) {
|
|||
);
|
||||
}
|
||||
|
||||
function hasImage(attachment?: Attachment) {
|
||||
return attachment && attachment.url;
|
||||
}
|
||||
|
||||
function isVideo(attachment?: Attachment) {
|
||||
return (
|
||||
attachment &&
|
||||
|
@ -88,24 +116,18 @@ function isVideo(attachment?: Attachment) {
|
|||
);
|
||||
}
|
||||
|
||||
function hasVideoScreenshot(attachment?: Attachment) {
|
||||
return attachment && attachment.screenshot && attachment.screenshot.url;
|
||||
}
|
||||
|
||||
function isAudio(attachment?: Attachment) {
|
||||
return (
|
||||
attachment && attachment.contentType && MIME.isAudio(attachment.contentType)
|
||||
);
|
||||
}
|
||||
|
||||
function getTimerBucket(expiration: number, length: number): string {
|
||||
const delta = expiration - Date.now();
|
||||
if (delta < 0) {
|
||||
return '00';
|
||||
}
|
||||
if (delta > length) {
|
||||
return '60';
|
||||
}
|
||||
|
||||
const increment = Math.round(delta / length * 12);
|
||||
|
||||
return padStart(String(increment * 5), 2, '0');
|
||||
function getInitial(name: string): string {
|
||||
return name.trim()[0] || '#';
|
||||
}
|
||||
|
||||
function getExtension({
|
||||
|
@ -131,58 +153,118 @@ function getExtension({
|
|||
return null;
|
||||
}
|
||||
|
||||
export class Message extends React.Component<Props> {
|
||||
public renderTimer() {
|
||||
const {
|
||||
attachment,
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
text,
|
||||
} = this.props;
|
||||
const MINIMUM_IMG_HEIGHT = 150;
|
||||
const MAXIMUM_IMG_HEIGHT = 300;
|
||||
const EXPIRATION_CHECK_MINIMUM = 2000;
|
||||
const EXPIRED_DELAY = 600;
|
||||
|
||||
if (!expirationLength || !expirationTimestamp) {
|
||||
return null;
|
||||
export class Message extends React.Component<Props, State> {
|
||||
public captureMenuTriggerBound: (trigger: any) => void;
|
||||
public showMenuBound: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
public handleImageErrorBound: () => void;
|
||||
|
||||
public menuTriggerRef: Trigger | null;
|
||||
public expirationCheckInterval: any;
|
||||
public expiredTimeout: any;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this);
|
||||
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,
|
||||
imageBroken: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const { expirationLength } = this.props;
|
||||
if (!expirationLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
const withImageNoCaption = !text && isImage(attachment);
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
const increment = getIncrement(expirationLength);
|
||||
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__metadata__timer',
|
||||
`module-message__metadata__timer--${bucket}`,
|
||||
`module-message__metadata__timer--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__timer--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
this.checkExpired();
|
||||
|
||||
this.expirationCheckInterval = setInterval(() => {
|
||||
this.checkExpired();
|
||||
}, checkFrequency);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.expirationCheckInterval) {
|
||||
clearInterval(this.expirationCheckInterval);
|
||||
}
|
||||
if (this.expiredTimeout) {
|
||||
clearTimeout(this.expiredTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
public checkExpired() {
|
||||
const now = Date.now();
|
||||
const { expirationTimestamp, expirationLength } = this.props;
|
||||
|
||||
if (!expirationTimestamp || !expirationLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (now >= expirationTimestamp) {
|
||||
this.setState({
|
||||
expiring: true,
|
||||
});
|
||||
|
||||
const setExpired = () => {
|
||||
this.setState({
|
||||
expired: true,
|
||||
});
|
||||
};
|
||||
this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
public handleImageError() {
|
||||
// tslint:disable-next-line no-console
|
||||
console.log('Message: Image failed to load; failing over to placeholder');
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
}
|
||||
|
||||
public renderMetadata() {
|
||||
const {
|
||||
attachment,
|
||||
collapseMetadata,
|
||||
color,
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
i18n,
|
||||
status,
|
||||
timestamp,
|
||||
text,
|
||||
attachment,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
if (collapseMetadata) {
|
||||
return null;
|
||||
}
|
||||
// We're not showing metadata on top of videos since they still have native controls
|
||||
if (!text && isVideo(attachment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withImageNoCaption = !text && isImage(attachment);
|
||||
const withImageNoCaption = Boolean(
|
||||
!text &&
|
||||
!imageBroken &&
|
||||
((isImage(attachment) && hasImage(attachment)) ||
|
||||
(isVideo(attachment) && hasVideoScreenshot(attachment)))
|
||||
);
|
||||
const showError = status === 'error' && direction === 'outgoing';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -193,33 +275,43 @@ export class Message extends React.Component<Props> {
|
|||
: null
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-message__metadata__date',
|
||||
`module-message__metadata__date--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__date--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
title={moment(timestamp).format('llll')}
|
||||
>
|
||||
{formatRelativeTime(timestamp, { i18n, extended: true })}
|
||||
</span>
|
||||
{this.renderTimer()}
|
||||
{showError ? (
|
||||
<span
|
||||
className={classNames(
|
||||
'module-message__metadata__date',
|
||||
`module-message__metadata__date--${direction}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__date--with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
{i18n('sendFailed')}
|
||||
</span>
|
||||
) : (
|
||||
<Timestamp
|
||||
i18n={i18n}
|
||||
timestamp={timestamp}
|
||||
direction={direction}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
module="module-message__metadata__date"
|
||||
/>
|
||||
)}
|
||||
{expirationLength && expirationTimestamp ? (
|
||||
<ExpireTimer
|
||||
direction={direction}
|
||||
expirationLength={expirationLength}
|
||||
expirationTimestamp={expirationTimestamp}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
/>
|
||||
) : null}
|
||||
<span className="module-message__metadata__spacer" />
|
||||
{direction === 'outgoing' ? (
|
||||
{direction === 'outgoing' && status !== 'error' ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__metadata__status-icon',
|
||||
`module-message__metadata__status-icon-${status}`,
|
||||
status === 'read'
|
||||
? `module-message__metadata__status-icon-${color}`
|
||||
: null,
|
||||
`module-message__metadata__status-icon--${status}`,
|
||||
withImageNoCaption
|
||||
? 'module-message__metadata__status-icon--with-image-no-caption'
|
||||
: null,
|
||||
withImageNoCaption && status === 'read'
|
||||
? 'module-message__metadata__status-icon--read-with-image-no-caption'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
|
@ -231,11 +323,11 @@ export class Message extends React.Component<Props> {
|
|||
public renderAuthor() {
|
||||
const {
|
||||
authorName,
|
||||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
} = this.props;
|
||||
|
||||
const title = authorName ? authorName : authorPhoneNumber;
|
||||
|
@ -244,21 +336,20 @@ export class Message extends React.Component<Props> {
|
|||
return null;
|
||||
}
|
||||
|
||||
const profileElement =
|
||||
authorProfileName && !authorName ? (
|
||||
<span className="module-message__author__profile-name">
|
||||
~<Emojify text={authorProfileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="module-message__author">
|
||||
<Emojify text={title} i18n={i18n} /> {profileElement}
|
||||
<ContactName
|
||||
phoneNumber={authorPhoneNumber}
|
||||
name={authorName}
|
||||
profileName={authorProfileName}
|
||||
module="module-message__author"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
|
||||
public renderAttachment() {
|
||||
const {
|
||||
i18n,
|
||||
|
@ -270,6 +361,7 @@ export class Message extends React.Component<Props> {
|
|||
quote,
|
||||
onClickAttachment,
|
||||
} = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
|
@ -282,9 +374,30 @@ export class Message extends React.Component<Props> {
|
|||
quote || (conversationType === 'group' && direction === 'incoming');
|
||||
|
||||
if (isImage(attachment)) {
|
||||
if (imageBroken || !attachment.url) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__broken-image',
|
||||
`module-message__broken-image--${direction}`
|
||||
)}
|
||||
>
|
||||
{i18n('imageFailedToLoad')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculating height to prevent reflow when image loads
|
||||
const height = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0);
|
||||
|
||||
return (
|
||||
<div className="module-message__attachment-container">
|
||||
<div
|
||||
onClick={onClickAttachment}
|
||||
role="button"
|
||||
className="module-message__attachment-container"
|
||||
>
|
||||
<img
|
||||
onError={this.handleImageErrorBound}
|
||||
className={classNames(
|
||||
'module-message__img-attachment',
|
||||
withCaption
|
||||
|
@ -294,9 +407,9 @@ export class Message extends React.Component<Props> {
|
|||
? 'module-message__img-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
height={Math.min(MAXIMUM_IMG_HEIGHT, height)}
|
||||
src={attachment.url}
|
||||
alt={i18n('imageAttachmentAlt')}
|
||||
onClick={onClickAttachment}
|
||||
/>
|
||||
{!withCaption && !collapseMetadata ? (
|
||||
<div className="module-message__img-overlay" />
|
||||
|
@ -304,21 +417,53 @@ export class Message extends React.Component<Props> {
|
|||
</div>
|
||||
);
|
||||
} else if (isVideo(attachment)) {
|
||||
const { screenshot } = attachment;
|
||||
if (imageBroken || !screenshot || !screenshot.url) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onClickAttachment}
|
||||
className={classNames(
|
||||
'module-message__broken-video-screenshot',
|
||||
`module-message__broken-video-screenshot--${direction}`
|
||||
)}
|
||||
>
|
||||
{i18n('videoScreenshotFailedToLoad')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculating height to prevent reflow when image loads
|
||||
const height = Math.max(MINIMUM_IMG_HEIGHT, screenshot.height || 0);
|
||||
|
||||
return (
|
||||
<video
|
||||
controls={true}
|
||||
className={classNames(
|
||||
'module-message__img-attachment',
|
||||
withCaption
|
||||
? 'module-message__img-attachment--with-content-below'
|
||||
: null,
|
||||
withContentAbove
|
||||
? 'module-message__img-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
<div
|
||||
onClick={onClickAttachment}
|
||||
role="button"
|
||||
className="module-message__attachment-container"
|
||||
>
|
||||
<source src={attachment.url} />
|
||||
</video>
|
||||
<img
|
||||
onError={this.handleImageErrorBound}
|
||||
className={classNames(
|
||||
'module-message__img-attachment',
|
||||
withCaption
|
||||
? 'module-message__img-attachment--with-content-below'
|
||||
: null,
|
||||
withContentAbove
|
||||
? 'module-message__img-attachment--with-content-above'
|
||||
: null
|
||||
)}
|
||||
alt={i18n('videoAttachmentAlt')}
|
||||
height={Math.min(MAXIMUM_IMG_HEIGHT, height)}
|
||||
src={screenshot.url}
|
||||
/>
|
||||
{!withCaption && !collapseMetadata ? (
|
||||
<div className="module-message__img-overlay" />
|
||||
) : null}
|
||||
<div className="module-message__video-overlay__circle">
|
||||
<div className="module-message__video-overlay__play-icon" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (isAudio(attachment)) {
|
||||
return (
|
||||
|
@ -384,38 +529,26 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderQuote() {
|
||||
const {
|
||||
color,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
onClickQuote,
|
||||
quote,
|
||||
} = this.props;
|
||||
const { conversationType, direction, i18n, quote } = this.props;
|
||||
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorTitle = quote.authorName
|
||||
? quote.authorName
|
||||
: quote.authorPhoneNumber;
|
||||
const authorProfileName = !quote.authorName
|
||||
? quote.authorProfileName
|
||||
: undefined;
|
||||
const withContentAbove =
|
||||
conversationType === 'group' && direction === 'incoming';
|
||||
|
||||
return (
|
||||
<Quote
|
||||
i18n={i18n}
|
||||
onClick={onClickQuote}
|
||||
color={color}
|
||||
onClick={quote.onClick}
|
||||
text={quote.text}
|
||||
attachments={quote.attachments}
|
||||
attachment={quote.attachment}
|
||||
isIncoming={direction === 'incoming'}
|
||||
authorTitle={authorTitle || ''}
|
||||
authorProfileName={authorProfileName}
|
||||
authorPhoneNumber={quote.authorPhoneNumber}
|
||||
authorProfileName={quote.authorProfileName}
|
||||
authorName={quote.authorName}
|
||||
authorColor={quote.authorColor}
|
||||
isFromMe={quote.isFromMe}
|
||||
withContentAbove={withContentAbove}
|
||||
/>
|
||||
|
@ -425,18 +558,13 @@ export class Message extends React.Component<Props> {
|
|||
public renderEmbeddedContact() {
|
||||
const {
|
||||
collapseMetadata,
|
||||
contactHasSignalAccount,
|
||||
contacts,
|
||||
contact,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
onClickContact,
|
||||
onSendMessageToContact,
|
||||
text,
|
||||
} = this.props;
|
||||
const first = contacts && contacts[0];
|
||||
|
||||
if (!first) {
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -447,12 +575,11 @@ export class Message extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<EmbeddedContact
|
||||
contact={first}
|
||||
hasSignalAccount={contactHasSignalAccount}
|
||||
contact={contact}
|
||||
hasSignalAccount={contact.hasSignalAccount}
|
||||
isIncoming={direction === 'incoming'}
|
||||
i18n={i18n}
|
||||
onSendMessage={onSendMessageToContact}
|
||||
onClickContact={onClickContact}
|
||||
onClick={contact.onClick}
|
||||
withContentAbove={withContentAbove}
|
||||
withContentBelow={withContentBelow}
|
||||
/>
|
||||
|
@ -460,22 +587,15 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderSendMessageButton() {
|
||||
const {
|
||||
contactHasSignalAccount,
|
||||
contacts,
|
||||
i18n,
|
||||
onSendMessageToContact,
|
||||
} = this.props;
|
||||
const first = contacts && contacts[0];
|
||||
|
||||
if (!first || !contactHasSignalAccount) {
|
||||
const { contact, i18n } = this.props;
|
||||
if (!contact || !contact.hasSignalAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onSendMessageToContact}
|
||||
onClick={contact.onSendMessage}
|
||||
className="module-message__send-message-button"
|
||||
>
|
||||
{i18n('sendMessageToContact')}
|
||||
|
@ -489,8 +609,8 @@ export class Message extends React.Component<Props> {
|
|||
authorPhoneNumber,
|
||||
authorProfileName,
|
||||
authorAvatarPath,
|
||||
authorColor,
|
||||
collapseMetadata,
|
||||
color,
|
||||
conversationType,
|
||||
direction,
|
||||
i18n,
|
||||
|
@ -509,14 +629,18 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
if (!authorAvatarPath) {
|
||||
const label = authorName ? getInitial(authorName) : '#';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__author-default-avatar',
|
||||
`module-message__author-default-avatar--${color}`
|
||||
`module-message__author-default-avatar--${authorColor}`
|
||||
)}
|
||||
>
|
||||
<div className="module-message__author-default-avatar__label">#</div>
|
||||
<div className="module-message__author-default-avatar__label">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -529,9 +653,14 @@ export class Message extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderText() {
|
||||
const { text, i18n, direction } = this.props;
|
||||
const { text, i18n, direction, status } = this.props;
|
||||
|
||||
if (!text) {
|
||||
const contents =
|
||||
direction === 'incoming' && status === 'error'
|
||||
? i18n('incomingError')
|
||||
: text;
|
||||
|
||||
if (!contents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -539,38 +668,162 @@ export class Message extends React.Component<Props> {
|
|||
<div
|
||||
className={classNames(
|
||||
'module-message__text',
|
||||
`module-message__text--${direction}`
|
||||
`module-message__text--${direction}`,
|
||||
status === 'error' && direction === 'incoming'
|
||||
? 'module-message__text--error'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<MessageBody text={text || ''} i18n={i18n} />
|
||||
<MessageBody text={contents || ''} i18n={i18n} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderError(isCorrectSide: boolean) {
|
||||
const { status, direction } = this.props;
|
||||
|
||||
if (!isCorrectSide || status !== 'error') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-message__error-container">
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__error',
|
||||
`module-message__error--${direction}`
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public captureMenuTrigger(triggerRef: Trigger) {
|
||||
this.menuTriggerRef = triggerRef;
|
||||
}
|
||||
public showMenu(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (this.menuTriggerRef) {
|
||||
this.menuTriggerRef.handleContextClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
public renderMenu(isCorrectSide: boolean, triggerId: string) {
|
||||
const {
|
||||
attachment,
|
||||
direction,
|
||||
disableMenu,
|
||||
onDownload,
|
||||
onReply,
|
||||
} = this.props;
|
||||
|
||||
if (!isCorrectSide || disableMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const downloadButton = attachment ? (
|
||||
<div
|
||||
onClick={onDownload}
|
||||
role="button"
|
||||
className={classNames(
|
||||
'module-message__buttons__download',
|
||||
`module-message__buttons__download--${direction}`
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const replyButton = (
|
||||
<div
|
||||
onClick={onReply}
|
||||
role="button"
|
||||
className={classNames(
|
||||
'module-message__buttons__reply',
|
||||
`module-message__buttons__download--${direction}`
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuButton = (
|
||||
<ContextMenuTrigger id={triggerId} ref={this.captureMenuTriggerBound}>
|
||||
<div
|
||||
role="button"
|
||||
onClick={this.showMenuBound}
|
||||
className={classNames(
|
||||
'module-message__buttons__menu',
|
||||
`module-message__buttons__download--${direction}`
|
||||
)}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
|
||||
const first = direction === 'incoming' ? downloadButton : menuButton;
|
||||
const last = direction === 'incoming' ? menuButton : downloadButton;
|
||||
|
||||
return (
|
||||
<div className="module-message__buttons">
|
||||
{first}
|
||||
{replyButton}
|
||||
{last}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContextMenu(triggerId: string) {
|
||||
const {
|
||||
direction,
|
||||
status,
|
||||
onDelete,
|
||||
onRetrySend,
|
||||
onShowDetail,
|
||||
i18n,
|
||||
} = this.props;
|
||||
|
||||
const showRetry = status === 'error' && direction === 'outgoing';
|
||||
|
||||
return (
|
||||
<ContextMenu id={triggerId}>
|
||||
<MenuItem onClick={onShowDetail}>{i18n('moreInfo')}</MenuItem>
|
||||
{showRetry ? (
|
||||
<MenuItem onClick={onRetrySend}>{i18n('retrySend')}</MenuItem>
|
||||
) : null}
|
||||
<MenuItem onClick={onDelete}>{i18n('deleteMessage')}</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
attachment,
|
||||
color,
|
||||
conversationType,
|
||||
authorPhoneNumber,
|
||||
authorColor,
|
||||
direction,
|
||||
id,
|
||||
quote,
|
||||
text,
|
||||
timestamp,
|
||||
} = this.props;
|
||||
const { expired, expiring } = this.state;
|
||||
|
||||
const imageAndNothingElse =
|
||||
!text && isImage(attachment) && conversationType !== 'group' && !quote;
|
||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||
// It needs to be unique.
|
||||
const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`);
|
||||
|
||||
if (expired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message',
|
||||
`module-message--${direction}`,
|
||||
expiring ? 'module-message--expired' : null
|
||||
)}
|
||||
>
|
||||
{this.renderError(direction === 'incoming')}
|
||||
{this.renderMenu(direction === 'outgoing', triggerId)}
|
||||
<div
|
||||
id={id}
|
||||
className={classNames(
|
||||
'module-message',
|
||||
`module-message--${direction}`,
|
||||
imageAndNothingElse ? 'module-message--with-image-only' : null,
|
||||
'module-message__container',
|
||||
`module-message__container--${direction}`,
|
||||
direction === 'incoming'
|
||||
? `module-message--incoming-${color}`
|
||||
? `module-message__container--incoming-${authorColor}`
|
||||
: null
|
||||
)}
|
||||
>
|
||||
|
@ -583,7 +836,10 @@ export class Message extends React.Component<Props> {
|
|||
{this.renderSendMessageButton()}
|
||||
{this.renderAvatar()}
|
||||
</div>
|
||||
</li>
|
||||
{this.renderError(direction === 'outgoing')}
|
||||
{this.renderMenu(direction === 'incoming', triggerId)}
|
||||
{this.renderContextMenu(triggerId)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
128
ts/components/conversation/MessageDetail.md
Normal file
128
ts/components/conversation/MessageDetail.md
Normal file
|
@ -0,0 +1,128 @@
|
|||
### Incoming message
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'incoming',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
sentAt={Date.now() - 2 * 60 * 1000}
|
||||
receivedAt={Date.now() - 10 * 1000}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
### Message to group, multiple contacts
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'outgoing',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
status: 'read',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
sentAt={Date.now()}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
profileName: 'Mr. Fire',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
status: 'sending',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
status: 'delivered',
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1003',
|
||||
color: 'teal',
|
||||
status: 'read',
|
||||
},
|
||||
]}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
### 1:1 conversation, just one recipient
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'outgoing',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
status: 'sending',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
status: 'sending',
|
||||
},
|
||||
]}
|
||||
sentAt={Date.now()}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
||||
|
||||
### Errors for some users, including on OutgoingKeyError
|
||||
|
||||
```jsx
|
||||
<MessageDetail
|
||||
message={{
|
||||
disableMenu: true,
|
||||
direction: 'outgoing',
|
||||
timestamp: Date.now(),
|
||||
authorColor: 'grey',
|
||||
text:
|
||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
status: 'error',
|
||||
onDelete: () => console.log('onDelete'),
|
||||
}}
|
||||
contacts={[
|
||||
{
|
||||
phoneNumber: '(202) 555-1001',
|
||||
avatarPath: util.gifObjectUrl,
|
||||
status: 'error',
|
||||
errors: [new Error('Something went wrong'), new Error('Bad things')],
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1002',
|
||||
avatarPath: util.pngObjectUrl,
|
||||
status: 'error',
|
||||
isOutgoingKeyError: true,
|
||||
errors: [new Error(util.i18n('newIdentity'))],
|
||||
onShowSafetyNumber: () => console.log('onShowSafetyNumber'),
|
||||
onSendAnyway: () => console.log('onSendAnyway'),
|
||||
},
|
||||
{
|
||||
phoneNumber: '(202) 555-1003',
|
||||
color: 'teal',
|
||||
status: 'read',
|
||||
},
|
||||
]}
|
||||
sentAt={Date.now()}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
```
|
209
ts/components/conversation/MessageDetail.tsx
Normal file
209
ts/components/conversation/MessageDetail.tsx
Normal file
|
@ -0,0 +1,209 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Message, Props as MessageProps } from './Message';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Contact {
|
||||
status: string;
|
||||
phoneNumber: string;
|
||||
name?: string;
|
||||
profileName?: string;
|
||||
avatarPath?: string;
|
||||
color: string;
|
||||
isOutgoingKeyError: boolean;
|
||||
|
||||
errors?: Array<Error>;
|
||||
onSendAnyway: () => void;
|
||||
onShowSafetyNumber: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sentAt: number;
|
||||
receivedAt: number;
|
||||
|
||||
message: MessageProps;
|
||||
errors: Array<Error>;
|
||||
contacts: Array<Contact>;
|
||||
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
function getInitial(name: string): string {
|
||||
return name.trim()[0] || '#';
|
||||
}
|
||||
|
||||
export class MessageDetail extends React.Component<Props> {
|
||||
public renderAvatar(contact: Contact) {
|
||||
const { i18n } = this.props;
|
||||
const { avatarPath, color, phoneNumber, name, profileName } = contact;
|
||||
|
||||
if (!avatarPath) {
|
||||
const initial = getInitial(name || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message-detail__contact__avatar',
|
||||
'module-message-detail__contact__default-avatar',
|
||||
`module-message-detail__contact__default-avatar--${color}`
|
||||
)}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = `${name || phoneNumber}${
|
||||
!name && profileName ? ` ~${profileName}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
className="module-message-detail__contact__avatar"
|
||||
alt={i18n('contactAvatarAlt', [title])}
|
||||
src={avatarPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderDeleteButton() {
|
||||
const { i18n, message } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-message-detail__delete-button-container">
|
||||
<button
|
||||
onClick={message.onDelete}
|
||||
className="module-message-detail__delete-button"
|
||||
>
|
||||
{i18n('deleteThisMessage')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContact(contact: Contact) {
|
||||
const { i18n } = this.props;
|
||||
const errors = contact.errors || [];
|
||||
|
||||
const errorComponent = contact.isOutgoingKeyError ? (
|
||||
<div className="module-message-detail__contact__error-buttons">
|
||||
<button
|
||||
className="module-message-detail__contact__show-safety-number"
|
||||
onClick={contact.onShowSafetyNumber}
|
||||
>
|
||||
{i18n('showSafetyNumber')}
|
||||
</button>
|
||||
<button
|
||||
className="module-message-detail__contact__send-anyway"
|
||||
onClick={contact.onSendAnyway}
|
||||
>
|
||||
{i18n('sendAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
const statusComponent = !contact.isOutgoingKeyError ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message-detail__contact__status-icon',
|
||||
`module-message-detail__contact__status-icon--${contact.status}`
|
||||
)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div key={contact.phoneNumber} className="module-message-detail__contact">
|
||||
{this.renderAvatar(contact)}
|
||||
<div className="module-message-detail__contact__text">
|
||||
<div className="module-message-detail__contact__name">
|
||||
<ContactName
|
||||
phoneNumber={contact.phoneNumber}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
{errors.map((error, index) => (
|
||||
<div key={index} className="module-message-detail__contact__error">
|
||||
{error.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errorComponent}
|
||||
{statusComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderContacts() {
|
||||
const { contacts } = this.props;
|
||||
|
||||
if (!contacts || !contacts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-message-detail__contact-container">
|
||||
{contacts.map(contact => this.renderContact(contact))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { errors, message, receivedAt, sentAt, i18n } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-message-detail">
|
||||
<div className="module-message-detail__message-container">
|
||||
<Message i18n={i18n} {...message} />
|
||||
</div>
|
||||
<table className="module-message-detail__info">
|
||||
<tbody>
|
||||
{(errors || []).map(error => (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('error')}
|
||||
</td>
|
||||
<td>
|
||||
{' '}
|
||||
<span className="error-message">{error.message}</span>{' '}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="module-message-detail__label">{i18n('sent')}</td>
|
||||
<td>
|
||||
{moment(sentAt).format('LLLL')}{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({sentAt})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{receivedAt ? (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('received')}
|
||||
</td>
|
||||
<td>
|
||||
{moment(receivedAt).format('LLLL')}{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({receivedAt})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{message.direction === 'incoming' ? i18n('from') : i18n('to')}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{this.renderContacts()}
|
||||
{this.renderDeleteButton()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
### Timer change
|
||||
|
||||
```jsx
|
||||
const fromOther = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: '+12025550003',
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
source: '+12025550003',
|
||||
},
|
||||
});
|
||||
const fromUpdate = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: util.ourNumber,
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
fromSync: true,
|
||||
source: util.ourNumber,
|
||||
},
|
||||
});
|
||||
const fromMe = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: util.ourNumber,
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
source: util.ourNumber,
|
||||
},
|
||||
});
|
||||
const View = Whisper.ExpirationTimerUpdateView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: fromOther }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: fromUpdate }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: fromMe }} />
|
||||
<Notification type="timerUpdate" onClick={() => console.log('onClick')} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Safety number change
|
||||
|
||||
```js
|
||||
const incoming = new Whisper.Message({
|
||||
type: 'keychange',
|
||||
sent_at: Date.now() - 200000,
|
||||
key_changed: '+12025550003',
|
||||
});
|
||||
const View = Whisper.KeyChangeView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: incoming }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Marking as verified
|
||||
|
||||
```js
|
||||
const fromPrimary = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
verified: true,
|
||||
});
|
||||
const local = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
local: true,
|
||||
verified: true,
|
||||
});
|
||||
|
||||
const View = Whisper.VerifiedChangeView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: local }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Marking as not verified
|
||||
|
||||
```js
|
||||
const fromPrimary = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
});
|
||||
const local = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
local: true,
|
||||
});
|
||||
|
||||
const View = Whisper.VerifiedChangeView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: fromPrimary }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: local }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### Group update
|
||||
|
||||
```js
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
group_update: {
|
||||
joined: ['+12025550007', '+12025550008', '+12025550009'],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(
|
||||
Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
})
|
||||
);
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: incoming }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
||||
|
||||
### End session
|
||||
|
||||
```js
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
|
||||
});
|
||||
const incoming = new Whisper.Message(
|
||||
Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
})
|
||||
);
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={{ model: incoming }} />
|
||||
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
|
||||
</util.ConversationContext>;
|
||||
```
|
|
@ -1,32 +0,0 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export class Notification extends React.Component<Props> {
|
||||
public renderContents() {
|
||||
const { type } = this.props;
|
||||
|
||||
return <span>Notification of type {type}</span>;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { onClick } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'module-notification',
|
||||
onClick ? 'module-notification--with-click-handler' : null
|
||||
)}
|
||||
>
|
||||
{this.renderContents()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -6,15 +6,16 @@ import classNames from 'classnames';
|
|||
import * as MIME from '../../../ts/types/MIME';
|
||||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
||||
|
||||
import { Emojify } from './Emojify';
|
||||
import { MessageBody } from './MessageBody';
|
||||
import { Localizer } from '../../types/Util';
|
||||
import { Color, Localizer } from '../../types/Util';
|
||||
import { ContactName } from './ContactName';
|
||||
|
||||
interface Props {
|
||||
attachments: Array<QuotedAttachment>;
|
||||
color: string;
|
||||
attachment?: QuotedAttachment;
|
||||
authorPhoneNumber: string;
|
||||
authorProfileName?: string;
|
||||
authorTitle: string;
|
||||
authorName?: string;
|
||||
authorColor: Color;
|
||||
i18n: Localizer;
|
||||
isFromMe: boolean;
|
||||
isIncoming: boolean;
|
||||
|
@ -43,7 +44,7 @@ function validateQuote(quote: Props): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (quote.attachments && quote.attachments.length > 0) {
|
||||
if (quote.attachment) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -124,14 +125,13 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderGenericFile() {
|
||||
const { attachments } = this.props;
|
||||
const { attachment } = this.props;
|
||||
|
||||
if (!attachments || !attachments.length) {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { fileName, contentType } = first;
|
||||
const { fileName, contentType } = attachment;
|
||||
const isGenericFile =
|
||||
!GoogleChrome.isVideoTypeSupported(contentType) &&
|
||||
!GoogleChrome.isImageTypeSupported(contentType) &&
|
||||
|
@ -150,13 +150,12 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderIconContainer() {
|
||||
const { attachments, i18n } = this.props;
|
||||
if (!attachments || attachments.length === 0) {
|
||||
const { attachment, i18n } = this.props;
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { contentType, thumbnail } = first;
|
||||
const { contentType, thumbnail } = attachment;
|
||||
const objectUrl = getObjectUrl(thumbnail);
|
||||
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
|
@ -177,7 +176,7 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderText() {
|
||||
const { i18n, text, attachments } = this.props;
|
||||
const { i18n, text, attachment } = this.props;
|
||||
|
||||
if (text) {
|
||||
return (
|
||||
|
@ -187,12 +186,11 @@ export class Quote extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { contentType, isVoiceMessage } = first;
|
||||
const { contentType, isVoiceMessage } = attachment;
|
||||
|
||||
const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage });
|
||||
if (typeLabel) {
|
||||
|
@ -231,29 +229,44 @@ export class Quote extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public renderAuthor() {
|
||||
const { authorProfileName, authorTitle, i18n, isFromMe } = this.props;
|
||||
|
||||
const authorProfileElement = authorProfileName ? (
|
||||
<span className="module-quote__primary__profile-name">
|
||||
~<Emojify text={authorProfileName} i18n={i18n} />
|
||||
</span>
|
||||
) : null;
|
||||
const {
|
||||
authorProfileName,
|
||||
authorPhoneNumber,
|
||||
authorName,
|
||||
authorColor,
|
||||
i18n,
|
||||
isFromMe,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-quote__primary__author">
|
||||
<div
|
||||
className={classNames(
|
||||
'module-quote__primary__author',
|
||||
!isFromMe ? `module-quote__primary__author--${authorColor}` : null
|
||||
)}
|
||||
>
|
||||
{isFromMe ? (
|
||||
i18n('you')
|
||||
) : (
|
||||
<span>
|
||||
<Emojify text={authorTitle} i18n={i18n} /> {authorProfileElement}
|
||||
</span>
|
||||
<ContactName
|
||||
phoneNumber={authorPhoneNumber}
|
||||
name={authorName}
|
||||
profileName={authorProfileName}
|
||||
i18n={i18n}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { color, isIncoming, onClick, withContentAbove } = this.props;
|
||||
const {
|
||||
authorColor,
|
||||
isFromMe,
|
||||
isIncoming,
|
||||
onClick,
|
||||
withContentAbove,
|
||||
} = this.props;
|
||||
|
||||
if (!validateQuote(this.props)) {
|
||||
return null;
|
||||
|
@ -266,7 +279,10 @@ export class Quote extends React.Component<Props> {
|
|||
className={classNames(
|
||||
'module-quote',
|
||||
isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing',
|
||||
!isIncoming ? `module-quote--outgoing-${color}` : null,
|
||||
!isIncoming && !isFromMe
|
||||
? `module-quote--outgoing-${authorColor}`
|
||||
: null,
|
||||
!isIncoming && isFromMe ? 'module-quote--outgoing-you' : null,
|
||||
!onClick ? 'module-quote--no-click' : null,
|
||||
withContentAbove ? 'module-quote--with-content-above' : null
|
||||
)}
|
||||
|
|
7
ts/components/conversation/ResetSessionNotification.md
Normal file
7
ts/components/conversation/ResetSessionNotification.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
### End session
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<ResetSessionNotification i18n={util.i18n} />
|
||||
</util.ConversationContext>
|
||||
```
|
19
ts/components/conversation/ResetSessionNotification.tsx
Normal file
19
ts/components/conversation/ResetSessionNotification.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class ResetSessionNotification extends React.Component<Props> {
|
||||
public render() {
|
||||
const { i18n } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-reset-session-notification">
|
||||
{i18n('sessionEnded')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
25
ts/components/conversation/SafetyNumberNotification.md
Normal file
25
ts/components/conversation/SafetyNumberNotification.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
### In group conversation
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<SafetyNumberNotification
|
||||
i18n={util.i18n}
|
||||
isGroup={true}
|
||||
contact={{ phoneNumber: '(202) 500-1000', profileName: 'Mr. Fire' }}
|
||||
onVerify={() => console.log('onVerify')}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### In one-on-one conversation
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<SafetyNumberNotification
|
||||
i18n={util.i18n}
|
||||
isGroup={false}
|
||||
contact={{ phoneNumber: '(202) 500-1000', profileName: 'Mr. Fire' }}
|
||||
onVerify={() => console.log('onVerify')}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
58
ts/components/conversation/SafetyNumberNotification.tsx
Normal file
58
ts/components/conversation/SafetyNumberNotification.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isGroup: boolean;
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
onVerify: () => void;
|
||||
}
|
||||
|
||||
export class SafetyNumberNotification extends React.Component<Props> {
|
||||
public render() {
|
||||
const { contact, isGroup, i18n, onVerify } = this.props;
|
||||
|
||||
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'}
|
||||
components={[
|
||||
<span
|
||||
key="external-1"
|
||||
className="module-safety-number-notification__contact"
|
||||
>
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
module="module-verification-notification__contact"
|
||||
/>
|
||||
</span>,
|
||||
]}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
onClick={onVerify}
|
||||
className="module-verification-notification__button"
|
||||
>
|
||||
{i18n('verifyNewNumber')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
39
ts/components/conversation/TimerNotification.md
Normal file
39
ts/components/conversation/TimerNotification.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
### From other
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<TimerNotification
|
||||
type="fromOther"
|
||||
phoneNumber="(202) 555-1000"
|
||||
profileName="Mr. Fire"
|
||||
timespan="1 hour"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### You changed
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<TimerNotification
|
||||
type="fromMe"
|
||||
phoneNumber="(202) 555-1000"
|
||||
timespan="1 hour"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Changed via sync
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<TimerNotification
|
||||
type="fromSync"
|
||||
phoneNumber="(202) 555-1000"
|
||||
timespan="1 hour"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
67
ts/components/conversation/TimerNotification.tsx
Normal file
67
ts/components/conversation/TimerNotification.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
interface Props {
|
||||
type: 'fromOther' | 'fromMe' | 'fromSync';
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
timespan: string;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class TimerNotification extends React.Component<Props> {
|
||||
public renderContents() {
|
||||
const { i18n, name, phoneNumber, profileName, timespan, type } = this.props;
|
||||
|
||||
switch (type) {
|
||||
case 'fromOther':
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="theyChangedTheTimer"
|
||||
components={[
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
key="external-1"
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
name={name}
|
||||
/>,
|
||||
timespan,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case 'fromMe':
|
||||
return i18n('youChangedTheTimer', [timespan]);
|
||||
case 'fromSync':
|
||||
return i18n('timerSetOnSync', [timespan]);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { timespan } = this.props;
|
||||
|
||||
return (
|
||||
<div className="module-timer-notification">
|
||||
<div className="module-timer-notification__icon-container">
|
||||
<div className="module-timer-notification__icon" />
|
||||
<div className="module-timer-notification__icon-label">
|
||||
{timespan}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-timer-notification__message">
|
||||
{this.renderContents()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
167
ts/components/conversation/Timestamp.md
Normal file
167
ts/components/conversation/Timestamp.md
Normal file
|
@ -0,0 +1,167 @@
|
|||
### All major transitions
|
||||
|
||||
```jsx
|
||||
function get1201() {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 1, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
function getYesterday1159() {
|
||||
return get1201() - 2 * 60 * 1000;
|
||||
}
|
||||
function getJanuary1201() {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getFullYear(), 0, 1, 0, 1);
|
||||
return d.getTime();
|
||||
}
|
||||
function getDecember1159() {
|
||||
return getJanuary1201() - 2 * 60 * 1000;
|
||||
}
|
||||
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 500}
|
||||
text="500ms ago - all below 1 minute are 'now'"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 5 * 1000}
|
||||
text="Five seconds ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 30 * 1000}
|
||||
text="30 seconds ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 60 * 1000}
|
||||
text="One minute ago - in minutes"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 30 * 60 * 1000}
|
||||
text="30 minutes ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 45 * 60 * 1000}
|
||||
text="45 minutes ago (used to round up to 1 hour with moment)"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 60 * 60 * 1000}
|
||||
text="One hour ago - in hours"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={get1201()}
|
||||
text="12:01am today"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={getYesterday1159()}
|
||||
text="11:59pm yesterday - adds day name"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 24 * 60 * 60 * 1000}
|
||||
text="24 hours ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 2 * 24 * 60 * 60 * 1000}
|
||||
text="Two days ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={Date.now() - 7 * 24 * 60 * 60 * 1000}
|
||||
text="Seven days ago - adds month"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 30 * 24 * 60 * 60 * 1000}
|
||||
text="Thirty days ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={getJanuary1201()}
|
||||
text="January 1st at 12:01am"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="red"
|
||||
timestamp={getDecember1159()}
|
||||
text="December 31st at 11:59pm - adds year"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
direction="incoming"
|
||||
authorColor="teal"
|
||||
timestamp={Date.now() - 366 * 24 * 60 * 60 * 1000}
|
||||
text="One year ago"
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</li>
|
||||
</util.ConversationContext>;
|
||||
```
|
66
ts/components/conversation/Timestamp.tsx
Normal file
66
ts/components/conversation/Timestamp.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
|
||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
||||
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
interface Props {
|
||||
timestamp: number;
|
||||
withImageNoCaption: boolean;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
module?: string;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
const UPDATE_FREQUENCY = 60 * 1000;
|
||||
|
||||
export class Timestamp extends React.Component<Props> {
|
||||
private interval: any;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const update = () => {
|
||||
this.setState({
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
};
|
||||
this.interval = setInterval(update, UPDATE_FREQUENCY);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
direction,
|
||||
i18n,
|
||||
module,
|
||||
timestamp,
|
||||
withImageNoCaption,
|
||||
} = this.props;
|
||||
const moduleName = module || 'module-timestamp';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
moduleName,
|
||||
`${moduleName}--${direction}`,
|
||||
withImageNoCaption ? `${moduleName}--with-image-no-caption` : null
|
||||
)}
|
||||
title={moment(timestamp).format('llll')}
|
||||
>
|
||||
{formatRelativeTime(timestamp, { i18n, extended: true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
49
ts/components/conversation/VerificationNotification.md
Normal file
49
ts/components/conversation/VerificationNotification.md
Normal file
|
@ -0,0 +1,49 @@
|
|||
### Marking as verified
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<VerificationNotification
|
||||
type="markVerified"
|
||||
isLocal={true}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<VerificationNotification
|
||||
type="markVerified"
|
||||
isLocal={false}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Marking as not verified
|
||||
|
||||
```js
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<VerificationNotification
|
||||
type="markNotVerified"
|
||||
isLocal={true}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
<VerificationNotification
|
||||
type="markNotVerified"
|
||||
isLocal={false}
|
||||
contact={{
|
||||
phoneNumber: '(202) 555-0003',
|
||||
profileName: 'Mr. Fire',
|
||||
}}
|
||||
i18n={util.i18n}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
75
ts/components/conversation/VerificationNotification.tsx
Normal file
75
ts/components/conversation/VerificationNotification.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
// import classNames from 'classnames';
|
||||
|
||||
import { ContactName } from './ContactName';
|
||||
import { Intl } from '../Intl';
|
||||
import { Localizer } from '../../types/Util';
|
||||
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
|
||||
interface Contact {
|
||||
phoneNumber: string;
|
||||
profileName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
type: 'markVerified' | 'markNotVerified';
|
||||
isLocal: boolean;
|
||||
contact: Contact;
|
||||
i18n: Localizer;
|
||||
}
|
||||
|
||||
export class VerificationNotification extends React.Component<Props> {
|
||||
public getStringId() {
|
||||
const { isLocal, type } = this.props;
|
||||
|
||||
switch (type) {
|
||||
case 'markVerified':
|
||||
return isLocal
|
||||
? 'youMarkedAsVerified'
|
||||
: 'youMarkedAsVerifiedOtherDevice';
|
||||
case 'markNotVerified':
|
||||
return isLocal
|
||||
? 'youMarkedAsNotVerified'
|
||||
: 'youMarkedAsNotVerifiedOtherDevice';
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public renderContents() {
|
||||
const { contact, i18n } = this.props;
|
||||
const id = this.getStringId();
|
||||
|
||||
return (
|
||||
<Intl
|
||||
id={id}
|
||||
components={[
|
||||
<ContactName
|
||||
i18n={i18n}
|
||||
key="external-1"
|
||||
name={contact.name}
|
||||
profileName={contact.profileName}
|
||||
phoneNumber={contact.phoneNumber}
|
||||
module="module-verification-notification__contact"
|
||||
/>,
|
||||
]}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { type } = this.props;
|
||||
const suffix =
|
||||
type === 'markVerified' ? 'mark-verified' : 'mark-not-verified';
|
||||
|
||||
return (
|
||||
<div className="module-verification-notification">
|
||||
<div className={`module-verification-notification__icon--${suffix}`} />
|
||||
{this.renderContents()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
```jsx
|
||||
const messages = [
|
||||
{
|
||||
id: '1',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.json',
|
||||
|
@ -10,6 +11,7 @@ const messages = [
|
|||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'bar.txt',
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 300,
|
||||
height: 200,
|
||||
}}
|
||||
>
|
||||
<EmptyState label="You have no attachments with media" />
|
||||
<EmptyState label={util.i18n('mediaEmptyState')} />
|
||||
</div>
|
||||
```
|
||||
|
||||
|
@ -24,6 +24,6 @@
|
|||
height: 500,
|
||||
}}
|
||||
>
|
||||
<EmptyState label="You have no documents with media" />
|
||||
<EmptyState label={util.i18n('documentsEmptyState')} />
|
||||
</div>
|
||||
```
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
### Media gallery with media and documents
|
||||
|
||||
```jsx
|
||||
const _ = util._;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MONTH_MS = 30 * DAY_MS;
|
||||
const YEAR_MS = 12 * MONTH_MS;
|
||||
|
@ -81,7 +82,14 @@ const messages = _.sortBy(
|
|||
```jsx
|
||||
const messages = [
|
||||
{
|
||||
attachments: [{ fileName: 'foo.jpg', contentType: 'application/json' }],
|
||||
id: '1',
|
||||
objectURL: 'https://placekitten.com/76/67',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.jpg',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
<MediaGallery i18n={util.i18n} media={messages} documents={messages} />;
|
||||
|
|
30
ts/components/conversation/media-gallery/MediaGridItem.md
Normal file
30
ts/components/conversation/media-gallery/MediaGridItem.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
## With image
|
||||
|
||||
```jsx
|
||||
const message = {
|
||||
id: '1',
|
||||
objectURL: 'https://placekitten.com/76/67',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.jpg',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
};
|
||||
<MediaGridItem i18n={util.i18n} message={message} />;
|
||||
```
|
||||
|
||||
## Without image
|
||||
|
||||
```jsx
|
||||
const message = {
|
||||
id: '1',
|
||||
attachments: [
|
||||
{
|
||||
fileName: 'foo.jpg',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
],
|
||||
};
|
||||
<MediaGridItem i18n={util.i18n} message={message} />;
|
||||
```
|
|
@ -1,17 +0,0 @@
|
|||
Rendering a real `Whisper.MessageView` using `<util.ConversationContext />` and
|
||||
`<util.BackboneWrapper />`.
|
||||
|
||||
```jsx
|
||||
const model = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'text',
|
||||
sent_at: Date.now() - 5000,
|
||||
});
|
||||
const View = Whisper.MessageView;
|
||||
const options = {
|
||||
model,
|
||||
};
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper View={View} options={options} />
|
||||
</util.ConversationContext>;
|
||||
```
|
|
@ -1,68 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
/** The View class, which will be instantiated then treated like a Backbone View */
|
||||
readonly View: BackboneViewConstructor;
|
||||
/** Options to be passed along to the view when constructed */
|
||||
readonly options: object;
|
||||
}
|
||||
|
||||
interface BackboneView {
|
||||
remove: () => void;
|
||||
render: () => void;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
interface BackboneViewConstructor {
|
||||
new (options: object): BackboneView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows Backbone Views to be rendered inside of React (primarily for the Style Guide)
|
||||
* while we slowly replace the internals of a given Backbone view with React.
|
||||
*/
|
||||
export class BackboneWrapper extends React.Component<Props> {
|
||||
protected el: HTMLElement | null = null;
|
||||
protected view: BackboneView | null = null;
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.teardown();
|
||||
}
|
||||
|
||||
public shouldComponentUpdate() {
|
||||
// we're handling all updates manually
|
||||
return false;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return <div ref={this.setEl} />;
|
||||
}
|
||||
|
||||
protected setEl = (element: HTMLDivElement | null) => {
|
||||
this.el = element;
|
||||
this.setup();
|
||||
};
|
||||
|
||||
protected setup = () => {
|
||||
const { View, options } = this.props;
|
||||
|
||||
if (!this.el) {
|
||||
return;
|
||||
}
|
||||
this.view = new View(options);
|
||||
this.view.render();
|
||||
|
||||
// It's important to let the view create its own root DOM element. This ensures that
|
||||
// its tagName property actually takes effect.
|
||||
this.el.appendChild(this.view.el);
|
||||
};
|
||||
|
||||
protected teardown() {
|
||||
if (!this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.view.remove();
|
||||
this.view = null;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue