Message Requests

This commit is contained in:
Ken Powers 2020-05-27 17:37:06 -04:00 committed by Scott Nonnenberg
parent 4d4b7a26a5
commit 83574eb067
60 changed files with 2566 additions and 216 deletions

94
ts/RemoteConfig.ts Normal file
View file

@ -0,0 +1,94 @@
// tslint:disable: no-backbone-get-set-outside-model
import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType = 'desktop.messageRequests';
type ConfigValueType = {
name: ConfigKeyType;
enabled: boolean;
enabledAt?: number;
};
type ConfigMapType = { [key: string]: ConfigValueType };
type ConfigListenerType = (value: ConfigValueType) => unknown;
type ConfigListenersMapType = {
[key: string]: Array<ConfigListenerType>;
};
function getServer(): WebAPIType {
const OLD_USERNAME = window.storage.get<string>('number_id');
const USERNAME = window.storage.get<string>('uuid_id');
const PASSWORD = window.storage.get<string>('password');
return window.WebAPI.connect({
username: (USERNAME || OLD_USERNAME) as string,
password: PASSWORD as string,
});
}
let config: ConfigMapType = {};
const listeners: ConfigListenersMapType = {};
export async function initRemoteConfig() {
config = window.storage.get('remoteConfig') || {};
await maybeRefreshRemoteConfig();
}
export function onChange(key: ConfigKeyType, fn: ConfigListenerType) {
const keyListeners: Array<ConfigListenerType> = get(listeners, key, []);
keyListeners.push(fn);
listeners[key] = keyListeners;
return () => {
listeners[key] = listeners[key].filter(l => l !== fn);
};
}
const refreshRemoteConfig = async () => {
const now = Date.now();
const server = getServer();
const newConfig = await server.getConfig();
// Process new configuration in light of the old configuration
// The old configuration is not set as the initial value in reduce because
// flags may have been deleted
const oldConfig = config;
config = newConfig.reduce((previous, { name, enabled }) => {
const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false);
// If a flag was previously not enabled and is now enabled, record the time it was enabled
const enabledAt: number | undefined =
previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']);
const value = {
name: name as ConfigKeyType,
enabled,
enabledAt,
};
// If enablement changes at all, notify listeners
const currentListeners = listeners[name] || [];
if (previouslyEnabled !== enabled) {
currentListeners.forEach(listener => {
listener(value);
});
}
// Return new configuration object
return {
...previous,
[name]: value,
};
}, {});
window.storage.put('remoteConfig', config);
};
export const maybeRefreshRemoteConfig = throttle(
refreshRemoteConfig,
// Only fetch remote configuration if the last fetch was more than two hours ago
2 * 60 * 60 * 1000,
{ trailing: false }
);
export function isEnabled(name: ConfigKeyType): boolean {
return get(config, [name, 'enabled'], false);
}

View file

