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:
Scott Nonnenberg 2018-07-09 14:29:13 -07:00
parent 69f11c4a7b
commit 3c69886320
102 changed files with 9644 additions and 7381 deletions

61
ts/components/Intl.md Normal file
View 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
View 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;
}
}

View file

@ -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' }],

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View 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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
### End session
```js
<util.ConversationContext theme={util.theme}>
<ResetSessionNotification i18n={util.i18n} />
</util.ConversationContext>
```

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

127
ts/selectors/message.ts Normal file
View file

@ -0,0 +1,127 @@
export function messageSelector({ model, view }: { model: any; view: any }) {
// tslint:disable-next-line
console.log({ model, view });
return null;
// const avatar = this.model.getAvatar();
// const avatarPath = avatar && avatar.url;
// const color = avatar && avatar.color;
// const isMe = this.ourNumber === this.model.id;
// const attachments = this.model.get('attachments') || [];
// const loadedAttachmentViews = Promise.all(
// attachments.map(
// attachment =>
// new Promise(async resolve => {
// const attachmentWithData = await loadAttachmentData(attachment);
// const view = new Whisper.AttachmentView({
// model: attachmentWithData,
// timestamp: this.model.get('sent_at'),
// });
// this.listenTo(view, 'update', () => {
// // NOTE: Can we do without `updated` flag now that we use promises?
// view.updated = true;
// resolve(view);
// });
// view.render();
// })
// )
// );
// Wiring up TimerNotification
// this.conversation = this.model.getExpirationTimerUpdateSource();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// this.listenTo(this.model, 'change', this.onChange);
// Wiring up SafetyNumberNotification
// this.conversation = this.model.getModelForKeyChange();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// Wiring up VerificationNotification
// this.conversation = this.model.getModelForVerifiedChange();
// this.listenTo(this.conversation, 'change', this.render);
// this.listenTo(this.model, 'unload', this.remove);
// this.contactView = new Whisper.ReactWrapperView({
// className: 'contact-wrapper',
// Component: window.Signal.Components.ContactListItem,
// props: {
// isMe,
// color,
// avatarPath,
// phoneNumber: model.getNumber(),
// name: model.getName(),
// profileName: model.getProfileName(),
// verified: model.isVerified(),
// onClick: showIdentity,
// },
// });
// this.$el.append(this.contactView.el);
}
// We actually don't listen to the model telling us that it's gone if it's disappearing
// onDestroy() {
// if (this.$el.hasClass('expired')) {
// return;
// }
// this.onUnload();
// },
// The backflips required to maintain scroll position when loading images
// Key is only adding the img to the DOM when the image has loaded.
//
// How might we get similar behavior with React?
//
// this.trigger('beforeChangeHeight');
// this.$('.attachments').append(view.el);
// view.setElement(view.el);
// this.trigger('afterChangeHeight');
// Timer code
// if (this.model.isExpired()) {
// return this;
// }
// if (this.model.isExpiring()) {
// this.render();
// const totalTime = this.model.get('expireTimer') * 1000;
// const remainingTime = this.model.msTilExpire();
// const elapsed = (totalTime - remainingTime) / totalTime;
// this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
// this.$el.css('display', 'inline-block');
// this.timeout = setTimeout(
// this.update.bind(this),
// Math.max(totalTime / 100, 500)
// );
// }
// Expiring message
// this.$el.addClass('expired');
// this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
// if (e.target === this.$('.bubble')[0]) {
// this.remove();
// }
// });
// // Failsafe: if in the background, animation events don't fire
// setTimeout(this.remove.bind(this), 1000);
// Retrying a message
// retryMessage() {
// const retrys = _.filter(
// this.model.get('errors'),
// this.model.isReplayableError.bind(this.model)
// );
// _.map(retrys, 'number').forEach(number => {
// this.model.resend(number);
// });
// },

View file

