Modern profile sharing in 1:1 and GroupV1 groups

This commit is contained in:
Scott Nonnenberg 2020-10-16 11:31:57 -07:00
parent 60f2422e2a
commit 04b7a29229
22 changed files with 371 additions and 115 deletions

View file

@ -2727,6 +2727,38 @@
"message": "Accept", "message": "Accept",
"description": "Shown as a button to let the user accept a message request" "description": "Shown as a button to let the user accept a message request"
}, },
"MessageRequests--continue": {
"message": "Continue",
"description": "Shown as a button to share your profile, necessary to continue messaging in a conversation"
},
"MessageRequests--profile-sharing--group": {
"message": "Continue your conversation with this group and share your name and photo with its members? $learnMore$",
"description": "Shown when user hasn't shared their profile in a group yet",
"placeholders": {
"learnMore": {
"content": "$1",
"example": "Learn More."
}
}
},
"MessageRequests--profile-sharing--direct": {
"message": "Continue this conversation with $firstName$ and share your name and photo with them? $learnMore$",
"description": "Shown when user hasn't shared their profile in a 1:1 conversation yet",
"placeholders": {
"firstName": {
"content": "$1",
"example": "Alice"
},
"learnMore": {
"content": "$2",
"example": "Learn More."
}
}
},
"MessageRequests--learn-more": {
"message": "Learn more.",
"description": "Shown at the end of profile sharing messages as a link."
},
"ConversationHero--members": { "ConversationHero--members": {
"message": "$count$ members", "message": "$count$ members",
"description": "Specifies the number of members in a group conversation", "description": "Specifies the number of members in a group conversation",

View file

@ -11,7 +11,7 @@
Component: window.Signal.Components.SafetyNumberChangeDialog, Component: window.Signal.Components.SafetyNumberChangeDialog,
props: { props: {
confirmText: options.confirmText, confirmText: options.confirmText,
contacts: options.contacts.map(contact => contact.cachedProps), contacts: options.contacts.map(contact => contact.format()),
i18n: window.i18n, i18n: window.i18n,
onCancel: () => { onCancel: () => {
dialog.remove(); dialog.remove();

View file

@ -3597,6 +3597,10 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
&__name { &__name {
@include font-body-2-bold; @include font-body-2-bold;
} }
&__learn-more {
text-decoration: none;
}
} }
&__buttons { &__buttons {

View file

@ -2,12 +2,13 @@ import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI'; import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType = type ConfigKeyType =
| 'desktop.messageRequests'
| 'desktop.gv2'
| 'desktop.cds' | 'desktop.cds'
| 'desktop.clientExpiration'
| 'desktop.gv2'
| 'desktop.mandatoryProfileSharing'
| 'desktop.messageRequests'
| 'desktop.storage' | 'desktop.storage'
| 'desktop.storageWrite' | 'desktop.storageWrite';
| 'desktop.clientExpiration';
type ConfigValueType = { type ConfigValueType = {
name: ConfigKeyType; name: ConfigKeyType;
enabled: boolean; enabled: boolean;

View file

@ -652,12 +652,13 @@ type WhatIsThis = typeof window.WhatIsThis;
function initializeRedux() { function initializeRedux() {
// Here we set up a full redux store with initial state for our LeftPane Root // Here we set up a full redux store with initial state for our LeftPane Root
const convoCollection = window.getConversations(); const convoCollection = window.getConversations();
const conversations = convoCollection.map( const conversations = convoCollection.map(conversation =>
conversation => conversation.cachedProps conversation.format()
); );
const ourNumber = window.textsecure.storage.user.getNumber(); const ourNumber = window.textsecure.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid(); const ourUuid = window.textsecure.storage.user.getUuid();
const ourConversationId = window.ConversationController.getOurConversationId(); const ourConversationId = window.ConversationController.getOurConversationId();
const initialState = { const initialState = {
conversations: { conversations: {
conversationLookup: window.Signal.Util.makeLookup(conversations, 'id'), conversationLookup: window.Signal.Util.makeLookup(conversations, 'id'),
@ -1580,7 +1581,7 @@ type WhatIsThis = typeof window.WhatIsThis;
'desktop.clientExpiration', 'desktop.clientExpiration',
({ value }) => { ({ value }) => {
const remoteBuildExpirationTimestamp = window.Signal.Util.parseRemoteClientExpiration( const remoteBuildExpirationTimestamp = window.Signal.Util.parseRemoteClientExpiration(
value value as string
); );
if (remoteBuildExpirationTimestamp) { if (remoteBuildExpirationTimestamp) {
window.storage.put( window.storage.put(

View file

@ -16,12 +16,15 @@ import {
MessageRequestActions, MessageRequestActions,
Props as MessageRequestActionsProps, Props as MessageRequestActionsProps,
} from './conversation/MessageRequestActions'; } from './conversation/MessageRequestActions';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { countStickers } from './stickers/lib'; import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { EmojiPickDataType } from './emoji/EmojiPicker'; import { EmojiPickDataType } from './emoji/EmojiPicker';
export type OwnProps = { export type OwnProps = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly groupVersion?: 1 | 2;
readonly isMissingMandatoryProfileSharing?: boolean;
readonly messageRequestsEnabled?: boolean; readonly messageRequestsEnabled?: boolean;
readonly acceptedMessageRequest?: boolean; readonly acceptedMessageRequest?: boolean;
readonly compositionApi?: React.MutableRefObject<{ readonly compositionApi?: React.MutableRefObject<{
@ -113,7 +116,9 @@ export const CompositionArea = ({
// Message Requests // Message Requests
acceptedMessageRequest, acceptedMessageRequest,
conversationType, conversationType,
groupVersion,
isBlocked, isBlocked,
isMissingMandatoryProfileSharing,
messageRequestsEnabled, messageRequestsEnabled,
name, name,
onAccept, onAccept,
@ -326,7 +331,7 @@ export const CompositionArea = ({
}; };
}, [setLarge]); }, [setLarge]);
if ((!acceptedMessageRequest || isBlocked) && messageRequestsEnabled) { if (messageRequestsEnabled && (!acceptedMessageRequest || isBlocked)) {
return ( return (
<MessageRequestActions <MessageRequestActions
i18n={i18n} i18n={i18n}
@ -345,6 +350,28 @@ export const CompositionArea = ({
); );
} }
// If no message request, but we haven't shared profile yet, we show profile-sharing UI
if (
(conversationType === 'direct' ||
(conversationType === 'group' && groupVersion === 1)) &&
isMissingMandatoryProfileSharing
) {
return (
<MandatoryProfileSharingActions
i18n={i18n}
conversationType={conversationType}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onDelete={onDelete}
onAccept={onAccept}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
title={title}
/>
);
}
return ( return (
<div className="module-composition-area"> <div className="module-composition-area">
<div className="module-composition-area__toggle-large"> <div className="module-composition-area__toggle-large">

View file

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

View file

@ -0,0 +1,134 @@
import * as React from 'react';
import classNames from 'classnames';
import { ContactName, PropsType 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;
firstName?: string;
onAccept(): unknown;
} & Omit<ContactNameProps, 'module' | 'i18n'> &
Pick<
MessageRequestActionsConfirmationProps,
'conversationType' | 'onBlock' | 'onBlockAndDelete' | 'onDelete'
>;
export const MandatoryProfileSharingActions = ({
conversationType,
firstName,
i18n,
name,
onAccept,
onBlock,
onBlockAndDelete,
onDelete,
phoneNumber,
profileName,
title,
}: Props): JSX.Element => {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
return (
<>
{mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation
i18n={i18n}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={() => {
throw new Error(
'Should not be able to unblock from MandatoryProfileSharingActions'
);
}}
onDelete={onDelete}
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
title={title}
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--profile-sharing--${conversationType}`}
components={{
firstName: (
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName
name={name}
profileName={profileName}
phoneNumber={phoneNumber}
title={firstName || title}
i18n={i18n}
/>
</strong>
),
learnMore: (
<a
href="https://support.signal.org/hc/articles/360007459591"
target="_blank"
rel="noreferrer"
className="module-message-request-actions__message__learn-more"
>
{i18n('MessageRequests--learn-more')}
</a>
),
}}
/>
</p>
<div className="module-message-request-actions__buttons">
<button
type="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
type="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>
<button
type="button"
onClick={onAccept}
tabIndex={0}
className={classNames(
'module-message-request-actions__buttons__button',
'module-message-request-actions__buttons__button--accept'
)}
>
{i18n('MessageRequests--continue')}
</button>
</div>
</div>
</>
);
};

View file

@ -45,6 +45,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
authorTitle: text('authorTitle', overrideProps.authorTitle || ''), authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
bodyRanges: overrideProps.bodyRanges, bodyRanges: overrideProps.bodyRanges,
canReply: true, canReply: true,
canDownload: true,
canDeleteForEveryone: overrideProps.canDeleteForEveryone || false, canDeleteForEveryone: overrideProps.canDeleteForEveryone || false,
clearSelectedMessage: action('clearSelectedMessage'), clearSelectedMessage: action('clearSelectedMessage'),
collapseMetadata: overrideProps.collapseMetadata, collapseMetadata: overrideProps.collapseMetadata,

View file

@ -137,6 +137,7 @@ export type PropsData = {
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
canReply: boolean; canReply: boolean;
canDownload: boolean;
canDeleteForEveryone: boolean; canDeleteForEveryone: boolean;
bodyRanges?: BodyRangesType; bodyRanges?: BodyRangesType;
}; };
@ -1159,6 +1160,7 @@ export class Message extends React.PureComponent<Props, State> {
): JSX.Element | null { ): JSX.Element | null {
const { const {
attachments, attachments,
canDownload,
canReply, canReply,
direction, direction,
disableMenu, disableMenu,
@ -1294,7 +1296,7 @@ export class Message extends React.PureComponent<Props, State> {
)} )}
> >
{canReply ? reactButton : null} {canReply ? reactButton : null}
{canReply ? downloadButton : null} {canDownload ? downloadButton : null}
{canReply ? replyButton : null} {canReply ? replyButton : null}
{menuButton} {menuButton}
</div> </div>
@ -1328,6 +1330,7 @@ export class Message extends React.PureComponent<Props, State> {
public renderContextMenu(triggerId: string): JSX.Element { public renderContextMenu(triggerId: string): JSX.Element {
const { const {
attachments, attachments,
canDownload,
canReply, canReply,
deleteMessage, deleteMessage,
deleteMessageForEveryone, deleteMessageForEveryone,
@ -1349,7 +1352,8 @@ export class Message extends React.PureComponent<Props, State> {
const menu = ( const menu = (
<ContextMenu id={triggerId}> <ContextMenu id={triggerId}>
{!isSticker && {canDownload &&
!isSticker &&
!multipleAttachments && !multipleAttachments &&
!isTapToView && !isTapToView &&
attachments && attachments &&

View file

@ -17,6 +17,7 @@ const defaultMessage: MessageProps = {
authorTitle: 'Max', authorTitle: 'Max',
canReply: true, canReply: true,
canDeleteForEveryone: true, canDeleteForEveryone: true,
canDownload: true,
clearSelectedMessage: () => null, clearSelectedMessage: () => null,
conversationId: 'my-convo', conversationId: 'my-convo',
conversationType: 'direct', conversationType: 'direct',

View file

@ -20,6 +20,7 @@ const defaultMessageProps: MessagesProps = {
authorTitle: 'Person X', authorTitle: 'Person X',
canReply: true, canReply: true,
canDeleteForEveryone: true, canDeleteForEveryone: true,
canDownload: true,
clearSelectedMessage: () => null, clearSelectedMessage: () => null,
conversationId: 'conversationId', conversationId: 'conversationId',
conversationType: 'direct', // override conversationType: 'direct', // override

4
ts/model-types.d.ts vendored
View file

@ -84,7 +84,7 @@ export type MessageAttributesType = {
referencedMessageNotFound: boolean; referencedMessageNotFound: boolean;
text: string; text: string;
} | null; } | null;
reactions: Array<{ fromId: string; emoji: unknown; timestamp: unknown }>; reactions: Array<{ fromId: string; emoji: string; timestamp: number }>;
read_by: Array<string | null>; read_by: Array<string | null>;
requiredProtocolVersion: number; requiredProtocolVersion: number;
sent: boolean; sent: boolean;
@ -141,7 +141,7 @@ export type ConversationAttributesType = {
accessKey: string | null; accessKey: string | null;
addedBy?: string; addedBy?: string;
capabilities: { uuid: string }; capabilities: { uuid: string };
color?: ColorType; color?: string;
discoveredUnregisteredAt: number; discoveredUnregisteredAt: number;
draftAttachments: Array<unknown>; draftAttachments: Array<unknown>;
draftTimestamp: number | null; draftTimestamp: number | null;

View file

@ -239,9 +239,16 @@ export class ConversationModel extends window.Backbone.Model<
// Keep props ready // Keep props ready
this.generateProps = () => { this.generateProps = () => {
// This is to prevent race conditions on startup; Conversation models are created
// but the full window.ConversationController.load() sequence isn't complete.
if (!window.ConversationController.isFetchComplete()) {
return;
}
this.cachedProps = this.getProps(); this.cachedProps = this.getProps();
}; };
this.on('change', this.generateProps); this.on('change', this.generateProps);
this.generateProps(); this.generateProps();
} }
@ -1027,19 +1034,13 @@ export class ConversationModel extends window.Backbone.Model<
}); });
} }
format(): ConversationType | null | undefined { format(): ConversationType {
this.cachedProps = this.cachedProps || this.getProps();
return this.cachedProps; return this.cachedProps;
} }
getProps(): ConversationType | null { getProps(): ConversationType {
// This is to prevent race conditions on startup; Conversation models are created
// but the full window.ConversationController.load() sequence isn't complete. So, we
// don't cache props on create, but we do later when load() calls generateProps()
// for us.
if (!window.ConversationController.isFetchComplete()) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const color = this.getColor()!; const color = this.getColor()!;
@ -1064,6 +1065,13 @@ export class ConversationModel extends window.Backbone.Model<
'desktop.messageRequests' 'desktop.messageRequests'
); );
let groupVersion: undefined | 1 | 2;
if (this.isGroupV1()) {
groupVersion = 1;
} else if (this.isGroupV2()) {
groupVersion = 2;
}
// TODO: DESKTOP-720 // TODO: DESKTOP-720
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
const result = { const result = {
@ -1078,12 +1086,14 @@ export class ConversationModel extends window.Backbone.Model<
draftPreview, draftPreview,
draftText, draftText,
firstName: this.get('profileName')!, firstName: this.get('profileName')!,
groupVersion,
inboxPosition, inboxPosition,
isAccepted: this.getAccepted(), isAccepted: this.getAccepted(),
isArchived: this.get('isArchived')!, isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(), isBlocked: this.isBlocked(),
isMe: this.isMe(), isMe: this.isMe(),
isPinned: this.get('isPinned'), isPinned: this.get('isPinned'),
isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(),
isVerified: this.isVerified(), isVerified: this.isVerified(),
lastMessage: { lastMessage: {
status: this.get('lastMessageStatus')!, status: this.get('lastMessageStatus')!,
@ -1091,6 +1101,7 @@ export class ConversationModel extends window.Backbone.Model<
deletedForEveryone: this.get('lastMessageDeletedForEveryone')!, deletedForEveryone: this.get('lastMessageDeletedForEveryone')!,
}, },
lastUpdated: this.get('timestamp')!, lastUpdated: this.get('timestamp')!,
membersCount: this.isPrivate() membersCount: this.isPrivate()
? undefined ? undefined
: (this.get('membersV2')! || this.get('members')! || []).length, : (this.get('membersV2')! || this.get('members')! || []).length,
@ -1693,6 +1704,18 @@ export class ConversationModel extends window.Backbone.Model<
return this.get('messageRequestResponseType') || 0; return this.get('messageRequestResponseType') || 0;
} }
isMissingRequiredProfileSharing(): boolean {
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
if (!mandatoryProfileSharingEnabled) {
return false;
}
return !this.get('profileSharing');
}
/** /**
* Determine if this conversation should be considered "accepted" in terms * Determine if this conversation should be considered "accepted" in terms
* of message requests * of message requests
@ -3864,20 +3887,19 @@ export class ConversationModel extends window.Backbone.Model<
} }
const { migrateColor } = Util; const { migrateColor } = Util;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion return migrateColor(this.get('color'));
return migrateColor(this.get('color')!);
} }
getAvatarPath(): string | null { getAvatarPath(): string | undefined {
const avatar = this.isMe() const avatar = this.isMe()
? this.get('profileAvatar') || this.get('avatar') ? this.get('profileAvatar') || this.get('avatar')
: this.get('avatar') || this.get('profileAvatar'); : this.get('avatar') || this.get('profileAvatar');
if (avatar && avatar.path) { if (!avatar || !avatar.path) {
return getAbsoluteAttachmentPath(avatar.path); return undefined;
} }
return null; return getAbsoluteAttachmentPath(avatar.path);
} }
canChangeTimer(): boolean { canChangeTimer(): boolean {

View file

@ -8,6 +8,7 @@ import {
LastMessageStatus, LastMessageStatus,
ConversationType, ConversationType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
import { PropsData } from '../components/conversation/Message';
import { CallbackResultType } from '../textsecure/SendMessage'; import { CallbackResultType } from '../textsecure/SendMessage';
import { BodyRangesType } from '../types/Util'; import { BodyRangesType } from '../types/Util';
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change'; import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
@ -59,7 +60,9 @@ const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
const { addStickerPackReference, getMessageBySender } = window.Signal.Data; const { addStickerPackReference, getMessageBySender } = window.Signal.Data;
const { bytesFromString } = window.Signal.Crypto; const { bytesFromString } = window.Signal.Crypto;
const PLACEHOLDER_CONTACT = { const PLACEHOLDER_CONTACT: Pick<ConversationType, 'title' | 'type' | 'id'> = {
id: 'placeholder-contact',
type: 'direct',
title: window.i18n('unknownContact'), title: window.i18n('unknownContact'),
}; };
@ -694,26 +697,26 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
.map(attachment => this.getPropsForAttachment(attachment)); .map(attachment => this.getPropsForAttachment(attachment));
} }
getPropsForMessage(): WhatIsThis { // Note: interactionMode is mixed in via selectors/conversations._messageSelector
getPropsForMessage(): Omit<PropsData, 'interactionMode'> {
const sourceId = this.getContactId(); const sourceId = this.getContactId();
const contact = this.findAndFormatContact(sourceId); const contact = this.findAndFormatContact(sourceId);
const contactModel = this.findContact(sourceId); const contactModel = this.findContact(sourceId);
const authorColor = contactModel ? contactModel.getColor() : null; const authorColor = contactModel ? contactModel.getColor() : undefined;
const authorAvatarPath = contactModel ? contactModel.getAvatarPath() : null; const authorAvatarPath = contactModel
? contactModel.getAvatarPath()
: undefined;
const expirationLength = this.get('expireTimer') * 1000; const expirationLength = this.get('expireTimer') * 1000;
const expireTimerStart = this.get('expirationStartTimestamp'); const expireTimerStart = this.get('expirationStartTimestamp');
const expirationTimestamp = const expirationTimestamp =
expirationLength && expireTimerStart expirationLength && expireTimerStart
? expireTimerStart + expirationLength ? expireTimerStart + expirationLength
: null; : undefined;
const conversation = this.getConversation(); const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate(); const isGroup = conversation && !conversation.isPrivate();
const conversationAccepted = Boolean(
conversation && conversation.getAccepted()
);
const sticker = this.get('sticker'); const sticker = this.get('sticker');
const isTapToView = this.isTapToView(); const isTapToView = this.isTapToView();
@ -739,7 +742,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
textPending: this.get('bodyPending'), textPending: this.get('bodyPending'),
id: this.id, id: this.id,
conversationId: this.get('conversationId'), conversationId: this.get('conversationId'),
conversationAccepted,
isSticker: Boolean(sticker), isSticker: Boolean(sticker),
direction: this.isIncoming() ? 'incoming' : 'outgoing', direction: this.isIncoming() ? 'incoming' : 'outgoing',
timestamp: this.get('sent_at'), timestamp: this.get('sent_at'),
@ -747,6 +749,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
contact: this.getPropsForEmbeddedContact(), contact: this.getPropsForEmbeddedContact(),
canReply: this.canReply(), canReply: this.canReply(),
canDeleteForEveryone: this.canDeleteForEveryone(), canDeleteForEveryone: this.canDeleteForEveryone(),
canDownload: this.canDownload(),
authorTitle: contact.title, authorTitle: contact.title,
authorColor, authorColor,
authorName: contact.name, authorName: contact.name,
@ -801,7 +804,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Dependencies of prop-generation functions // Dependencies of prop-generation functions
findAndFormatContact( findAndFormatContact(
identifier?: string identifier?: string
): Partial<ConversationType> & Pick<ConversationType, 'title'> { ): Partial<ConversationType> &
Pick<ConversationType, 'title' | 'id' | 'type'> {
if (!identifier) { if (!identifier) {
return PLACEHOLDER_CONTACT; return PLACEHOLDER_CONTACT;
} }
@ -824,6 +828,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}); });
return { return {
id: 'phone-only',
type: 'direct',
title: phoneNumber, title: phoneNumber,
phoneNumber, phoneNumber,
}; };
@ -839,9 +845,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
createNonBreakingLastSeparator(text: string): string | null { createNonBreakingLastSeparator(text: string): string | undefined {
if (!text) { if (!text) {
return null; return undefined;
} }
const nbsp = '\xa0'; const nbsp = '\xa0';
@ -859,7 +865,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return this.get('type') === 'incoming'; return this.get('type') === 'incoming';
} }
getMessagePropStatus(): LastMessageStatus | null { getMessagePropStatus(): LastMessageStatus | undefined {
const sent = this.get('sent'); const sent = this.get('sent');
const sentTo = this.get('sent_to') || []; const sentTo = this.get('sent_to') || [];
@ -870,7 +876,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return 'error'; return 'error';
} }
if (!this.isOutgoing()) { if (!this.isOutgoing()) {
return null; return undefined;
} }
const readBy = this.get('read_by') || []; const readBy = this.get('read_by') || [];
@ -2010,34 +2016,50 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return true; return true;
} }
canDownload(): boolean {
const conversation = this.getConversation();
const isAccepted = Boolean(conversation && conversation.getAccepted());
if (this.isOutgoing()) {
return true;
}
return isAccepted;
}
canReply(): boolean { canReply(): boolean {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conversation = this.getConversation();
const isAccepted = this.getConversation()!.getAccepted();
const errors = this.get('errors'); const errors = this.get('errors');
const isOutgoing = this.get('type') === 'outgoing'; const isOutgoing = this.get('type') === 'outgoing';
const numDelivered = this.get('delivered'); const numDelivered = this.get('delivered');
// Case 1: We cannot reply if we have accepted the message request // Case 1: If mandatory profile sharing is enabled, and we haven't shared yet, then
if (!isAccepted) { // we can't reply.
if (conversation?.isMissingRequiredProfileSharing()) {
return false; return false;
} }
// Case 2: We cannot reply if this message is deleted for everyone // Case 2: We cannot reply if we have accepted the message request
if (!conversation?.getAccepted()) {
return false;
}
// Case 3: We cannot reply if this message is deleted for everyone
if (this.get('deletedForEveryone')) { if (this.get('deletedForEveryone')) {
return false; return false;
} }
// Case 3: We can reply if this is outgoing and delievered to at least one recipient // Case 4: We can reply if this is outgoing and delievered to at least one recipient
if (isOutgoing && numDelivered > 0) { if (isOutgoing && numDelivered > 0) {
return true; return true;
} }
// Case 4: We can reply if there are no errors // Case 5: We can reply if there are no errors
if (!errors || (errors && errors.length === 0)) { if (!errors || (errors && errors.length === 0)) {
return true; return true;
} }
// Case 5: default // Case 6: default
return false; return false;
} }

View file

@ -117,14 +117,7 @@ export class CallingClass {
return; return;
} }
const conversationProps = conversation.cachedProps; const conversationProps = conversation.format();
if (!conversationProps) {
window.log.error(
'CallingClass.startCallingLobby(): No conversation props?'
);
return;
}
window.log.info('CallingClass.startCallingLobby(): Starting lobby'); window.log.info('CallingClass.startCallingLobby(): Starting lobby');
this.uxActions.showCallLobby({ this.uxActions.showCallLobby({
@ -829,10 +822,7 @@ export class CallingClass {
conversation: ConversationModel, conversation: ConversationModel,
call: Call call: Call
): CallDetailsType { ): CallDetailsType {
const conversationProps = conversation.cachedProps; const conversationProps = conversation.format();
if (!conversationProps) {
throw new Error('getAcceptedCallDetails: No conversation props?');
}
return { return {
...conversationProps, ...conversationProps,

View file

@ -75,6 +75,8 @@ export type ConversationType = {
draftText?: string | null; draftText?: string | null;
draftPreview?: string; draftPreview?: string;
groupVersion?: 1 | 2;
isMissingMandatoryProfileSharing?: boolean;
messageRequestsEnabled?: boolean; messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean; acceptedMessageRequest?: boolean;
}; };

View file

@ -1,15 +1,18 @@
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
export type ProfileNameChangeType = { export type ProfileNameChangeType = {
type: 'name'; type: 'name';
oldName: string; oldName: string;
newName: string; newName: string;
}; };
type ContactType = {
title: string;
name?: string;
};
export function getStringForProfileChange( export function getStringForProfileChange(
change: ProfileNameChangeType, change: ProfileNameChangeType,
changedContact: ConversationType, changedContact: ContactType,
i18n: LocalizerType i18n: LocalizerType
): string { ): string {
if (change.type === 'name') { if (change.type === 'name') {

View file

@ -13120,7 +13120,7 @@
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js", "path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';", "line": " el.innerHTML = '';",
"lineNumber": 24, "lineNumber": 25,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z", "updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Our code, no user input, only clearing out the dom" "reasonDetail": "Our code, no user input, only clearing out the dom"
@ -13129,7 +13129,7 @@
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx", "path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';", "line": " el.innerHTML = '';",
"lineNumber": 78, "lineNumber": 81,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-06-03T19:23:21.195Z", "updated": "2020-06-03T19:23:21.195Z",
"reasonDetail": "Our code, no user input, only clearing out the dom" "reasonDetail": "Our code, no user input, only clearing out the dom"
@ -13305,7 +13305,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx", "path": "ts/components/conversation/Message.tsx",
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();", "line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 217, "lineNumber": 218,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z" "updated": "2020-09-08T20:19:01.913Z"
}, },
@ -13313,7 +13313,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx", "path": "ts/components/conversation/Message.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();", "line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"lineNumber": 219, "lineNumber": 220,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-09-08T20:19:01.913Z" "updated": "2020-09-08T20:19:01.913Z"
}, },
@ -13321,7 +13321,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx", "path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();", "line": " > = React.createRef();",
"lineNumber": 223, "lineNumber": 224,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-08-28T19:36:40.817Z" "updated": "2020-08-28T19:36:40.817Z"
}, },
@ -13548,4 +13548,4 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z" "updated": "2020-09-08T23:07:22.682Z"
} }
] ]

View file

@ -1,7 +1,5 @@
import { ColorType } from '../types/Colors'; import { ColorType } from '../types/Colors';
// import { missingCaseError } from './missingCaseError';
type OldColorType = type OldColorType =
| 'amber' | 'amber'
| 'blue' | 'blue'
@ -22,9 +20,11 @@ type OldColorType =
| 'red' | 'red'
| 'teal' | 'teal'
| 'yellow' | 'yellow'
| 'ultramarine'; | 'ultramarine'
| string
| undefined;
export function migrateColor(color: OldColorType): ColorType { export function migrateColor(color?: OldColorType): ColorType {
switch (color) { switch (color) {
// These colors no longer exist // These colors no longer exist
case 'orange': case 'orange':
@ -62,10 +62,6 @@ export function migrateColor(color: OldColorType): ColorType {
case 'ultramarine': case 'ultramarine':
return color; return color;
// Can uncomment this to ensure that we've covered all potential cases
// default:
// throw missingCaseError(color);
default: default:
return 'grey'; return 'grey';
} }

View file

@ -436,11 +436,12 @@ Whisper.ConversationView = Whisper.View.extend({
: null; : null;
return { return {
...this.model.cachedProps, ...this.model.format(),
leftGroup: this.model.get('left'), leftGroup: this.model.get('left'),
disableTimerChanges: disableTimerChanges:
this.model.isMissingRequiredProfileSharing() ||
this.model.get('left') || this.model.get('left') ||
!this.model.getAccepted() || !this.model.getAccepted() ||
!this.model.canChangeTimer(), !this.model.canChangeTimer(),

37
ts/window.d.ts vendored
View file

@ -3,6 +3,7 @@
import * as Backbone from 'backbone'; import * as Backbone from 'backbone';
import * as Underscore from 'underscore'; import * as Underscore from 'underscore';
import { Ref } from 'react'; import { Ref } from 'react';
import * as Util from './util';
import { import {
ConversationModelCollectionType, ConversationModelCollectionType,
MessageModelCollectionType, MessageModelCollectionType,
@ -356,41 +357,7 @@ declare global {
}; };
VisualAttachment: any; VisualAttachment: any;
}; };
Util: { Util: typeof Util;
isFileDangerous: any;
GoogleChrome: {
isImageTypeSupported: (contentType: string) => unknown;
isVideoTypeSupported: (contentType: string) => unknown;
};
downloadAttachment: (attachment: WhatIsThis) => WhatIsThis;
getStringForProfileChange: (
change: unknown,
changedContact: unknown,
i18n: unknown
) => string;
getTextWithMentions: (
bodyRanges: BodyRangesType,
text: string
) => string;
deleteForEveryone: (
message: unknown,
del: unknown,
bool: boolean
) => void;
zkgroup: typeof zkgroup;
combineNames: typeof combineNames;
migrateColor: (color: string) => ColorType;
createBatcher: (options: WhatIsThis) => WhatIsThis;
Registration: {
everDone: () => boolean;
markDone: () => void;
markEverDone: () => void;
remove: () => void;
};
hasExpired: () => boolean;
makeLookup: (conversations: WhatIsThis, key: string) => void;
parseRemoteClientExpiration: (value: WhatIsThis) => WhatIsThis;
};
LinkPreviews: { LinkPreviews: {
isMediaLinkInWhitelist: any; isMediaLinkInWhitelist: any;
getTitleMetaTag: any; getTitleMetaTag: any;