@ -2,6 +2,13 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="blue"
avatarPath={util.gifObjectUrl}
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={80}
color="blue"
@ -90,6 +97,13 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="blue"
name="One"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={80}
color="blue"
@ -143,6 +157,14 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="pink"
noteToSelf={true}
phoneNumber="(555) 353-3433"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={80}
color="pink"
@ -174,6 +196,7 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar size={112} color="blue" conversationType="group" i18n={util.i18n} />
<Avatar size={80} color="blue" conversationType="group" i18n={util.i18n} />
<Avatar size={52} color="blue" conversationType="group" i18n={util.i18n} />
<Avatar size={28} color="blue" conversationType="group" i18n={util.i18n} />
@ -184,6 +207,7 @@
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar size={112} color="blue" conversationType="direct" i18n={util.i18n} />
<Avatar size={80} color="blue" conversationType="direct" i18n={util.i18n} />
<Avatar size={52} color="blue" conversationType="direct" i18n={util.i18n} />
<Avatar size={28} color="blue" conversationType="direct" i18n={util.i18n} />
@ -361,6 +385,43 @@
</util.ConversationContext>
```
### 112px
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<Avatar
size={112}
color="teal"
name="John Smith"
avatarPath={util.gifObjectUrl}
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={112}
color="teal"
name="John"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar
size={112}
color="teal"
name="John Smith"
conversationType="direct"
i18n={util.i18n}
/>
<Avatar size={112} color="teal" conversationType="direct" i18n={util.i18n} />
<Avatar
size={112}
color="teal"
name="Pupplies"
conversationType="group"
i18n={util.i18n}
/>
</util.ConversationContext>
```
### Broken color
```jsx

View file

@ -1,10 +1,10 @@
import React from 'react';
import * as React from 'react';
import classNames from 'classnames';
import { getInitials } from '../util/getInitials';
import { ColorType, LocalizerType } from '../types/Util';
export interface Props {
export type Props = {
avatarPath?: string;
color?: ColorType;
@ -13,7 +13,7 @@ export interface Props {
name?: string;
phoneNumber?: string;
profileName?: string;
size: 28 | 32 | 52 | 80;
size: 28 | 32 | 52 | 80 | 112;
onClick?: () => unknown;
@ -21,7 +21,7 @@ export interface Props {
innerRef?: React.Ref<HTMLDivElement>;
i18n: LocalizerType;
}
} & Pick<React.HTMLProps<HTMLDivElement>, 'className'>;
interface State {
imageBroken: boolean;
@ -139,12 +139,13 @@ export class Avatar extends React.Component<Props, State> {
noteToSelf,
onClick,
size,
className,
} = this.props;
const { imageBroken } = this.state;
const hasImage = !noteToSelf && avatarPath && !imageBroken;
if (![28, 32, 52, 80].includes(size)) {
if (![28, 32, 52, 80, 112].includes(size)) {
throw new Error(`Size ${size} is not supported!`);
}
@ -166,7 +167,8 @@ export class Avatar extends React.Component<Props, State> {
'module-avatar',
`module-avatar--${size}`,
hasImage ? 'module-avatar--with-image' : 'module-avatar--no-image',
!hasImage ? `module-avatar--${color}` : null
!hasImage ? `module-avatar--${color}` : null,
className
)}
ref={innerRef}
>

View file

@ -16,11 +16,17 @@ import {
InputApi,
Props as CompositionInputProps,
} from './CompositionInput';
import {
MessageRequestActions,
Props as MessageRequestActionsProps,
} from './conversation/MessageRequestActions';
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly messageRequestsEnabled?: boolean;
readonly acceptedMessageRequest?: boolean;
readonly compositionApi?: React.MutableRefObject<{
focusInput: () => void;
isDirty: () => boolean;
@ -66,6 +72,7 @@ export type Props = Pick<
| 'showPickerHint'
| 'clearShowPickerHint'
> &
MessageRequestActionsProps &
OwnProps;
const emptyElement = (el: HTMLElement) => {
@ -73,7 +80,7 @@ const emptyElement = (el: HTMLElement) => {
el.innerHTML = '';
};
// tslint:disable-next-line max-func-body-length
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
export const CompositionArea = ({
i18n,
attachmentListEl,
@ -86,6 +93,8 @@ export const CompositionArea = ({
onEditorStateChange,
onTextTooLong,
startingText,
clearQuotedMessage,
getQuotedMessage,
// EmojiButton
onPickEmoji,
onSetSkinTone,
@ -104,8 +113,19 @@ export const CompositionArea = ({
clearShowIntroduction,
showPickerHint,
clearShowPickerHint,
clearQuotedMessage,
getQuotedMessage,
// Message Requests
messageRequestsEnabled,
acceptedMessageRequest,
conversationType,
isBlocked,
name,
onAccept,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
profileName,
phoneNumber,
}: Props) => {
const [disabled, setDisabled] = React.useState(false);
const [showMic, setShowMic] = React.useState(!startingText);
@ -299,6 +319,24 @@ export const CompositionArea = ({
};
}, [setLarge]);
if ((!acceptedMessageRequest || isBlocked) && messageRequestsEnabled) {
return (
<MessageRequestActions
i18n={i18n}
conversationType={conversationType}
isBlocked={isBlocked}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={onUnblock}
onDelete={onDelete}
onAccept={onAccept}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>
);
}
return (
<div className="module-composition-area">
<div className="module-composition-area__toggle-large">

View file

@ -1,16 +0,0 @@
#### All Options
```jsx
<util.ConversationContext theme={util.theme}>
<ConfirmationDialog
i18n={util.i18n}
onClose={() => console.log('onClose')}
onAffirmative={() => console.log('onAffirmative')}
affirmativeText="Affirm"
onNegative={() => console.log('onNegative')}
negativeText="Negate"
>
asdf child
</ConfirmationDialog>
</util.ConversationContext>
```

View file

@ -0,0 +1,40 @@
import * as React from 'react';
import { ConfirmationDialog } from './ConfirmationDialog';
// @ts-ignore
import { setup as setupI18n } from '../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { text } from '@storybook/addon-knobs';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/ConfirmationDialog', module).add(
'ConfirmationDialog',
() => {
return (
<ConfirmationDialog
i18n={i18n}
onClose={action('onClose')}
title={text('Title', 'Foo bar banana baz?')}
actions={[
{
text: 'Negate',
style: 'negative',
action: action('negative'),
},
{
text: 'Affirm',
style: 'affirmative',
action: action('affirmative'),
},
]}
>
{text('Child text', 'asdf blip')}
</ConfirmationDialog>
);
}
);

View file

@ -2,14 +2,18 @@ import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
export type ActionSpec = {
text: string;
action: () => unknown;
style?: 'affirmative' | 'negative';
};
export type OwnProps = {
readonly i18n: LocalizerType;
readonly children: React.ReactNode;
readonly affirmativeText?: string;
readonly onAffirmative?: () => unknown;
readonly title?: string | React.ReactNode;
readonly actions: Array<ActionSpec>;
readonly onClose: () => unknown;
readonly negativeText?: string;
readonly onNegative?: () => unknown;
};
export type Props = OwnProps;
@ -21,15 +25,7 @@ function focusRef(el: HTMLElement | null) {
}
export const ConfirmationDialog = React.memo(
({
i18n,
onClose,
children,
onAffirmative,
onNegative,
affirmativeText,
negativeText,
}: Props) => {
({ i18n, onClose, children, title, actions }: Props) => {
React.useEffect(() => {
const handler = ({ key }: KeyboardEvent) => {
if (key === 'Escape') {
@ -52,22 +48,25 @@ export const ConfirmationDialog = React.memo(
[onClose]
);
const handleNegative = React.useCallback(() => {
onClose();
if (onNegative) {
onNegative();
}
}, [onClose, onNegative]);
const handleAffirmative = React.useCallback(() => {
onClose();
if (onAffirmative) {
onAffirmative();
}
}, [onClose, onAffirmative]);
const handleAction = React.useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
onClose();
if (e.currentTarget.dataset.action) {
const actionIndex = parseInt(e.currentTarget.dataset.action, 10);
const { action } = actions[actionIndex];
action();
}
},
[onClose, actions]
);
return (
<div className="module-confirmation-dialog__container">
{title ? (
<h1 className="module-confirmation-dialog__container__title">
{title}
</h1>
) : null}
<div className="module-confirmation-dialog__container__content">
{children}
</div>
@ -79,28 +78,24 @@ export const ConfirmationDialog = React.memo(
>
{i18n('confirmation-dialog--Cancel')}
</button>
{onNegative && negativeText ? (
{actions.map((action, i) => (
<button
onClick={handleNegative}
key={i}
onClick={handleAction}
data-action={i}
className={classNames(
'module-confirmation-dialog__container__buttons__button',
'module-confirmation-dialog__container__buttons__button--negative'
action.style === 'affirmative'
? 'module-confirmation-dialog__container__buttons__button--affirmative'
: null,
action.style === 'negative'
? 'module-confirmation-dialog__container__buttons__button--negative'
: null
)}
>
{negativeText}
{action.text}
</button>
) : null}
{onAffirmative && affirmativeText ? (
<button
onClick={handleAffirmative}
className={classNames(
'module-confirmation-dialog__container__buttons__button',
'module-confirmation-dialog__container__buttons__button--affirmative'
)}
>
{affirmativeText}
</button>
) : null}
))}
</div>
</div>
);

View file

@ -1,31 +1,21 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { ConfirmationDialog } from './ConfirmationDialog';
import {
ConfirmationDialog,
Props as ConfirmationDialogProps,
} from './ConfirmationDialog';
import { LocalizerType } from '../types/Util';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly children: React.ReactNode;
readonly affirmativeText?: string;
readonly onAffirmative?: () => unknown;
readonly onClose: () => unknown;
readonly negativeText?: string;
readonly onNegative?: () => unknown;
};
export type Props = OwnProps;
export type Props = OwnProps & ConfirmationDialogProps;
export const ConfirmationModal = React.memo(
// tslint:disable-next-line max-func-body-length
({
i18n,
onClose,
children,
onAffirmative,
onNegative,
affirmativeText,
negativeText,
}: Props) => {
({ i18n, onClose, children, ...rest }: Props) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
@ -72,14 +62,7 @@ export const ConfirmationModal = React.memo(
className="module-confirmation-dialog__overlay"
onClick={handleCancel}
>
<ConfirmationDialog
i18n={i18n}
onClose={onClose}
onAffirmative={onAffirmative}
onNegative={onNegative}
affirmativeText={affirmativeText}
negativeText={negativeText}
>
<ConfirmationDialog i18n={i18n} {...rest} onClose={onClose}>
{children}
</ConfirmationDialog>
</div>,

View file

@ -25,7 +25,7 @@ export interface PropsType {
phoneNumber: string;
isMe: boolean;
name?: string;
color: ColorType;
color?: ColorType;
verified: boolean;
profileName?: string;
avatarPath?: string;

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Emojify } from './Emojify';
interface Props {
export interface Props {
phoneNumber?: string;
name?: string;
profileName?: string;

View file

@ -64,6 +64,7 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0001',
id: '1',
profileName: '🔥Flames🔥',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -76,6 +77,7 @@ const stories: Array<ConversationHeaderStory> = [
name: 'Someone 🔥 Somewhere',
phoneNumber: '(202) 555-0002',
id: '2',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -88,6 +90,7 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0003',
id: '3',
profileName: '🔥Flames🔥',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -97,6 +100,7 @@ const stories: Array<ConversationHeaderStory> = [
props: {
phoneNumber: '(202) 555-0011',
id: '11',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -108,6 +112,7 @@ const stories: Array<ConversationHeaderStory> = [
color: 'deep_orange',
phoneNumber: '(202) 555-0004',
id: '4',
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -129,6 +134,7 @@ const stories: Array<ConversationHeaderStory> = [
value: 10,
},
],
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -159,6 +165,7 @@ const stories: Array<ConversationHeaderStory> = [
value: 10,
},
],
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -183,6 +190,7 @@ const stories: Array<ConversationHeaderStory> = [
value: 10,
},
],
isAccepted: true,
...actionProps,
...housekeepingProps,
},
@ -200,6 +208,25 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0007',
id: '7',
isMe: true,
isAccepted: true,
...actionProps,
...housekeepingProps,
},
},
],
},
{
title: 'Unaccepted',
description: 'No safety number entry.',
items: [
{
title: '1:1 conversation',
props: {
color: 'blue',
phoneNumber: '(202) 555-0007',
id: '7',
isMe: false,
isAccepted: false,
...actionProps,
...housekeepingProps,
},

View file

@ -25,6 +25,7 @@ export interface PropsData {
color?: ColorType;
avatarPath?: string;
isAccepted?: boolean;
isVerified?: boolean;
isMe?: boolean;
isGroup?: boolean;
@ -222,6 +223,7 @@ export class ConversationHeader extends React.Component<Props> {
public renderMenu(triggerId: string) {
const {
i18n,
isAccepted,
isMe,
isGroup,
isArchived,
@ -241,7 +243,7 @@ export class ConversationHeader extends React.Component<Props> {
return (
<ContextMenu id={triggerId}>
{leftGroup ? null : (
{!leftGroup && isAccepted ? (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
@ -254,7 +256,7 @@ export class ConversationHeader extends React.Component<Props> {
</MenuItem>
))}
</SubMenu>
)}
) : null}
<MenuItem onClick={onShowAllMedia}>{i18n('viewRecentMedia')}</MenuItem>
{isGroup ? (
<MenuItem onClick={onShowGroupMembers}>
@ -266,7 +268,7 @@ export class ConversationHeader extends React.Component<Props> {
{i18n('showSafetyNumber')}
</MenuItem>
) : null}
{!isGroup ? (
{!isGroup && isAccepted ? (
<MenuItem onClick={onResetSession}>{i18n('resetSession')}</MenuItem>
) : null}
{isArchived ? (

View file

@ -0,0 +1,130 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { number as numberKnob, text } from '@storybook/addon-knobs';
import { ConversationHero } from './ConversationHero';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const getName = () => text('name', 'Cayce Bollard');
const getProfileName = () => text('profileName', 'Cayce Bollard');
const getAvatarPath = () =>
text('avatarPath', '/fixtures/kitten-4-112-112.jpg');
const getPhoneNumber = () => text('phoneNumber', '+1 (646) 327-2700');
storiesOf('Components/Conversation/ConversationHero', module)
.add('Direct (Three Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={['NYC Rock Climbers', 'Dinner Party', 'Friends 🌿']}
/>
</div>
);
})
.add('Direct (Two Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={['NYC Rock Climbers', 'Dinner Party']}
/>
</div>
);
})
.add('Direct (One Other Group)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={['NYC Rock Climbers']}
/>
</div>
);
})
.add('Direct (No Other Groups)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
avatarPath={getAvatarPath()}
name={getName()}
profileName={getProfileName()}
phoneNumber={getPhoneNumber()}
conversationType="direct"
groups={[]}
/>
</div>
);
})
.add('Group (many members)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
name={text('groupName', 'NYC Rock Climbers')}
phoneNumber={text('phoneNumber', '+1 (646) 327-2700')}
conversationType="group"
membersCount={numberKnob('membersCount', 22)}
/>
</div>
);
})
.add('Group (one member)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
name={text('groupName', 'NYC Rock Climbers')}
phoneNumber={text('phoneNumber', '+1 (646) 327-2700')}
conversationType="group"
membersCount={1}
/>
</div>
);
})
.add('Group (zero members)', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
name={text('groupName', 'NYC Rock Climbers')}
phoneNumber={text('phoneNumber', '+1 (646) 327-2700')}
conversationType="group"
membersCount={0}
/>
</div>
);
})
.add('Note to Self', () => {
return (
<div style={{ width: '480px' }}>
<ConversationHero
i18n={i18n}
isMe={true}
conversationType="direct"
phoneNumber={getPhoneNumber()}
/>
</div>
);
});

View file

@ -0,0 +1,125 @@
import * as React from 'react';
import { take } from 'lodash';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { ContactName } from './ContactName';
import { Emojify } from './Emojify';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
isMe?: boolean;
groups?: Array<string>;
membersCount?: number;
phoneNumber: string;
onHeightChange?: () => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderMembershipRow = ({
i18n,
groups,
conversationType,
isMe,
}: Pick<Props, 'i18n' | 'groups' | 'conversationType' | 'isMe'>) => {
const className = 'module-conversation-hero__membership';
const nameClassName = `${className}__name`;
if (isMe) {
return <div className={className}>{i18n('noteToSelfHero')}</div>;
}
if (conversationType === 'direct' && groups && groups.length > 0) {
const firstThreeGroups = take(groups, 3).map((group, i) => (
<strong key={i} className={nameClassName}>
<Emojify text={group} />
</strong>
));
return (
<div className={className}>
<Intl
i18n={i18n}
id={`ConversationHero--membership-${firstThreeGroups.length}`}
components={firstThreeGroups}
/>
</div>
);
}
return null;
};
export const ConversationHero = ({
i18n,
avatarPath,
color,
conversationType,
isMe,
membersCount,
groups = [],
name,
phoneNumber,
profileName,
onHeightChange,
}: Props) => {
const firstRenderRef = React.useRef(true);
React.useEffect(() => {
// If any of the depenencies for this hook change then the height of this
// component may have changed. The cleanup function notifies listeners of
// any potential height changes.
return () => {
if (onHeightChange && !firstRenderRef.current) {
onHeightChange();
} else {
firstRenderRef.current = false;
}
};
}, [
firstRenderRef,
onHeightChange,
// Avoid collisions in these dependencies by prefixing them
// These dependencies may be dynamic, and therefore may cause height changes
`mc-${membersCount}`,
`n-${name}`,
`pn-${profileName}`,
...groups.map(g => `g-${g}`),
]);
return (
<div className="module-conversation-hero">
<Avatar
i18n={i18n}
color={color}
noteToSelf={isMe}
avatarPath={avatarPath}
conversationType={conversationType}
name={name}
profileName={profileName}
size={112}
className="module-conversation-hero__avatar"
/>
<h1 className="module-conversation-hero__profile-name">
{isMe ? (
i18n('noteToSelf')
) : (
<ContactName
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>
)}
</h1>
{!isMe ? (
<div className="module-conversation-hero__with">
{membersCount === 1
? i18n('ConversationHero--members-1')
: membersCount !== undefined
? i18n('ConversationHero--members', [`${membersCount}`])
: phoneNumber}
</div>
) : null}
{renderMembershipRow({ isMe, groups, conversationType, i18n })}
</div>
);
};

View file

@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import { Spinner } from '../Spinner';
import { LocalizerType } from '../../types/Util';
@ -30,6 +31,7 @@ interface Props {
darkOverlay?: boolean;
playIconOverlay?: boolean;
softCorners?: boolean;
blurHash?: string;
i18n: LocalizerType;
onClick?: (attachment: AttachmentType) => void;
@ -38,7 +40,21 @@ interface Props {
}
export class Image extends React.Component<Props> {
private canClick() {
const { onClick, attachment, url } = this.props;
const { pending } = attachment || { pending: true };
return Boolean(onClick && !pending && url);
}
public handleClick = (event: React.MouseEvent) => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
const { onClick, attachment } = this.props;
if (onClick) {
@ -50,6 +66,13 @@ export class Image extends React.Component<Props> {
};
public handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
const { onClick, attachment } = this.props;
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
@ -64,6 +87,7 @@ export class Image extends React.Component<Props> {
const {
alt,
attachment,
blurHash,
bottomOverlay,
closeButton,
curveBottomLeft,
@ -71,11 +95,10 @@ export class Image extends React.Component<Props> {
curveTopLeft,
curveTopRight,
darkOverlay,
height,
height = 0,
i18n,
noBackground,
noBorder,
onClick,
onClickClose,
onError,
overlayText,
@ -84,18 +107,16 @@ export class Image extends React.Component<Props> {
softCorners,
tabIndex,
url,
width,
width = 0,
} = this.props;
const { caption, pending } = attachment || { caption: null, pending: true };
const canClick = onClick && !pending;
const canClick = this.canClick();
const overlayClassName = classNames(
'module-image__border-overlay',
noBorder ? null : 'module-image__border-overlay--with-border',
canClick && onClick
? 'module-image__border-overlay--with-click-handler'
: null,
canClick ? 'module-image__border-overlay--with-click-handler' : null,
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
@ -105,19 +126,14 @@ export class Image extends React.Component<Props> {
darkOverlay ? 'module-image__border-overlay--dark' : null
);
let overlay;
if (canClick && onClick) {
overlay = (
<button
className={overlayClassName}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
tabIndex={tabIndex}
/>
);
} else {
overlay = <div className={overlayClassName} />;
}
const overlay = canClick ? (
<button
className={overlayClassName}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
tabIndex={tabIndex}
/>
) : null;
return (
<div
@ -145,7 +161,7 @@ export class Image extends React.Component<Props> {
>
<Spinner svgSize="normal" />
</div>
) : (
) : url ? (
<img
onError={onError}
className="module-image__image"
@ -154,7 +170,14 @@ export class Image extends React.Component<Props> {
width={width}
src={url}
/>
)}
) : blurHash ? (
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : null}
{caption ? (
<img
className="module-image__caption-icon"

View file

@ -76,6 +76,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
noBackground={isSticker}
@ -103,6 +104,7 @@ export class ImageGrid extends React.Component<Props> {
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
attachment={attachments[0]}
blurHash={attachments[0].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveTopLeft={curveTopLeft}
@ -117,6 +119,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveTopRight={curveTopRight}
@ -139,6 +142,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveTopLeft={curveTopLeft}
@ -155,6 +159,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
curveTopRight={curveTopRight}
height={99}
width={99}
@ -167,6 +172,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
blurHash={attachments[2].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveBottomRight={curveBottomRight}
@ -191,6 +197,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
curveTopLeft={curveTopLeft}
noBorder={false}
attachment={attachments[0]}
@ -204,6 +211,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
noBorder={false}
@ -219,6 +227,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
blurHash={attachments[2].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveBottomLeft={curveBottomLeft}
@ -233,6 +242,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
blurHash={attachments[3].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={false}
curveBottomRight={curveBottomRight}
@ -262,6 +272,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[0], i18n)}
i18n={i18n}
blurHash={attachments[0].blurHash}
curveTopLeft={curveTopLeft}
attachment={attachments[0]}
playIconOverlay={isVideoAttachment(attachments[0])}
@ -274,6 +285,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[1], i18n)}
i18n={i18n}
blurHash={attachments[1].blurHash}
curveTopRight={curveTopRight}
playIconOverlay={isVideoAttachment(attachments[1])}
height={149}
@ -288,6 +300,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[2], i18n)}
i18n={i18n}
blurHash={attachments[2].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomLeft={curveBottomLeft}
@ -302,6 +315,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[3], i18n)}
i18n={i18n}
blurHash={attachments[3].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
playIconOverlay={isVideoAttachment(attachments[3])}
@ -315,6 +329,7 @@ export class ImageGrid extends React.Component<Props> {
<Image
alt={getAlt(attachments[4], i18n)}
i18n={i18n}
blurHash={attachments[4].blurHash}
bottomOverlay={withBottomOverlay}
noBorder={isSticker}
curveBottomRight={curveBottomRight}

View file

@ -16,6 +16,7 @@ import {
PropsHousekeeping,
} from './Message';
import { EmojiPicker } from '../emoji/EmojiPicker';
import { MIMEType } from '../../types/MIME';
const book = storiesOf('Components/Conversation/Message', module);
@ -1272,6 +1273,28 @@ const stories: Array<MessageStory> = [
})),
],
],
[
'BlurHash',
[
{
title: 'Incoming BlurHash',
makeDataProps: () => ({
...baseDataProps,
direction: 'incoming',
attachments: [
{
blurHash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
width: 300,
height: 600,
fileName: 'foo.jpg',
contentType: 'image/jpeg' as MIMEType,
url: '',
},
],
}),
},
],
],
];
const renderEmojiPicker: AllProps['renderEmojiPicker'] = ({

View file

@ -573,6 +573,9 @@ export class Message extends React.PureComponent<Props, State> {
withContentBelow
? 'module-message__attachment-container--with-content-below'
: null,
collapseMetadata
? 'module-message__attachment-container--with-collapsed-metadata'
: null,
isSticker && !collapseMetadata
? 'module-message__sticker-container--with-content-below'
: null
@ -627,6 +630,9 @@ export class Message extends React.PureComponent<Props, State> {
: null,
withContentAbove
? 'module-message__generic-attachment--with-content-above'
: null,
!firstAttachment.url
? 'module-message__generic-attachment--not-active'
: null
)}
// There's only ever one of these, so we don't want users to tab into it
@ -635,6 +641,10 @@ export class Message extends React.PureComponent<Props, State> {
event.stopPropagation();
event.preventDefault();
if (!firstAttachment.url) {
return;
}
this.openGenericAttachment();
}}
>
@ -1117,7 +1127,7 @@ export class Message extends React.PureComponent<Props, State> {
)}
>
{canReply ? reactButton : null}
{downloadButton}
{canReply ? downloadButton : null}
{canReply ? replyButton : null}
{menuButton}
</div>
@ -1881,6 +1891,15 @@ export class Message extends React.PureComponent<Props, State> {
return;
}
// If there an incomplete attachment, do not execute the default action
const { attachments } = this.props;
if (attachments && attachments.length > 0) {
const [firstAttachment] = attachments;
if (!firstAttachment.url) {
return;
}
}
this.handleOpen(event);
};

View file

@ -0,0 +1,59 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import {
MessageRequestActions,
Props as MessageRequestActionsProps,
} from './MessageRequestActions';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const getBaseProps = (isGroup = false): MessageRequestActionsProps => ({
i18n,
conversationType: isGroup ? 'group' : 'direct',
profileName: isGroup ? undefined : text('profileName', 'Cayce Bollard'),
name: isGroup
? text('name', 'NYC Rock Climbers')
: text('name', 'Cayce Bollard'),
onBlock: action('block'),
onDelete: action('delete'),
onBlockAndDelete: action('blockAndDelete'),
onUnblock: action('unblock'),
onAccept: action('accept'),
});
storiesOf('Components/Conversation/MessageRequestActions', module)
.add('Direct', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps()} />
</div>
);
})
.add('Direct (Blocked)', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps()} isBlocked={true} />
</div>
);
})
.add('Group', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps(true)} />
</div>
);
})
.add('Group (Blocked)', () => {
return (
<div style={{ width: '480px' }}>
<MessageRequestActions {...getBaseProps(true)} isBlocked={true} />
</div>
);
});

View file

@ -0,0 +1,130 @@
import * as React from 'react';
import classNames from 'classnames';
import { ContactName, Props as ContactNameProps } from './ContactName';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
Props as MessageRequestActionsConfirmationProps,
} from './MessageRequestActionsConfirmation';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export type Props = {
i18n: LocalizerType;
onAccept(): unknown;
} & Omit<ContactNameProps, 'module'> &
Omit<
MessageRequestActionsConfirmationProps,
'i18n' | 'state' | 'onChangeState'
>;
export const MessageRequestActions = ({
i18n,
name,
profileName,
phoneNumber,
conversationType,
isBlocked,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
onAccept,
}: Props) => {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
return (
<>
{mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation
i18n={i18n}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={onUnblock}
onDelete={onDelete}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
conversationType={conversationType}
state={mrState}
onChangeState={setMrState}
/>
) : null}
<div className="module-message-request-actions">
<p className="module-message-request-actions__message">
<Intl
i18n={i18n}
id={`MessageRequests--message-${conversationType}${
isBlocked ? '-blocked' : ''
}`}
components={[
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>
</strong>,
]}
/>
</p>
<div className="module-message-request-actions__buttons">
{isBlocked ? (
<button
onClick={() => {
setMrState(MessageRequestState.unblocking);
}}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--accept'
)}
>
{i18n('MessageRequests--unblock')}
</button>
) : (
<button
onClick={() => {
setMrState(MessageRequestState.blocking);
}}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--deny'
)}
>
{i18n('MessageRequests--block')}
</button>
)}
<button
onClick={() => {
setMrState(MessageRequestState.deleting);
}}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--deny'
)}
>
{i18n('MessageRequests--delete')}
</button>
{!isBlocked ? (
<button
onClick={onAccept}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--accept'
)}
>
{i18n('MessageRequests--accept')}
</button>
) : null}
</div>
</div>
</>
);
};

View file

@ -0,0 +1,156 @@
import * as React from 'react';
import { ContactName, Props as ContactNameProps } from './ContactName';
import { ConfirmationModal } from '../ConfirmationModal';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export enum MessageRequestState {
blocking,
deleting,
unblocking,
default,
}
export type Props = {
i18n: LocalizerType;
conversationType: 'group' | 'direct';
isBlocked?: boolean;
onBlock(): unknown;
onBlockAndDelete(): unknown;
onUnblock(): unknown;
onDelete(): unknown;
state: MessageRequestState;
onChangeState(state: MessageRequestState): unknown;
} & Omit<ContactNameProps, 'module'>;
// tslint:disable-next-line: max-func-body-length
export const MessageRequestActionsConfirmation = ({
i18n,
name,
profileName,
phoneNumber,
conversationType,
onBlock,
onBlockAndDelete,
onUnblock,
onDelete,
state,
onChangeState,
}: Props) => {
if (state === MessageRequestState.blocking) {
return (
// tslint:disable-next-line: use-simple-attributes
<ConfirmationModal
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={
<Intl
i18n={i18n}
id={`MessageRequests--block-${conversationType}-confirm-title`}
components={[
<ContactName
key="name"
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>,
]}
/>
}
actions={[
{
text: i18n('MessageRequests--block'),
action: onBlock,
style: 'negative',
},
{
text: i18n('MessageRequests--block-and-delete'),
action: onBlockAndDelete,
style: 'negative',
},
]}
>
{i18n(`MessageRequests--block-${conversationType}-confirm-body`)}
</ConfirmationModal>
);
}
if (state === MessageRequestState.unblocking) {
return (
// tslint:disable-next-line: use-simple-attributes
<ConfirmationModal
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={
<Intl
i18n={i18n}
id={'MessageRequests--unblock-confirm-title'}
components={[
<ContactName
key="name"
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>,
]}
/>
}
actions={[
{
text: i18n('MessageRequests--unblock'),
action: onUnblock,
style: 'affirmative',
},
{
text: i18n('MessageRequests--delete'),
action: onDelete,
style: 'negative',
},
]}
>
{i18n(`MessageRequests--unblock-${conversationType}-confirm-body`)}
</ConfirmationModal>
);
}
if (state === MessageRequestState.deleting) {
return (
// tslint:disable-next-line: use-simple-attributes
<ConfirmationModal
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={
<Intl
i18n={i18n}
id={`MessageRequests--delete-${conversationType}-confirm-title`}
components={[
<ContactName
key="name"
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
/>,
]}
/>
}
actions={[
{
text: i18n(`MessageRequests--delete-${conversationType}`),
action: onDelete,
style: 'negative',
},
]}
>
{i18n(`MessageRequests--delete-${conversationType}-confirm-body`)}
</ConfirmationModal>
);
}
return null;
};

View file

@ -50,6 +50,7 @@ type PropsHousekeepingType = {
actions: Object
) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: (id: string, resizeHeroRow: () => unknown) => JSX.Element;
renderLoadingRow: (id: string) => JSX.Element;
renderTypingBubble: (id: string) => JSX.Element;
};
@ -250,6 +251,10 @@ export class Timeline extends React.PureComponent<Props, State> {
this.recomputeRowHeights(row || 0);
};
public resizeHeroRow = () => {
this.resize(0);
};
public onScroll = (data: OnScrollParamsType) => {
// Ignore scroll events generated as react-virtualized recursively scrolls and
// re-measures to get us where we want to go.
@ -501,6 +506,7 @@ export class Timeline extends React.PureComponent<Props, State> {
haveOldest,
items,
renderItem,
renderHeroRow,
renderLoadingRow,
renderLastSeenIndicator,
renderTypingBubble,
@ -515,7 +521,13 @@ export class Timeline extends React.PureComponent<Props, State> {
const typingBubbleRow = this.getTypingBubbleRow();
let rowContents;
if (!haveOldest && row === 0) {
if (haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={styleWithWidth} role="row">
{renderHeroRow(id, this.resizeHeroRow)}
</div>
);
} else if (!haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={styleWithWidth} role="row">
{renderLoadingRow(id)}
@ -574,13 +586,10 @@ export class Timeline extends React.PureComponent<Props, State> {
};
public fromItemIndexToRow(index: number) {
const { haveOldest, oldestUnreadIndex } = this.props;
const { oldestUnreadIndex } = this.props;
let addition = 0;
if (!haveOldest) {
addition += 1;
}
// We will always render either the hero row or the loading row
let addition = 1;
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
addition += 1;
@ -590,15 +599,12 @@ export class Timeline extends React.PureComponent<Props, State> {
}
public getRowCount() {
const { haveOldest, oldestUnreadIndex, typingContact } = this.props;
const { oldestUnreadIndex, typingContact } = this.props;
const { items } = this.props;
const itemsCount = items && items.length ? items.length : 0;
let extraRows = 0;
if (!haveOldest) {
extraRows += 1;
}
// We will always render either the hero row or the loading row
let extraRows = 1;
if (isNumber(oldestUnreadIndex)) {
extraRows += 1;
@ -612,13 +618,10 @@ export class Timeline extends React.PureComponent<Props, State> {
}
public fromRowToItemIndex(row: number, props?: Props): number | undefined {
const { haveOldest, items } = props || this.props;
const { items } = props || this.props;
let subtraction = 0;
if (!haveOldest) {
subtraction += 1;
}
// We will always render either the hero row or the loading row
let subtraction = 1;
const oldestUnreadRow = this.getLastSeenIndicatorRow();
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {

View file

@ -30,6 +30,7 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({
const getDefaultProps = () => ({
conversationId: 'conversation-id',
conversationAccepted: true,
id: 'asdf',
isSelected: false,
selectMessage: action('selectMessage'),

View file

@ -77,6 +77,7 @@ export type TimelineItemType =
type PropsLocalType = {
conversationId: string;
conversationAccepted: boolean;
item?: TimelineItemType;
id: string;
isSelected: boolean;

View file

@ -92,8 +92,13 @@ export const StickerManagerPackRow = React.memo(
<ConfirmationModal
i18n={i18n}
onClose={clearUninstalling}
negativeText={i18n('stickers--StickerManager--Uninstall')}
onNegative={handleConfirmUninstall}
actions={[
{
style: 'negative',
text: i18n('stickers--StickerManager--Uninstall'),
action: handleConfirmUninstall,
},
]}
>
{i18n('stickers--StickerManager--UninstallWarning')}
</ConfirmationModal>

View file

@ -174,8 +174,13 @@ export const StickerPreviewModal = React.memo(
<ConfirmationDialog
i18n={i18n}
onClose={onClose}
negativeText={i18n('stickers--StickerManager--Uninstall')}
onNegative={handleUninstall}
actions={[
{
style: 'negative',
text: i18n('stickers--StickerManager--Uninstall'),
action: handleUninstall,
},
]}
>
{i18n('stickers--StickerManager--UninstallWarning')}
</ConfirmationDialog>

View file

@ -852,8 +852,8 @@ async function searchMessagesInConversation(
// Message
async function getMessageCount() {
return channels.getMessageCount();
async function getMessageCount(conversationId?: string) {
return channels.getMessageCount(conversationId);
}
async function saveMessage(

View file

@ -85,7 +85,7 @@ export interface DataInterface {
options?: { limit?: number }
) => Promise<Array<SearchResultMessageType>>;
getMessageCount: () => Promise<number>;
getMessageCount: (conversationId?: string) => Promise<number>;
saveMessages: (
arrayOfMessages: Array<MessageType>,
options: { forceSave?: boolean }

View file

@ -1542,6 +1542,40 @@ async function updateToSchemaVersion20(
}
}
async function updateToSchemaVersion21(
currentVersion: number,
instance: PromisifiedSQLDatabase
) {
if (currentVersion >= 21) {
return;
}
try {
await instance.run('BEGIN TRANSACTION;');
await instance.run(`
UPDATE conversations
SET json = json_set(
json,
'$.messageCount',
(SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id)
);
`);
await instance.run(`
UPDATE conversations
SET json = json_set(
json,
'$.sentMessageCount',
(SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id AND messages.type = 'outgoing')
);
`);
await instance.run('PRAGMA user_version = 21;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion21: success!');
} catch (error) {
await instance.run('ROLLBACK');
throw error;
}
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -1563,6 +1597,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion18,
updateToSchemaVersion19,
updateToSchemaVersion20,
updateToSchemaVersion21,
];
async function updateSchema(instance: PromisifiedSQLDatabase) {
@ -2326,9 +2361,14 @@ async function searchMessagesInConversation(
}));
}
async function getMessageCount() {
async function getMessageCount(conversationId?: string) {
const db = getInstance();
const row = await db.get('SELECT count(*) from messages;');
const row = conversationId
? await db.get(
'SELECT count(*) from messages WHERE conversationId = $conversationId;',
{ $conversationId: conversationId }
)
: await db.get('SELECT count(*) from messages;');
if (!row) {
throw new Error('getMessageCount: Unable to get count of messages');

View file

@ -12,6 +12,7 @@ import {
import { trigger } from '../../shims/events';
import { NoopActionType } from './noop';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Util';
// State
@ -24,7 +25,11 @@ export type DBConversationType = {
export type ConversationType = {
id: string;
name?: string;
isArchived: boolean;
profileName?: string;
avatarPath?: string;
color?: ColorType;
isArchived?: boolean;
isBlocked?: boolean;
activeAt?: number;
timestamp: number;
inboxPosition: number;
@ -33,6 +38,7 @@ export type ConversationType = {
text: string;
};
phoneNumber: string;
membersCount?: number;
type: 'direct' | 'group';
isMe: boolean;
lastUpdated: number;
@ -49,6 +55,9 @@ export type ConversationType = {
shouldShowDraft?: boolean;
draftText?: string;
draftPreview?: string;
messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean;
};
export type ConversationLookupType = {
[key: string]: ConversationType;

View file

@ -71,6 +71,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
recentStickers,
showIntroduction,
showPickerHint,
// Message Requests
messageRequestsEnabled: conversation.messageRequestsEnabled,
acceptedMessageRequest: conversation.acceptedMessageRequest,
isBlocked: conversation.isBlocked,
conversationType: conversation.type,
name: conversation.name,
profileName: conversation.profileName,
phoneNumber: conversation.phoneNumber,
};
};

View file

@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { ConversationHero } from '../../components/conversation/ConversationHero';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
type ExternalProps = {
id: string;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const conversation = state.conversations.conversationLookup[id];
if (!conversation) {
throw new Error(`Did not find conversation ${id} in state!`);
}
return {
i18n: getIntl(state),
avatarPath: conversation.avatarPath,
color: conversation.color,
conversationType: conversation.type,
isMe: conversation.isMe,
membersCount: conversation.membersCount,
name: conversation.name,
phoneNumber: conversation.phoneNumber,
profileName: conversation.profileName,
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartHeroRow = smart(ConversationHero);

View file

@ -16,6 +16,7 @@ import {
import { SmartTimelineItem } from './TimelineItem';
import { SmartTypingBubble } from './TypingBubble';
import { SmartLastSeenIndicator } from './LastSeenIndicator';
import { SmartHeroRow } from './HeroRow';
import { SmartTimelineLoadingRow } from './TimelineLoadingRow';
import { SmartEmojiPicker } from './EmojiPicker';
@ -24,6 +25,7 @@ import { SmartEmojiPicker } from './EmojiPicker';
const FilteredSmartTimelineItem = SmartTimelineItem as any;
const FilteredSmartTypingBubble = SmartTypingBubble as any;
const FilteredSmartLastSeenIndicator = SmartLastSeenIndicator as any;
const FilteredSmartHeroRow = SmartHeroRow as any;
const FilteredSmartTimelineLoadingRow = SmartTimelineLoadingRow as any;
type ExternalProps = {
@ -66,6 +68,9 @@ function renderEmojiPicker({
function renderLastSeenIndicator(id: string): JSX.Element {
return <FilteredSmartLastSeenIndicator id={id} />;
}
function renderHeroRow(id: string, onHeightChange: () => unknown): JSX.Element {
return <FilteredSmartHeroRow id={id} onHeightChange={onHeightChange} />;
}
function renderLoadingRow(id: string): JSX.Element {
return <FilteredSmartTimelineLoadingRow id={id} />;
}
@ -88,6 +93,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state),
renderItem,
renderLastSeenIndicator,
renderHeroRow,
renderLoadingRow,
renderTypingBubble,
...actions,

View file

@ -31,6 +31,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id2: {
id: 'id2',
@ -51,6 +53,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id3: {
id: 'id3',
@ -71,6 +75,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id4: {
id: 'id4',
@ -91,6 +97,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
id5: {
id: 'id5',
@ -111,6 +119,8 @@ describe('state/selectors/conversations', () => {
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
},
};
const comparator = _getConversationComparator(i18n, regionCode);

18
ts/textsecure.d.ts vendored
View file

@ -531,6 +531,7 @@ export declare class SyncMessageClass {
padding?: ProtoBinaryType;
stickerPackOperation?: Array<SyncMessageClass.StickerPackOperation>;
viewOnceOpen?: SyncMessageClass.ViewOnceOpen;
messageRequestResponse?: SyncMessageClass.MessageRequestResponse;
}
// Note: we need to use namespaces to express nested classes in Typescript
@ -582,6 +583,13 @@ export declare namespace SyncMessageClass {
senderUuid?: string;
timestamp?: ProtoBinaryType;
}
class MessageRequestResponse {
threadE164?: string;
threadUuid?: string;
groupId?: ProtoBinaryType;
type?: number;
}
}
// Note: we need to use namespaces to express nested classes in Typescript
@ -612,6 +620,16 @@ export declare namespace SyncMessageClass.StickerPackOperation {
}
}
export declare namespace SyncMessageClass.MessageRequestResponse {
class Type {
static UNKNOWN: number;
static ACCEPT: number;
static DELETE: number;
static BLOCK: number;
static BLOCK_AND_DELETE: number;
}
}
export declare class TypingMessageClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,

View file

@ -42,6 +42,8 @@ declare global {
deliveryReceipt?: any;
error?: any;
groupDetails?: any;
groupId?: string;
messageRequestResponseType?: number;
proto?: any;
read?: any;
reason?: any;
@ -51,6 +53,8 @@ declare global {
source?: any;
sourceUuid?: any;
stickerPacks?: any;
threadE164?: string;
threadUuid?: string;
timestamp?: any;
typing?: any;
verified?: any;
@ -1119,6 +1123,22 @@ class MessageReceiverInner extends EventTarget {
) {
p = this.handleEndSession(destination);
}
if (
msg.flags &&
msg.flags &
window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE
) {
const ev = new Event('profileKeyUpdate');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.data = {
source: envelope.source,
sourceUuid: envelope.sourceUuid,
profileKey: msg.profileKey.toString('base64'),
};
return this.dispatchAndWait(ev);
}
return p.then(async () =>
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
@ -1373,6 +1393,11 @@ class MessageReceiverInner extends EventTarget {
);
} else if (syncMessage.viewOnceOpen) {
return this.handleViewOnceOpen(envelope, syncMessage.viewOnceOpen);
} else if (syncMessage.messageRequestResponse) {
return this.handleMessageRequestResponse(
envelope,
syncMessage.messageRequestResponse
);
}
this.removeFromCache(envelope);
@ -1408,6 +1433,27 @@ class MessageReceiverInner extends EventTarget {
return this.dispatchAndWait(ev);
}
async handleMessageRequestResponse(
envelope: EnvelopeClass,
sync: SyncMessageClass.MessageRequestResponse
) {
window.log.info('got message request response sync message');
const ev = new Event('messageRequestResponse');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.threadE164 = sync.threadE164;
ev.threadUuid = sync.threadUuid;
ev.groupId = sync.groupId ? sync.groupId.toString('binary') : null;
ev.messageRequestResponseType = sync.type;
window.normalizeUuids(
ev,
['threadUuid'],
'MessageReceiver::handleMessageRequestResponse'
);
return this.dispatchAndWait(ev);
}
async handleStickerPackOperation(
envelope: EnvelopeClass,
operations: Array<SyncMessageClass.StickerPackOperation>

View file

@ -74,7 +74,7 @@ type MessageOptionsType = {
};
needsSync?: boolean;
preview?: Array<PreviewType> | null;
profileKey?: string;
profileKey?: ArrayBuffer;
quote?: any;
recipients: Array<string>;
sticker?: any;
@ -93,7 +93,7 @@ class Message {
};
needsSync?: boolean;
preview: any;
profileKey?: string;
profileKey?: ArrayBuffer;
quote?: any;
recipients: Array<string>;
sticker?: any;
@ -274,6 +274,8 @@ export type AttachmentType = {
caption: string;
attachmentPointer?: AttachmentPointerClass;
blurHash?: string;
};
export default class MessageSender {
@ -348,6 +350,9 @@ export default class MessageSender {
if (attachment.caption) {
proto.caption = attachment.caption;
}
if (attachment.blurHash) {
proto.blurHash = attachment.blurHash;
}
return proto;
}
@ -862,6 +867,31 @@ export default class MessageSender {
);
}
async sendProfileKeyUpdate(
profileKey: ArrayBuffer,
recipients: Array<string>,
sendOptions: SendOptionsType,
groupId?: string
) {
return this.sendMessage(
{
recipients,
timestamp: Date.now(),
profileKey,
flags: window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE,
...(groupId
? {
group: {
id: groupId,
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
},
}
: {}),
},
sendOptions
);
}
async sendDeliveryReceipt(
recipientE164: string,
recipientUuid: string,
@ -985,6 +1015,48 @@ export default class MessageSender {
);
}
async syncMessageRequestResponse(
responseArgs: {
threadE164?: string;
threadUuid?: string;
groupId?: string;
type: number;
},
sendOptions?: SendOptionsType
) {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1 || myDevice === '1') {
return null;
}
const syncMessage = this.createSyncMessage();
const response = new window.textsecure.protobuf.SyncMessage.MessageRequestResponse();
response.threadE164 = responseArgs.threadE164;
response.threadUuid = responseArgs.threadUuid;
response.groupId = responseArgs.groupId
? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(
responseArgs.groupId
)
: null;
response.type = responseArgs.type;
syncMessage.messageRequestResponse = response;
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
silent,
sendOptions
);
}
async sendStickerPackSync(
operations: Array<{
packId: string;
@ -1152,7 +1224,7 @@ export default class MessageSender {
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: string,
profileKey?: ArrayBuffer,
flags?: number
) {
const attributes = {
@ -1195,7 +1267,7 @@ export default class MessageSender {
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
return this.sendMessage(
@ -1308,7 +1380,7 @@ export default class MessageSender {
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
const myE164 = window.textsecure.storage.user.getNumber();
@ -1480,7 +1552,7 @@ export default class MessageSender {
groupIdentifiers: Array<string>,
expireTimer: number | undefined,
timestamp: number,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
const myNumber = window.textsecure.storage.user.getNumber();
@ -1517,7 +1589,7 @@ export default class MessageSender {
identifier: string,
expireTimer: number | undefined,
timestamp: number,
profileKey?: string,
profileKey?: ArrayBuffer,
options?: SendOptionsType
) {
return this.sendMessage(

View file

@ -489,6 +489,7 @@ const URL_CALLS = {
signed: 'v2/keys/signed',
getStickerPackUpload: 'v1/sticker/pack/form',
whoami: 'v1/accounts/whoami',
config: 'v1/config',
};
type InitializeOptionsType = {
@ -604,6 +605,7 @@ export type WebAPIType = {
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
updateDeviceName: (deviceName: string) => Promise<void>;
whoami: () => Promise<any>;
getConfig: () => Promise<Array<{ name: string; enabled: boolean }>>;
};
export type SignedPreKeyType = {
@ -724,9 +726,10 @@ export function initialize({
setSignedPreKey,
updateDeviceName,
whoami,
getConfig,
};
async function _ajax(param: AjaxOptionsType) {
async function _ajax(param: AjaxOptionsType): Promise<any> {
if (!param.urlParameters) {
param.urlParameters = '';
}
@ -792,6 +795,21 @@ export function initialize({
});
}
async function getConfig() {
type ResType = {
config: Array<{ name: string; enabled: boolean }>;
};
const res: ResType = await _ajax({
call: 'config',
httpType: 'GET',
responseType: 'json',
});
return res.config.filter(({ name }: { name: string }) =>
name.startsWith('desktop.')
);
}
async function getSenderCertificate() {
return _ajax({
call: 'deliveryCert',

View file

@ -18,6 +18,7 @@ const MIN_HEIGHT = 50;
// Used for display
export interface AttachmentType {
blurHash?: string;
caption?: string;
contentType: MIME.MIMEType;
fileName: string;
@ -133,7 +134,7 @@ export function hasImage(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
(attachments[0].url || attachments[0].pending)
(attachments[0].url || attachments[0].pending || attachments[0].blurHash)
);
}

View file

@ -0,0 +1,50 @@
import loadImage from 'blueimp-load-image';
import { encode } from 'blurhash';
type Input = Parameters<typeof loadImage>[0];
const loadImageData = async (input: Input): Promise<ImageData> => {
return new Promise((resolve, reject) => {
loadImage(
input,
canvasOrError => {
if (canvasOrError instanceof Event && canvasOrError.type === 'error') {
const processError = new Error(
'imageToBlurHash: Failed to process image'
);
processError.cause = canvasOrError;
reject(processError);
return;
}
if (canvasOrError instanceof HTMLCanvasElement) {
const context = canvasOrError.getContext('2d');
resolve(
context?.getImageData(
0,
0,
canvasOrError.width,
canvasOrError.height
)
);
}
const error = new Error(
'imageToBlurHash: Failed to place image on canvas'
);
reject(error);
return;
},
// Calculating the blurhash on large images is a long-running and
// synchronous operation, so here we ensure the images are a reasonable
// size before calculating the blurhash. iOS uses a max size of 200x200
// and Android uses a max size of 1/16 the original size. 200x200 is
// easier for us.
{ canvas: true, orientation: true, maxWidth: 200, maxHeight: 200 }
);
});
};
export const imageToBlurHash = async (input: Input) => {
const { data, width, height } = await loadImageData(input);
// 4 horizontal components and 3 vertical components
return encode(data, width, height, 4, 3);
};

View file

@ -219,6 +219,14 @@
"reasonCategory": "usageTrusted",
"updated": "2020-03-25T15:45:04.024Z"
},
{
"rule": "jQuery-wrap(",
"path": "js/models/conversations.js",
"line": " await wrap(",
"lineNumber": 641,
"reasonCategory": "falseMatch",
"updated": "2020-05-27T21:15:43.044Z"
},
{
"rule": "jQuery-append(",
"path": "js/modules/debuglogs.js",
@ -570,7 +578,7 @@
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 198,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Known DOM elements"
},
{
@ -579,7 +587,7 @@
"line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 201,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Hardcoded selector"
},
{
@ -11426,18 +11434,18 @@
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';",
"lineNumber": 22,
"lineNumber": 23,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 73,
"lineNumber": 80,
"reasonCategory": "usageTrusted",
"updated": "2019-12-16T14:36:25.614ZZ",
"updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
@ -11507,9 +11515,9 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 67,
"lineNumber": 68,
"reasonCategory": "usageTrusted",
"updated": "2019-07-31T00:19:18.696Z",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu"
},
{
@ -11553,7 +11561,7 @@
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 184,
"reasonCategory": "usageTrusted",
"updated": "2020-04-30T15:59:13.160Z"
"updated": "2020-05-21T16:56:07.875Z"
},
{
"rule": "React-createRef",
@ -11561,7 +11569,7 @@
"line": " > = React.createRef();",
"lineNumber": 188,
"reasonCategory": "usageTrusted",
"updated": "2020-04-30T15:59:13.160Z"
"updated": "2020-05-21T16:56:07.875Z"
},
{
"rule": "React-createRef",
@ -11784,4 +11792,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
}
]
]

6
ts/window.d.ts vendored
View file

@ -31,7 +31,7 @@ declare global {
storage: {
put: (key: string, value: any) => void;
remove: (key: string) => void;
get: (key: string) => any;
get: <T = any>(key: string) => T | undefined;
};
textsecure: TextSecureType;
@ -48,6 +48,10 @@ declare global {
WebAPI: WebAPIConnectType;
Whisper: WhisperType;
}
interface Error {
cause?: Event;
}
}
export type ConversationType = {