@ -1,22 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { default as _, padStart, sample } from 'lodash';
import libphonenumber from 'google-libphonenumber';
import moment from 'moment';
import QueryString from 'qs';
export { _ };
// Helper components used in the Style Guide, exposed at 'util' in the global scope via
// the 'context' option in react-styleguidist.
// This file provides helpers for the Style Guide, exposed at 'util' in the global scope
// via the 'context' option in react-styleguidist.
import { default as _ } from 'lodash';
export { ConversationContext } from './ConversationContext';
export { BackboneWrapper } from '../components/utility/BackboneWrapper';
// @ts-ignore
import * as Signal from '../../js/modules/signal';
import { SignalService } from '../protobuf';
export { _ };
// TypeScript wants two things when you import:
// 1) a normal typescript file
@ -25,6 +15,7 @@ import { SignalService } from '../protobuf';
// @ts-ignore
import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif';
// 320x240
const gifObjectUrl = makeObjectUrl(gif, 'image/gif');
// @ts-ignore
import mp3 from '../../fixtures/incompetech-com-Agnus-Dei-X.mp3';
@ -37,6 +28,7 @@ import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4');
// @ts-ignore
import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png';
// 800×1200
const pngObjectUrl = makeObjectUrl(png, 'image/png');
// @ts-ignore
@ -63,9 +55,6 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
return URL.createObjectURL(blob);
}
const ourNumber = '+12025559999';
const groupNumber = '+12025550099';
export {
mp3,
mp3ObjectUrl,
@ -87,13 +76,8 @@ export {
landscapeRedObjectUrl,
portraitTeal,
portraitTealObjectUrl,
ourNumber,
groupNumber,
};
// Required, or TypeScript complains about adding keys to window
const parent = window as any;
const query = window.location.search.replace(/^\?/, '');
const urlOptions = QueryString.parse(query);
const theme = urlOptions.theme || 'light-theme';
@ -104,123 +88,10 @@ import localeMessages from '../../_locales/en/messages.json';
// @ts-ignore
import { setup } from '../../js/modules/i18n';
import fileSize from 'filesize';
const i18n = setup(locale, localeMessages);
parent.filesize = fileSize;
parent.i18n = i18n;
parent.React = React;
parent.ReactDOM = ReactDOM;
parent.moment = moment;
parent.moment.updateLocale(locale, {
relativeTime: {
h: parent.i18n('timestamp_h'),
m: parent.i18n('timestamp_m'),
s: parent.i18n('timestamp_s'),
},
});
parent.moment.locale(locale);
export { theme, locale, i18n };
// Used by signal.js to set up code that deals with message attachments/avatars
const Attachments = {
createAbsolutePathGetter: () => () => '/fake/path',
createDeleter: () => async () => undefined,
createReader: () => async () => new ArrayBuffer(10),
createWriterForExisting: () => async () => '/fake/path',
createWriterForNew: () => async () => ({
data: new ArrayBuffer(10),
path: '/fake/path',
}),
getPath: (path: string) => path,
};
parent.Signal = Signal.setup({
Attachments,
userDataPath: '/',
// tslint:disable-next-line:no-backbone-get-set-outside-model
getRegionCode: () => parent.storage.get('regionCode'),
});
parent.SignalService = SignalService;
parent.ConversationController._initialFetchComplete = true;
parent.ConversationController._initialPromise = Promise.resolve();
const COLORS = [
'red',
'pink',
'purple',
'deep_purple',
'indigo',
'blue',
'light_blue',
'cyan',
'teal',
'green',
'light_green',
'orange',
'deep_orange',
'amber',
'blue_grey',
'grey',
'default',
];
const CONTACTS = COLORS.map((color, index) => {
const title = `${sample(['Mr.', 'Mrs.', 'Ms.', 'Unknown'])} ${color} 🔥`;
const key = sample(['name', 'profileName']) as string;
const id = `+1202555${padStart(index.toString(), 4, '0')}`;
const contact = {
color,
[key]: title,
id,
type: 'private',
};
return parent.ConversationController.dangerouslyCreateAndAdd(contact);
});
const me = parent.ConversationController.dangerouslyCreateAndAdd({
id: ourNumber,
name: 'Me!',
type: 'private',
color: 'light_blue',
});
const group = parent.ConversationController.dangerouslyCreateAndAdd({
id: groupNumber,
name: 'A place for sharing cats',
type: 'group',
});
group.contactCollection.add(me);
group.contactCollection.add(CONTACTS[0]);
group.contactCollection.add(CONTACTS[1]);
group.contactCollection.add(CONTACTS[2]);
export { COLORS, CONTACTS, me, group };
parent.textsecure.storage.user.getNumber = () => ourNumber;
parent.textsecure.messaging = {
getProfile: async (phoneNumber: string): Promise<boolean> => {
if (parent.ConversationController.get(phoneNumber)) {
return true;
}
throw new Error('User does not have Signal account');
},
};
parent.libphonenumber = libphonenumber.PhoneNumberUtil.getInstance();
parent.libphonenumber.PhoneNumberFormat = libphonenumber.PhoneNumberFormat;
parent.storage.put('regionCode', 'US');
// Telling Lodash to relinquish _ for use by underscore
// @ts-ignore
_.noConflict();

View file

@ -70,10 +70,19 @@ export function contactSelector(
contact: Contact,
options: {
regionCode: string;
hasSignalAccount: boolean;
getAbsoluteAttachmentPath: (path: string) => string;
onSendMessage: () => void;
onClick: () => void;
}
) {
const { regionCode, getAbsoluteAttachmentPath } = options;
const {
getAbsoluteAttachmentPath,
hasSignalAccount,
onClick,
onSendMessage,
regionCode,
} = options;
let { avatar } = contact;
if (avatar && avatar.avatar && avatar.avatar.path) {
@ -88,6 +97,9 @@ export function contactSelector(
return {
...contact,
hasSignalAccount,
onSendMessage,
onClick,
avatar,
number:
contact.number &&

View file

@ -6,3 +6,15 @@ export type RenderTextCallback = (
) => JSX.Element | string;
export type Localizer = (key: string, values?: Array<string>) => string;
export type Color =
| 'gray'
| 'blue'
| 'cyan'
| 'deep_orange'
| 'green'
| 'indigo'
| 'pink'
| 'purple'
| 'red'
| 'teal';

View file

@ -1,5 +1,6 @@
import * as GoogleChrome from './GoogleChrome';
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
import { missingCaseError } from './missingCaseError';
import { migrateColor } from './migrateColor';
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError };
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, migrateColor };