GroupsV2: Better group invite behavior

This commit is contained in:
Scott Nonnenberg 2020-10-06 10:06:34 -07:00 committed by Josh Perez
parent b9ff4f07d3
commit d51a0b5ece
24 changed files with 1408 additions and 313 deletions

View file

@ -2951,13 +2951,46 @@
"message": "Emoji", "message": "Emoji",
"description": "Label for emoji button" "description": "Label for emoji button"
}, },
"ErrorModal--title": {
"message": "Something went wrong!",
"description": "Title of pop-up dialog when user-initiated task has gone wrong"
},
"ErrorModal--description": {
"message": "Please try again or contact support.",
"description": "Description text in pop-up dialog when user-initiated task has gone wrong"
},
"ErrorModal--buttonText": {
"message": "Okay",
"description": "Button to dismiss pop-up dialog when user-initiated task has gone wrong"
},
"GroupV2--admin": { "GroupV2--admin": {
"message": "Admin", "message": "Admin",
"description": "Shown next to the set of administrators in a group" "description": "Shown next to the set of administrators in a group"
}, },
"GroupV2--timerConflict": { "updating": {
"message": "Failed to update disappearing message timer. Please try again later.", "message": "Updating...",
"description": "Shown if the user runs into a group update conflict attempting to update a GroupV2 message timer" "description": "Shown along with a spinner when an update operation takes longer than one second"
},
"GroupV2--create--you": {
"message": "You created the group.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--create--other": {
"message": "$memberName$ created the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--create--unknown": {
"message": "The group was created.",
"description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"GroupV2--title--change--other": { "GroupV2--title--change--other": {
"message": "$memberName$ changed the group name to \"$newTitle$\".", "message": "$memberName$ changed the group name to \"$newTitle$\".",
@ -3253,7 +3286,7 @@
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"GroupV2--member-add--you--unknown": { "GroupV2--member-add--you--unknown": {
"message": "A member added you to the group.", "message": "You were added to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"GroupV2--member-remove--other--other": { "GroupV2--member-remove--other--other": {
@ -3271,7 +3304,7 @@
} }
}, },
"GroupV2--member-remove--other--self": { "GroupV2--member-remove--other--self": {
"message": "$memberName$ left.", "message": "$memberName$ left the group.",
"description": "Shown in timeline or conversation preview when v2 group changes", "description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": { "placeholders": {
"memberName": { "memberName": {
@ -3311,11 +3344,11 @@
} }
}, },
"GroupV2--member-remove--you--you": { "GroupV2--member-remove--you--you": {
"message": "You left.", "message": "You left the group.",
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"GroupV2--member-remove--you--unknown": { "GroupV2--member-remove--you--unknown": {
"message": "A member removed you.", "message": "You were removed from the group.",
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
@ -3437,7 +3470,7 @@
} }
}, },
"GroupV2--pending-add--one--other--unknown": { "GroupV2--pending-add--one--other--unknown": {
"message": "A member invited 1 person to the group.", "message": "One person was invited to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes", "description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": { "placeholders": {
"inviteeName": { "inviteeName": {
@ -3457,7 +3490,7 @@
} }
}, },
"GroupV2--pending-add--one--you--unknown": { "GroupV2--pending-add--one--you--unknown": {
"message": "A member invited you to the group.", "message": "You were invited to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"GroupV2--pending-add--many--other": { "GroupV2--pending-add--many--other": {
@ -3485,7 +3518,7 @@
} }
}, },
"GroupV2--pending-add--many--unknown": { "GroupV2--pending-add--many--unknown": {
"message": "A member invited $count$ people to the group.", "message": "$count$ people were invited to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes", "description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": { "placeholders": {
"count": { "count": {
@ -3515,6 +3548,10 @@
} }
} }
}, },
"GroupV2--pending-remove--decline--from-you": {
"message": "You declined the invitation to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--pending-remove--decline--unknown": { "GroupV2--pending-remove--decline--unknown": {
"message": "1 person declined their invitation to the group.", "message": "1 person declined their invitation to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
@ -3539,6 +3576,26 @@
} }
} }
}, },
"GroupV2--pending-remove--revoke-own--to-you": {
"message": "$inviterName$ revoked their invitation to you.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviterName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-own--unknown": {
"message": "$inviterName$ revoked their invitation to 1 person.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviterName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke--one--unknown": { "GroupV2--pending-remove--revoke--one--unknown": {
"message": "An admin revoked an invitation to the group for 1 person.", "message": "An admin revoked an invitation to the group for 1 person.",
"description": "Shown in timeline or conversation preview when v2 group changes", "description": "Shown in timeline or conversation preview when v2 group changes",

View file

@ -28,6 +28,7 @@ const {
AttachmentList, AttachmentList,
} = require('../../ts/components/conversation/AttachmentList'); } = require('../../ts/components/conversation/AttachmentList');
const { CaptionEditor } = require('../../ts/components/CaptionEditor'); const { CaptionEditor } = require('../../ts/components/CaptionEditor');
const { ConfirmationModal } = require('../../ts/components/ConfirmationModal');
const { const {
ContactDetail, ContactDetail,
} = require('../../ts/components/conversation/ContactDetail'); } = require('../../ts/components/conversation/ContactDetail');
@ -36,6 +37,7 @@ const {
ConversationHeader, ConversationHeader,
} = require('../../ts/components/conversation/ConversationHeader'); } = require('../../ts/components/conversation/ConversationHeader');
const { Emojify } = require('../../ts/components/conversation/Emojify'); const { Emojify } = require('../../ts/components/conversation/Emojify');
const { ErrorModal } = require('../../ts/components/ErrorModal');
const { Lightbox } = require('../../ts/components/Lightbox'); const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery'); const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const { const {
@ -45,6 +47,7 @@ const {
MessageDetail, MessageDetail,
} = require('../../ts/components/conversation/MessageDetail'); } = require('../../ts/components/conversation/MessageDetail');
const { Quote } = require('../../ts/components/conversation/Quote'); const { Quote } = require('../../ts/components/conversation/Quote');
const { ProgressModal } = require('../../ts/components/ProgressModal');
const { const {
SafetyNumberChangeDialog, SafetyNumberChangeDialog,
} = require('../../ts/components/SafetyNumberChangeDialog'); } = require('../../ts/components/SafetyNumberChangeDialog');
@ -289,16 +292,19 @@ exports.setup = (options = {}) => {
const Components = { const Components = {
AttachmentList, AttachmentList,
CaptionEditor, CaptionEditor,
ConfirmationModal,
ContactDetail, ContactDetail,
ContactListItem, ContactListItem,
ConversationHeader, ConversationHeader,
Emojify, Emojify,
ErrorModal,
getCallingNotificationText, getCallingNotificationText,
Lightbox, Lightbox,
LightboxGallery, LightboxGallery,
MediaGallery, MediaGallery,
MessageDetail, MessageDetail,
Quote, Quote,
ProgressModal,
SafetyNumberChangeDialog, SafetyNumberChangeDialog,
StagedLinkPreview, StagedLinkPreview,
Types: { Types: {

View file

@ -5293,6 +5293,18 @@ button.module-image__border-overlay:focus {
} }
} }
.module-spinner__circle--on-progress-dialog {
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-80;
}
}
.module-spinner__arc--on-progress-dialog {
background-color: $ultramarine-ui-light;
}
// Module: Highlighted Message Body // Module: Highlighted Message Body
.module-message-body__highlight { .module-message-body__highlight {
@ -6659,7 +6671,6 @@ button.module-image__border-overlay:focus {
border-top: 1px solid $color-gray-05; border-top: 1px solid $color-gray-05;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-bottom: -18px;
margin-left: -16px; margin-left: -16px;
margin-right: -16px; margin-right: -16px;
margin-top: -14px; margin-top: -14px;
@ -7808,10 +7819,11 @@ button.module-image__border-overlay:focus {
&__content { &__content {
@include font-body-1; @include font-body-1;
margin-bottom: 22px;
} }
&__buttons { &__buttons {
margin-top: 22px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
@ -9276,6 +9288,60 @@ button.module-image__border-overlay:focus {
margin-right: auto; margin-right: auto;
} }
// Module: Progress Dialog
.module-progress-dialog {
width: 138px;
padding: 18px;
border-radius: 8px;
@include popper-shadow();
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
@include light-theme() {
background: $color-white;
color: $color-gray-90;
}
@include dark-theme() {
background: $color-gray-80;
color: $color-gray-05;
}
}
.module-progress-dialog__spinner {
padding: 10px;
}
.module-progress-dialog__text {
@include font-body-2;
}
.module-progress-dialog__overlay {
background: $color-black-alpha-40;
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
z-index: 5;
}
// Module: Error Modal
.module-error-modal__button-container {
margin-top: 10px;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
/* Third-party module: react-tooltip-lite */ /* Third-party module: react-tooltip-lite */
.react-tooltip-lite { .react-tooltip-lite {

View file

@ -2546,6 +2546,9 @@ type WhatIsThis = typeof window.WhatIsThis;
if (message.groupV2) { if (message.groupV2) {
const { id } = message.groupV2; const { id } = message.groupV2;
const conversationId = window.ConversationController.ensureGroup(id, { const conversationId = window.ConversationController.ensureGroup(id, {
// Note: We don't set active_at, because we don't want the group to show until
// we have information about it beyond these initial details.
// see maybeUpdateGroup().
groupVersion: 2, groupVersion: 2,
masterKey: message.groupV2.masterKey, masterKey: message.groupV2.masterKey,
secretParams: message.groupV2.secretParams, secretParams: message.groupV2.secretParams,

View file

@ -0,0 +1,35 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { PropsType, ErrorModal } from './ErrorModal';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
title: text('title', overrideProps.title || ''),
description: text('description', overrideProps.description || ''),
buttonText: text('buttonText', overrideProps.buttonText || ''),
i18n,
onClose: action('onClick'),
});
storiesOf('Components/ErrorModal', module).add('Normal', () => {
return <ErrorModal {...createProps()} />;
});
storiesOf('Components/ErrorModal', module).add('Custom Strings', () => {
return (
<ErrorModal
{...createProps({
title: 'Real bad!',
description: 'Just avoid that next time, kay?',
buttonText: 'Fine',
})}
/>
);
});

View file

@ -0,0 +1,46 @@
import * as React from 'react';
import { LocalizerType } from '../types/Util';
import { ConfirmationModal } from './ConfirmationModal';
export type PropsType = {
buttonText: string;
description: string;
title: string;
onClose: () => void;
i18n: LocalizerType;
};
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const ErrorModal = (props: PropsType): JSX.Element => {
const { buttonText, description, i18n, onClose, title } = props;
return (
<ConfirmationModal
actions={[]}
title={title || i18n('ErrorModal--title')}
i18n={i18n}
onClose={onClose}
>
<div className="module-error-modal__description">
{description || i18n('ErrorModal--description')}
</div>
<div className="module-error-modal__button-container">
<button
type="button"
className="module-confirmation-dialog__container__buttons__button"
onClick={onClose}
ref={focusRef}
>
{buttonText || i18n('ErrorModal--buttonText')}
</button>
</div>
</ConfirmationModal>
);
};

View file

@ -0,0 +1,21 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { ProgressDialog, PropsType } from './ProgressDialog';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const story = storiesOf('Components/ProgressDialog', module);
const i18n = setupI18n('en', enMessages);
const createProps = (): PropsType => ({
i18n,
});
story.add('Normal', () => {
const props = createProps();
return <ProgressDialog {...props} />;
});

View file

@ -0,0 +1,18 @@
import * as React from 'react';
import { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
export type PropsType = {
readonly i18n: LocalizerType;
};
export const ProgressDialog = React.memo(({ i18n }: PropsType) => {
return (
<div className="module-progress-dialog">
<div className="module-progress-dialog__spinner">
<Spinner svgSize="normal" size="39px" direction="on-progress-dialog" />
</div>
<div className="module-progress-dialog__text">{i18n('updating')}</div>
</div>
);
});

View file

@ -0,0 +1,13 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { ProgressModal } from './ProgressModal';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/ProgressModal', module).add('Normal', () => {
return <ProgressModal i18n={i18n} />;
});

View file

@ -0,0 +1,35 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { ProgressDialog } from './ProgressDialog';
import { LocalizerType } from '../types/Util';
export type PropsType = {
readonly i18n: LocalizerType;
};
export const ProgressModal = React.memo(({ i18n }: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
// Note: We explicitly don't register for user interaction here, since this dialog
// cannot be dismissed.
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
return root
? createPortal(
<div role="presentation" className="module-progress-dialog__overlay">
<ProgressDialog i18n={i18n} />
</div>,
root
)
: null;
});

View file

@ -8,6 +8,7 @@ export const SpinnerDirections = [
'outgoing', 'outgoing',
'incoming', 'incoming',
'on-background', 'on-background',
'on-progress-dialog',
] as const; ] as const;
export type SpinnerDirection = typeof SpinnerDirections[number]; export type SpinnerDirection = typeof SpinnerDirections[number];

View file

@ -81,6 +81,35 @@ storiesOf('Components/Conversation/GroupV2Change', module)
</> </>
); );
}) })
.add('Create', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'create',
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'create',
},
],
})}
{renderChange({
details: [
{
type: 'create',
},
],
})}
</>
);
})
.add('Title', () => { .add('Title', () => {
return ( return (
<> <>
@ -784,6 +813,28 @@ storiesOf('Components/Conversation/GroupV2Change', module)
}, },
], ],
})} })}
{renderChange({
from: CONTACT_B,
details: [
{
type: 'pending-remove-one',
conversationId: OUR_ID,
inviter: CONTACT_B,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'pending-remove-one',
conversationId: CONTACT_B,
inviter: CONTACT_A,
},
],
})}
{renderChange({ {renderChange({
from: CONTACT_C, from: CONTACT_C,
details: [ details: [

View file

@ -65,10 +65,6 @@ story.add('No Image', () => {
return <StagedLinkPreview {...createProps()} />; return <StagedLinkPreview {...createProps()} />;
}); });
story.add('No Image', () => {
return <StagedLinkPreview {...createProps()} />;
});
story.add('Image', () => { story.add('Image', () => {
const props = createProps({ const props = createProps({
image: createAttachment({ image: createAttachment({
@ -102,18 +98,6 @@ story.add('No Image, Long Title With Description', () => {
return <StagedLinkPreview {...props} />; return <StagedLinkPreview {...props} />;
}); });
story.add('Image, Long Title With Description', () => {
const props = createProps({
title: LONG_TITLE,
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: 'image/jpeg' as MIMEType,
}),
});
return <StagedLinkPreview {...props} />;
});
story.add('No Image, Long Title Without Description', () => { story.add('No Image, Long Title Without Description', () => {
const props = createProps({ const props = createProps({
title: LONG_TITLE, title: LONG_TITLE,
@ -123,7 +107,7 @@ story.add('No Image, Long Title Without Description', () => {
return <StagedLinkPreview {...props} />; return <StagedLinkPreview {...props} />;
}); });
story.add('Image, Long Title With Description', () => { story.add('Image, Long Title Without Description', () => {
const props = createProps({ const props = createProps({
title: LONG_TITLE, title: LONG_TITLE,
image: createAttachment({ image: createAttachment({

View file

@ -52,6 +52,17 @@ export function renderChangeDetail(
} = options; } = options;
const fromYou = Boolean(from && from === ourConversationId); const fromYou = Boolean(from && from === ourConversationId);
if (detail.type === 'create') {
if (fromYou) {
return renderString('GroupV2--create--you', i18n);
}
if (from) {
return renderString('GroupV2--create--other', i18n, {
memberName: renderContact(from),
});
}
return renderString('GroupV2--create--unknown', i18n);
}
if (detail.type === 'title') { if (detail.type === 'title') {
const { newTitle } = detail; const { newTitle } = detail;
@ -406,10 +417,12 @@ export function renderChangeDetail(
} else if (detail.type === 'pending-remove-one') { } else if (detail.type === 'pending-remove-one') {
const { inviter, conversationId } = detail; const { inviter, conversationId } = detail;
const weAreInviter = Boolean(inviter && inviter === ourConversationId); const weAreInviter = Boolean(inviter && inviter === ourConversationId);
const weAreInvited = conversationId === ourConversationId;
const sentByInvited = Boolean(from && from === conversationId); const sentByInvited = Boolean(from && from === conversationId);
const sentByInviter = Boolean(from && inviter && from === inviter);
if (weAreInviter) { if (weAreInviter) {
if (inviter && sentByInvited) { if (sentByInvited) {
return renderString('GroupV2--pending-remove--decline--you', i18n, [ return renderString('GroupV2--pending-remove--decline--you', i18n, [
renderContact(conversationId), renderContact(conversationId),
]); ]);
@ -438,6 +451,9 @@ export function renderChangeDetail(
); );
} }
if (sentByInvited) { if (sentByInvited) {
if (fromYou) {
return renderString('GroupV2--pending-remove--decline--from-you', i18n);
}
if (inviter) { if (inviter) {
return renderString('GroupV2--pending-remove--decline--other', i18n, [ return renderString('GroupV2--pending-remove--decline--other', i18n, [
renderContact(inviter), renderContact(inviter),
@ -445,6 +461,20 @@ export function renderChangeDetail(
} }
return renderString('GroupV2--pending-remove--decline--unknown', i18n); return renderString('GroupV2--pending-remove--decline--unknown', i18n);
} }
if (inviter && sentByInviter) {
if (weAreInvited) {
return renderString(
'GroupV2--pending-remove--revoke-own--to-you',
i18n,
[renderContact(inviter)]
);
}
return renderString(
'GroupV2--pending-remove--revoke-own--unknown',
i18n,
[renderContact(inviter)]
);
}
if (inviter) { if (inviter) {
if (fromYou) { if (fromYou) {
return renderString( return renderString(

View file

@ -20,6 +20,7 @@ import {
MessageAttributesType, MessageAttributesType,
} from './model-types.d'; } from './model-types.d';
import { import {
createProfileKeyCredentialPresentation,
decryptGroupBlob, decryptGroupBlob,
decryptProfileKey, decryptProfileKey,
decryptProfileKeyCredentialPresentation, decryptProfileKeyCredentialPresentation,
@ -28,9 +29,11 @@ import {
deriveGroupPublicParams, deriveGroupPublicParams,
deriveGroupSecretParams, deriveGroupSecretParams,
encryptGroupBlob, encryptGroupBlob,
encryptUuid,
getAuthCredentialPresentation, getAuthCredentialPresentation,
getClientZkAuthOperations, getClientZkAuthOperations,
getClientZkGroupCipher, getClientZkGroupCipher,
getClientZkProfileOperations,
} from './util/zkgroup'; } from './util/zkgroup';
import { import {
arrayBufferToBase64, arrayBufferToBase64,
@ -51,6 +54,9 @@ import { GroupCredentialsType } from './textsecure/WebAPI';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
export type GroupV2AccessCreateChangeType = {
type: 'create';
};
export type GroupV2AccessAttributesChangeType = { export type GroupV2AccessAttributesChangeType = {
type: 'access-attributes'; type: 'access-attributes';
newPrivilege: number; newPrivilege: number;
@ -112,6 +118,7 @@ export type GroupV2PendingRemoveManyChangeType = {
}; };
export type GroupV2ChangeDetailType = export type GroupV2ChangeDetailType =
| GroupV2AccessCreateChangeType
| GroupV2TitleChangeType | GroupV2TitleChangeType
| GroupV2AvatarChangeType | GroupV2AvatarChangeType
| GroupV2AccessAttributesChangeType | GroupV2AccessAttributesChangeType
@ -156,7 +163,7 @@ export const MASTER_KEY_LENGTH = 32;
const TEMPORAL_AUTH_REJECTED_CODE = 401; const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403; const GROUP_ACCESS_DENIED_CODE = 403;
// Group Changes // Group Modifications
export function buildDisappearingMessagesTimerChange({ export function buildDisappearingMessagesTimerChange({
expireTimer, expireTimer,
@ -189,6 +196,91 @@ export function buildDisappearingMessagesTimerChange({
return actions; return actions;
} }
export function buildDeletePendingMemberChange({
uuid,
group,
}: {
uuid: string;
group: ConversationAttributesType;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildDeletePendingMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeletePendingMemberAction();
deletePendingMember.deletedUserId = uuidCipherTextBuffer;
actions.version = (group.revision || 0) + 1;
actions.deletePendingMembers = [deletePendingMember];
return actions;
}
export function buildDeleteMemberChange({
uuid,
group,
}: {
uuid: string;
group: ConversationAttributesType;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error('buildDeleteMemberChange: group was missing secretParams!');
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deleteMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberAction();
deleteMember.deletedUserId = uuidCipherTextBuffer;
actions.version = (group.revision || 0) + 1;
actions.deleteMembers = [deleteMember];
return actions;
}
export function buildPromoteMemberChange({
group,
profileKeyCredentialBase64,
serverPublicParamsBase64,
}: {
group: ConversationAttributesType;
profileKeyCredentialBase64: string;
serverPublicParamsBase64: string;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
);
}
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
group.secretParams
);
const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromotePendingMemberAction();
promotePendingMember.presentation = presentation;
actions.version = (group.revision || 0) + 1;
actions.promotePendingMembers = [promotePendingMember];
return actions;
}
export async function uploadGroupChange({ export async function uploadGroupChange({
actions, actions,
group, group,
@ -309,11 +401,7 @@ export async function maybeUpdateGroup({
// Ensure we have the credentials we need before attempting GroupsV2 operations // Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials(); await maybeFetchNewCredentials();
const { const updates = await getGroupUpdates({
newAttributes,
groupChangeMessages,
members,
} = await getGroupUpdates({
group: conversation.attributes, group: conversation.attributes,
serverPublicParamsBase64: window.getServerPublicParams(), serverPublicParamsBase64: window.getServerPublicParams(),
newRevision, newRevision,
@ -321,7 +409,34 @@ export async function maybeUpdateGroup({
dropInitialJoinMessage, dropInitialJoinMessage,
}); });
conversation.set(newAttributes); await updateGroup({ conversation, receivedAt, sentAt, updates });
} catch (error) {
window.log.error(
`maybeUpdateGroup/${logId}: Failed to update group:`,
error && error.stack ? error.stack : error
);
throw error;
}
}
async function updateGroup({
conversation,
receivedAt,
sentAt,
updates,
}: {
conversation: ConversationModel;
receivedAt?: number;
sentAt?: number;
updates: UpdatesResultType;
}): Promise<void> {
const { newAttributes, groupChangeMessages, members } = updates;
const startingRevision = conversation.get('revision');
const endingRevision = newAttributes.revision;
const isInitialDataFetch =
!isNumber(startingRevision) && isNumber(endingRevision);
// Ensure that all generated messages are ordered properly. // Ensure that all generated messages are ordered properly.
// Before the provided timestamp so update messages appear before the // Before the provided timestamp so update messages appear before the
@ -329,6 +444,18 @@ export async function maybeUpdateGroup({
let syntheticTimestamp = receivedAt let syntheticTimestamp = receivedAt
? receivedAt - (groupChangeMessages.length + 1) ? receivedAt - (groupChangeMessages.length + 1)
: Date.now(); : Date.now();
conversation.set({
...newAttributes,
// We force this conversation into the left pane if this is the first time we've
// fetched data about it, and we were able to fetch its name. Nobody likes to see
// Unknown Group in the left pane.
active_at:
isInitialDataFetch && newAttributes.name
? syntheticTimestamp
: newAttributes.active_at,
});
// Save all synthetic messages describing group changes // Save all synthetic messages describing group changes
const changeMessagesToSave = groupChangeMessages.map(changeMessage => { const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
// We do this to preserve the order of the timeline // We do this to preserve the order of the timeline
@ -362,14 +489,7 @@ export async function maybeUpdateGroup({
} }
}); });
await conversation.updateLastMessage(); // No need for convo.updateLastMessage(), 'newmessage' handler does that
} catch (error) {
window.log.error(
`maybeUpdateGroup/${logId}: Failed to update group:`,
error && error.stack ? error.stack : error
);
throw error;
}
} }
function idForLogging(group: ConversationAttributesType) { function idForLogging(group: ConversationAttributesType) {
@ -396,12 +516,16 @@ async function getGroupUpdates({
const currentRevision = group.revision; const currentRevision = group.revision;
const isFirstFetch = !isNumber(group.revision); const isFirstFetch = !isNumber(group.revision);
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
const isOneVersionUp =
isNumber(currentRevision) &&
isNumber(newRevision) &&
newRevision === currentRevision + 1;
if ( if (
groupChangeBase64 && groupChangeBase64 &&
((isFirstFetch && newRevision === 0) || isNumber(newRevision) &&
(isNumber(newRevision) && (isInitialCreationMessage || isOneVersionUp)
isNumber(currentRevision) &&
newRevision === currentRevision + 1))
) { ) {
window.log.info(`getGroupUpdates/${logId}: Processing just one change`); window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64); const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
@ -700,9 +824,12 @@ async function integrateGroupChanges({
for (let j = 0; j < jmax; j += 1) { for (let j = 0; j < jmax; j += 1) {
const changeState = groupChanges[j]; const changeState = groupChanges[j];
const { groupChange } = changeState; const { groupChange, groupState } = changeState;
if (!groupChange) { if (!groupChange || !groupState) {
window.log.warn(
'integrateGroupChanges: item had neither groupState nor groupChange. Skipping.'
);
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
continue; continue;
} }
@ -717,6 +844,7 @@ async function integrateGroupChanges({
group: attributes, group: attributes,
newRevision, newRevision,
groupChange, groupChange,
groupState,
}); });
attributes = newAttributes; attributes = newAttributes;
@ -768,15 +896,19 @@ async function integrateGroupChanges({
async function integrateGroupChange({ async function integrateGroupChange({
group, group,
groupChange, groupChange,
groupState,
newRevision, newRevision,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
groupChange: GroupChangeClass; groupChange: GroupChangeClass;
groupState?: GroupClass;
newRevision: number; newRevision: number;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const logId = idForLogging(group); const logId = idForLogging(group);
if (!group.secretParams) { if (!group.secretParams) {
throw new Error('integrateGroupChange: Group was missing secretParams!'); throw new Error(
`integrateGroupChange/${logId}: Group was missing secretParams!`
);
} }
const groupChangeActions = window.textsecure.protobuf.GroupChange.Actions.decode( const groupChangeActions = window.textsecure.protobuf.GroupChange.Actions.decode(
@ -804,9 +936,48 @@ async function integrateGroupChange({
); );
const sourceConversationId = sourceConversation.id; const sourceConversationId = sourceConversation.id;
const isFirstFetch = !isNumber(group.revision);
const isMoreThanOneVersionUp =
groupChangeActions.version &&
isNumber(group.revision) &&
groupChangeActions.version > group.revision + 1;
if (groupState && (isFirstFetch || isMoreThanOneVersionUp)) {
window.log.info(
`integrateGroupChange/${logId}: Applying full group state, from version ${group.revision} to ${groupState.version}`
);
const decryptedGroupState = decryptGroupState(
groupState,
group.secretParams,
logId
);
const newAttributes = await applyGroupState({
group,
groupState: decryptedGroupState,
sourceConversationId: isFirstFetch ? sourceConversationId : undefined,
});
return {
newAttributes,
groupChangeMessages: extractDiffs({
old: group,
current: newAttributes,
sourceConversationId: isFirstFetch ? sourceConversationId : undefined,
}),
members: getMembers(decryptedGroupState),
};
}
window.log.info(
`integrateGroupChange/${logId}: Applying group change actions, from version ${group.revision} to ${groupChangeActions.version}`
);
const { newAttributes, newProfileKeys } = await applyGroupChange({ const { newAttributes, newProfileKeys } = await applyGroupChange({
group, group,
actions: decryptedChangeActions, actions: decryptedChangeActions,
sourceConversationId,
}); });
const groupChangeMessages = extractDiffs({ const groupChangeMessages = extractDiffs({
old: group, old: group,
@ -861,7 +1032,10 @@ export async function getCurrentGroupState({
logId logId
); );
const newAttributes = await applyGroupState(group, decryptedGroupState); const newAttributes = await applyGroupState({
group,
groupState: decryptedGroupState,
});
return { return {
newAttributes, newAttributes,
@ -888,7 +1062,10 @@ function extractDiffs({
const logId = idForLogging(old); const logId = idForLogging(old);
const details: Array<GroupV2ChangeDetailType> = []; const details: Array<GroupV2ChangeDetailType> = [];
const ourConversationId = window.ConversationController.getOurConversationId(); const ourConversationId = window.ConversationController.getOurConversationId();
let areWeInGroup = false; let areWeInGroup = false;
let areWeInvitedToGroup = false;
let whoInvitedUsUserId = null;
if ( if (
current.accessControl && current.accessControl &&
@ -988,6 +1165,11 @@ function extractDiffs({
const { conversationId } = currentPendingMember; const { conversationId } = currentPendingMember;
const oldPendingMember = oldPendingMemberLookup[conversationId]; const oldPendingMember = oldPendingMemberLookup[conversationId];
if (ourConversationId && conversationId === ourConversationId) {
areWeInvitedToGroup = true;
whoInvitedUsUserId = currentPendingMember.addedByUserId;
}
if (!oldPendingMember) { if (!oldPendingMember) {
lastPendingConversationId = conversationId; lastPendingConversationId = conversationId;
count += 1; count += 1;
@ -1049,16 +1231,50 @@ function extractDiffs({
const sourceUuid = conversation ? conversation.get('uuid') : undefined; const sourceUuid = conversation ? conversation.get('uuid') : undefined;
const firstUpdate = !isNumber(old.revision); const firstUpdate = !isNumber(old.revision);
const firstEventSourceId = sourceConversationId || ourConversationId;
// Here we hardcode initial messages if this is our first time processing data this
// group. Ideally we can collapse it down to just one of: 'you were added',
// 'you were invited', or 'you created.'
if (firstUpdate && dropInitialJoinMessage) { if (firstUpdate && dropInitialJoinMessage) {
message = undefined; message = undefined;
} else if (
firstUpdate &&
ourConversationId &&
sourceConversationId &&
sourceConversationId === ourConversationId
) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from: sourceConversationId,
details: [
{
type: 'create',
},
],
},
};
} else if (firstUpdate && ourConversationId && areWeInvitedToGroup) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from: whoInvitedUsUserId || sourceConversationId,
details: [
{
type: 'pending-add-one',
conversationId: ourConversationId,
},
],
},
};
} else if (firstUpdate && ourConversationId && areWeInGroup) { } else if (firstUpdate && ourConversationId && areWeInGroup) {
message = { message = {
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v2-change', type: 'group-v2-change',
groupV2Change: { groupV2Change: {
from: firstEventSourceId, from: sourceConversationId,
details: [ details: [
{ {
type: 'member-add', type: 'member-add',
@ -1067,6 +1283,19 @@ function extractDiffs({
], ],
}, },
}; };
} else if (firstUpdate) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from: sourceConversationId,
details: [
{
type: 'create',
},
],
},
};
} else if (details.length > 0) { } else if (details.length > 0) {
message = { message = {
...generateBasicMessage(), ...generateBasicMessage(),
@ -1132,18 +1361,22 @@ type GroupChangeResultType = {
}; };
async function applyGroupChange({ async function applyGroupChange({
group,
actions, actions,
group,
sourceConversationId,
}: { }: {
sourceConversationId?: string;
group: ConversationAttributesType;
actions: GroupChangeClass.Actions; actions: GroupChangeClass.Actions;
group: ConversationAttributesType;
sourceConversationId: string;
}): Promise<GroupChangeResultType> { }): Promise<GroupChangeResultType> {
const logId = idForLogging(group); const logId = idForLogging(group);
const ourConversationId = window.ConversationController.getOurConversationId();
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role; const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const version = actions.version || 0; const version = actions.version || 0;
const result = { const result: ConversationAttributesType = {
...group, ...group,
}; };
const newProfileKeys: Array<GroupChangeMemberType> = []; const newProfileKeys: Array<GroupChangeMemberType> = [];
@ -1198,6 +1431,15 @@ async function applyGroupChange({
delete pendingMembers[conversation.id]; delete pendingMembers[conversation.id];
} }
// Capture who added us
if (
ourConversationId &&
sourceConversationId &&
conversation.id === ourConversationId
) {
result.addedBy = sourceConversationId;
}
if (added.profileKey) { if (added.profileKey) {
newProfileKeys.push({ newProfileKeys.push({
profileKey: added.profileKey, profileKey: added.profileKey,
@ -1438,7 +1680,6 @@ async function applyGroupChange({
}; };
} }
const ourConversationId = window.ConversationController.getOurConversationId();
if (ourConversationId) { if (ourConversationId) {
result.left = !members[ourConversationId]; result.left = !members[ourConversationId];
} }
@ -1524,14 +1765,19 @@ async function applyNewAvatar(
} }
/* eslint-enable no-param-reassign */ /* eslint-enable no-param-reassign */
async function applyGroupState( async function applyGroupState({
group: ConversationAttributesType, group,
groupState: GroupClass groupState,
): Promise<ConversationAttributesType> { sourceConversationId,
}: {
group: ConversationAttributesType;
groupState: GroupClass;
sourceConversationId?: string;
}): Promise<ConversationAttributesType> {
const logId = idForLogging(group); const logId = idForLogging(group);
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const version = groupState.version || 0; const version = groupState.version || 0;
const result = { const result: ConversationAttributesType = {
...group, ...group,
}; };
@ -1589,6 +1835,16 @@ async function applyGroupState(
if (ourConversationId && conversation.id === ourConversationId) { if (ourConversationId && conversation.id === ourConversationId) {
result.left = false; result.left = false;
// Capture who added us if we were previously not in group
if (
sourceConversationId &&
(result.membersV2 || []).every(
item => item.conversationId !== ourConversationId
)
) {
result.addedBy = sourceConversationId;
}
} }
if ( if (

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

@ -139,7 +139,7 @@ export type ConversationAttributesTypeType = 'private' | 'group';
export type ConversationAttributesType = { export type ConversationAttributesType = {
accessKey: string | null; accessKey: string | null;
addedBy: string; addedBy?: string;
capabilities: { uuid: string }; capabilities: { uuid: string };
color?: ColorType; color?: ColorType;
discoveredUnregisteredAt: number; discoveredUnregisteredAt: number;
@ -155,8 +155,8 @@ export type ConversationAttributesType = {
muteExpiresAt: number; muteExpiresAt: number;
pinIndex?: number; pinIndex?: number;
profileAvatar: WhatIsThis; profileAvatar: WhatIsThis;
profileKeyCredential: unknown | null; profileKeyCredential: string | null;
profileKeyVersion: string; profileKeyVersion: string | null;
quotedMessageId: string; quotedMessageId: string;
sealedSender: unknown; sealedSender: unknown;
sentMessageCount: number; sentMessageCount: number;

View file

@ -7,7 +7,7 @@ import {
ConversationAttributesType, ConversationAttributesType,
VerificationOptions, VerificationOptions,
} from '../model-types.d'; } from '../model-types.d';
import { CallbackResultType } from '../textsecure/SendMessage'; import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
import { import {
ConversationType, ConversationType,
ConversationTypeType, ConversationTypeType,
@ -25,6 +25,7 @@ import {
stringFromBytes, stringFromBytes,
verifyAccessKey, verifyAccessKey,
} from '../Crypto'; } from '../Crypto';
import { GroupChangeClass } from '../textsecure.d';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -107,11 +108,6 @@ export class ConversationModel extends window.Backbone.Model<
messageCollection?: MessageModelCollectionType; messageCollection?: MessageModelCollectionType;
// backbone ensures this exists in initialize()
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
messageRequestEnum: typeof window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
ourNumber?: string; ourNumber?: string;
ourUuid?: string; ourUuid?: string;
@ -192,8 +188,6 @@ export class ConversationModel extends window.Backbone.Model<
this.ourNumber = window.textsecure.storage.user.getNumber(); this.ourNumber = window.textsecure.storage.user.getNumber();
this.ourUuid = window.textsecure.storage.user.getUuid(); this.ourUuid = window.textsecure.storage.user.getUuid();
this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus; this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus;
this.messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
// This may be overridden by window.ConversationController.getOrCreate, and signify // This may be overridden by window.ConversationController.getOrCreate, and signify
// our first save to the database. Or first fetch from the database. // our first save to the database. Or first fetch from the database.
@ -258,16 +252,334 @@ export class ConversationModel extends window.Backbone.Model<
} }
isGroupV1(): boolean { isGroupV1(): boolean {
const groupID = this.get('groupId'); const groupId = this.get('groupId');
if (!groupID) { if (!groupId) {
return false; return false;
} }
return fromEncodedBinaryToArrayBuffer(groupID).byteLength === 16; return fromEncodedBinaryToArrayBuffer(groupId).byteLength === 16;
} }
isGroupV2(): boolean { isGroupV2(): boolean {
return (this.get('groupVersion') || 0) === 2; const groupId = this.get('groupId');
if (!groupId) {
return false;
}
const groupVersion = this.get('groupVersion') || 0;
return groupVersion === 2 && base64ToArrayBuffer(groupId).byteLength === 32;
}
isMemberPending(conversationId: string): boolean {
if (!this.isGroupV2()) {
throw new Error(
`isPendingMember: Called for non-GroupV2 conversation ${this.idForLogging()}`
);
}
const pendingMembersV2 = this.get('pendingMembersV2');
if (!pendingMembersV2 || !pendingMembersV2.length) {
return false;
}
return window._.any(
pendingMembersV2,
item => item.conversationId === conversationId
);
}
isMember(conversationId: string): boolean {
if (!this.isGroupV2()) {
throw new Error(
`isMember: Called for non-GroupV2 conversation ${this.idForLogging()}`
);
}
const membersV2 = this.get('membersV2');
if (!membersV2 || !membersV2.length) {
return false;
}
return window._.any(
membersV2,
item => item.conversationId === conversationId
);
}
async updateExpirationTimerInGroupV2(
seconds?: number
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
const current = this.get('expireTimer');
const bothFalsey = Boolean(current) === false && Boolean(seconds) === false;
if (current === seconds || bothFalsey) {
window.log.warn(
`updateExpirationTimerInGroupV2/${idLog}: Requested timer ${seconds} is unchanged from existing ${current}.`
);
return undefined;
}
return window.Signal.Groups.buildDisappearingMessagesTimerChange({
expireTimer: seconds || 0,
group: this.attributes,
});
}
async promotePendingMember(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(conversationId)) {
window.log.warn(
`promotePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`promotePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
);
}
// We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will
// ensure that we have as much as we can get with the data we have.
let profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
await pendingMember.getProfiles();
profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
throw new Error(
`promotePendingMember/${idLog}: No profileKeyCredential for conversation ${pendingMember.idForLogging()}`
);
}
}
return window.Signal.Groups.buildPromoteMemberChange({
group: this.attributes,
profileKeyCredentialBase64,
serverPublicParamsBase64: window.getServerPublicParams(),
});
}
async removePendingMember(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(conversationId)) {
window.log.warn(
`removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`removePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
throw new Error(
`removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
);
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: this.attributes,
uuid,
});
}
async removeMember(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMember(conversationId)) {
window.log.warn(
`removeMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const member = window.ConversationController.get(conversationId);
if (!member) {
throw new Error(
`removeMember/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = member.get('uuid');
if (!uuid) {
throw new Error(
`removeMember/${idLog}: Missing uuid for conversation ${member.idForLogging()}`
);
}
return window.Signal.Groups.buildDeleteMemberChange({
group: this.attributes,
uuid,
});
}
async modifyGroupV2({
name,
createGroupChange,
}: {
name: string;
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
}): Promise<void> {
const idLog = `${name}/${this.idForLogging()}`;
if (!this.isGroupV2()) {
throw new Error(
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
);
}
const ONE_MINUTE = 1000 * 60;
const startTime = Date.now();
const timeoutTime = startTime + ONE_MINUTE;
const MAX_ATTEMPTS = 5;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
try {
// eslint-disable-next-line no-await-in-loop
await window.waitForEmptyEventQueue();
window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
// eslint-disable-next-line no-await-in-loop
await this.queueJob(async () => {
window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
const actions = await createGroupChange();
if (!actions) {
window.log.warn(
`modifyGroupV2/${idLog}: No change actions. Returning early.`
);
return;
}
// The new revision has to be exactly one more than the current revision
// or it won't upload properly, and it won't apply in maybeUpdateGroup
const currentRevision = this.get('revision');
const newRevision = actions.version;
if ((currentRevision || 0) + 1 !== newRevision) {
throw new Error(
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
);
}
// Upload. If we don't have permission, the server will return an error here.
const groupChange = await window.Signal.Groups.uploadGroupChange({
actions,
group: this.attributes,
serverPublicParamsBase64: window.getServerPublicParams(),
});
const groupChangeBuffer = groupChange.toArrayBuffer();
const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer);
// Apply change locally, just like we would with an incoming change. This will
// change conversation state and add change notifications to the timeline.
await window.Signal.Groups.maybeUpdateGroup({
conversation: this,
groupChangeBase64,
newRevision,
});
// Send message to notify group members (including pending members) of change
const profileKey = this.get('profileSharing')
? window.storage.get('profileKey')
: undefined;
const sendOptions = this.getSendOptions();
const timestamp = Date.now();
const promise = this.wrapSend(
window.textsecure.messaging.sendMessageToGroup(
{
groupV2: this.getGroupV2Info({
groupChange: groupChangeBuffer,
includePendingMembers: true,
}),
timestamp,
profileKey,
},
sendOptions
)
);
// We don't save this message; we just use it to ensure that a sync message is
// sent to our linked devices.
const m = new window.Whisper.Message(({
conversationId: this.id,
type: 'not-to-save',
sent_at: timestamp,
received_at: timestamp,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown) as MessageAttributesType);
// This is to ensure that the functions in send() and sendSyncMessage()
// don't save anything to the database.
m.doNotSave = true;
await m.send(promise);
});
// If we've gotten here with no error, we exit!
window.log.info(
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
);
break;
} catch (error) {
if (error.code === 409 && Date.now() <= timeoutTime) {
window.log.info(
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
);
// eslint-disable-next-line no-await-in-loop
await this.fetchLatestGroupV2Data();
} else if (error.code === 409) {
window.log.error(
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
);
// We don't wait here because we're breaking out of the loop immediately.
this.fetchLatestGroupV2Data();
throw error;
} else {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`modifyGroupV2/${idLog}: Error updating: ${errorString}`
);
throw error;
}
}
}
} }
isEverUnregistered(): boolean { isEverUnregistered(): boolean {
@ -522,7 +834,11 @@ export class ConversationModel extends window.Backbone.Model<
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
} }
getGroupV2Info(groupChange?: ArrayBuffer): WhatIsThis { getGroupV2Info(
options: { groupChange?: ArrayBuffer; includePendingMembers?: boolean } = {}
): GroupV2InfoType | undefined {
const { groupChange, includePendingMembers } = options;
if (this.isPrivate() || !this.isGroupV2()) { if (this.isPrivate() || !this.isGroupV2()) {
return undefined; return undefined;
} }
@ -533,7 +849,9 @@ export class ConversationModel extends window.Backbone.Model<
), ),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
revision: this.get('revision')!, revision: this.get('revision')!,
members: this.getRecipients(), members: this.getRecipients({
includePendingMembers,
}),
groupChange, groupChange,
}; };
} }
@ -854,7 +1172,11 @@ export class ConversationModel extends window.Backbone.Model<
* This function is called when a message request is accepted in order to * This function is called when a message request is accepted in order to
* handle sending read receipts and download any pending attachments. * handle sending read receipts and download any pending attachments.
*/ */
async handleReadAndDownloadAttachments(): Promise<void> { async handleReadAndDownloadAttachments(
options: { isLocalAction?: boolean } = {}
): Promise<void> {
const { isLocalAction } = options;
let messages: MessageModelCollectionType | undefined; let messages: MessageModelCollectionType | undefined;
do { do {
const first = messages ? messages.first() : undefined; const first = messages ? messages.first() : undefined;
@ -887,8 +1209,12 @@ export class ConversationModel extends window.Backbone.Model<
timestamp: m.get('sent_at'), timestamp: m.get('sent_at'),
hasErrors: m.hasErrors(), hasErrors: m.hasErrors(),
})); }));
if (isLocalAction) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.sendReadReceiptsFor(receiptSpecs); await this.sendReadReceiptsFor(receiptSpecs);
}
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await Promise.all(readMessages.map(m => m.queueAttachmentDownloads())); await Promise.all(readMessages.map(m => m.queueAttachmentDownloads()));
} while (messages.length > 0); } while (messages.length > 0);
@ -898,56 +1224,128 @@ export class ConversationModel extends window.Backbone.Model<
response: number, response: number,
{ fromSync = false, viaStorageServiceSync = false } = {} { fromSync = false, viaStorageServiceSync = false } = {}
): Promise<void> { ): Promise<void> {
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
const isLocalAction = !fromSync && !viaStorageServiceSync;
const ourConversationId = window.ConversationController.getOurConversationId();
// Apply message request response locally // Apply message request response locally
this.set({ this.set({
messageRequestResponseType: response, messageRequestResponseType: response,
}); });
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (response === messageRequestEnum.ACCEPT) {
if (response === this.messageRequestEnum!.ACCEPT) {
this.unblock({ viaStorageServiceSync }); this.unblock({ viaStorageServiceSync });
this.enableProfileSharing({ viaStorageServiceSync }); this.enableProfileSharing({ viaStorageServiceSync });
if (!fromSync) { await this.handleReadAndDownloadAttachments({ isLocalAction });
if (isLocalAction) {
if (this.isGroupV1() || this.isPrivate()) {
this.sendProfileKeyUpdate(); this.sendProfileKeyUpdate();
// Locally accepted } else if (
await this.handleReadAndDownloadAttachments(); ourConversationId &&
this.isGroupV2() &&
this.isMemberPending(ourConversationId)
) {
await this.modifyGroupV2({
name: 'promotePendingMember',
createGroupChange: () =>
this.promotePendingMember(ourConversationId),
});
} else if (
ourConversationId &&
this.isGroupV2() &&
this.isMember(ourConversationId)
) {
window.log.info(
'applyMessageRequestResponse/accept: Already a member of v2 group'
);
} else {
window.log.error(
'applyMessageRequestResponse/accept: Neither member nor pending member of v2 group'
);
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion }
} else if (response === this.messageRequestEnum!.BLOCK) { } else if (response === messageRequestEnum.BLOCK) {
// Block locally, other devices should block upon receiving the sync message // Block locally, other devices should block upon receiving the sync message
this.block({ viaStorageServiceSync }); this.block({ viaStorageServiceSync });
this.disableProfileSharing({ viaStorageServiceSync }); this.disableProfileSharing({ viaStorageServiceSync });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
} else if (response === this.messageRequestEnum!.DELETE) { if (isLocalAction) {
if (this.isGroupV1() || this.isPrivate()) {
await this.leaveGroup();
} else if (this.isGroupV2()) {
await this.leaveGroupV2();
}
}
} else if (response === messageRequestEnum.DELETE) {
this.disableProfileSharing({ viaStorageServiceSync });
// Delete messages locally, other devices should delete upon receiving // Delete messages locally, other devices should delete upon receiving
// the sync message // the sync message
this.destroyMessages(); await this.destroyMessages();
this.disableProfileSharing({ viaStorageServiceSync });
this.updateLastMessage(); this.updateLastMessage();
if (!fromSync) {
if (isLocalAction) {
this.trigger('unload', 'deleted from message request'); this.trigger('unload', 'deleted from message request');
if (this.isGroupV1() || this.isPrivate()) {
await this.leaveGroup();
} else if (this.isGroupV2()) {
await this.leaveGroupV2();
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion }
} else if (response === this.messageRequestEnum!.BLOCK_AND_DELETE) { } else if (response === messageRequestEnum.BLOCK_AND_DELETE) {
// Delete messages locally, other devices should delete upon receiving
// the sync message
this.destroyMessages();
this.disableProfileSharing({ viaStorageServiceSync });
this.updateLastMessage();
// Block locally, other devices should block upon receiving the sync message // Block locally, other devices should block upon receiving the sync message
this.block({ viaStorageServiceSync }); this.block({ viaStorageServiceSync });
// Leave group if this was a local action this.disableProfileSharing({ viaStorageServiceSync });
if (!fromSync) {
// TODO: DESKTOP-721 // Delete messages locally, other devices should delete upon receiving
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // the sync message
// @ts-ignore await this.destroyMessages();
this.leaveGroup(); this.updateLastMessage();
if (isLocalAction) {
this.trigger('unload', 'blocked and deleted from message request'); this.trigger('unload', 'blocked and deleted from message request');
if (this.isGroupV1() || this.isPrivate()) {
await this.leaveGroup();
} else if (this.isGroupV2()) {
await this.leaveGroupV2();
} }
} }
} }
}
async leaveGroupV2(): Promise<void> {
const ourConversationId = window.ConversationController.getOurConversationId();
if (
ourConversationId &&
this.isGroupV2() &&
this.isMemberPending(ourConversationId)
) {
await this.modifyGroupV2({
name: 'delete',
createGroupChange: () => this.removePendingMember(ourConversationId),
});
} else if (
ourConversationId &&
this.isGroupV2() &&
this.isMember(ourConversationId)
) {
await this.modifyGroupV2({
name: 'delete',
createGroupChange: () => this.removeMember(ourConversationId),
});
} else {
window.log.error(
'leaveGroupV2: We were neither a member nor a pending member of the group'
);
}
}
async syncMessageRequestResponse(response: number): Promise<void> { async syncMessageRequestResponse(response: number): Promise<void> {
// Let this run, no await // Let this run, no await
@ -1302,10 +1700,9 @@ export class ConversationModel extends window.Backbone.Model<
return true; return true;
} }
if ( const messageRequestEnum =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
this.getMessageRequestResponseType() === this.messageRequestEnum!.ACCEPT if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) {
) {
return true; return true;
} }
@ -1611,15 +2008,24 @@ export class ConversationModel extends window.Backbone.Model<
return this.jobQueue.add(taskWithTimeout); return this.jobQueue.add(taskWithTimeout);
} }
getMembers(): Array<WhatIsThis> { getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<WhatIsThis> {
if (this.isPrivate()) { if (this.isPrivate()) {
return [this]; return [this];
} }
if (this.get('membersV2')) { if (this.get('membersV2')) {
const { includePendingMembers } = options;
const members: Array<{ conversationId: string }> = includePendingMembers
? [
...(this.get('membersV2') || []),
...(this.get('pendingMembersV2') || []),
]
: this.get('membersV2') || [];
return window._.compact( return window._.compact(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion members.map(member => {
this.get('membersV2')!.map(member => {
const c = window.ConversationController.get(member.conversationId); const c = window.ConversationController.get(member.conversationId);
// In groups we won't sent to contacts we believe are unregistered // In groups we won't sent to contacts we believe are unregistered
@ -1660,8 +2066,12 @@ export class ConversationModel extends window.Backbone.Model<
return members.map(member => member.id); return members.map(member => member.id);
} }
getRecipients(): Array<string> { getRecipients(
const members = this.getMembers(); options: { includePendingMembers?: boolean } = {}
): Array<string> {
const { includePendingMembers } = options;
const members = this.getMembers({ includePendingMembers });
// Eliminate our // Eliminate our
return window._.compact( return window._.compact(
@ -1913,6 +2323,10 @@ export class ConversationModel extends window.Backbone.Model<
); );
})(); })();
// This is to ensure that the functions in send() and sendSyncMessage() don't save
// anything to the database.
message.doNotSave = true;
return message.send(this.wrapSend(promise)); return message.send(this.wrapSend(promise));
}).catch(error => { }).catch(error => {
window.log.error( window.log.error(
@ -2040,6 +2454,10 @@ export class ConversationModel extends window.Backbone.Model<
); );
})(); })();
// This is to ensure that the functions in send() and sendSyncMessage() don't save
// anything to the database.
message.doNotSave = true;
return message.send(this.wrapSend(promise)); return message.send(this.wrapSend(promise));
}).catch(error => { }).catch(error => {
window.log.error('Error sending reaction', reaction, target, error); window.log.error('Error sending reaction', reaction, target, error);
@ -2436,7 +2854,9 @@ export class ConversationModel extends window.Backbone.Model<
return false; return false;
} }
return Boolean(conv.get('name')) || conv.get('profileSharing'); return Boolean(
conv.isMe() || conv.get('name') || conv.get('profileSharing')
);
} }
async updateLastMessage(): Promise<void> { async updateLastMessage(): Promise<void> {
@ -2500,74 +2920,6 @@ export class ConversationModel extends window.Backbone.Model<
} }
} }
async updateExpirationTimerInGroupV2(seconds?: number): Promise<void> {
// Make change on the server
const actions = window.Signal.Groups.buildDisappearingMessagesTimerChange({
expireTimer: seconds || 0,
group: this.attributes,
});
let signedGroupChange;
try {
signedGroupChange = await window.Signal.Groups.uploadGroupChange({
actions,
group: this.attributes,
serverPublicParamsBase64: window.getServerPublicParams(),
});
} catch (error) {
// Get latest GroupV2 data, since we ran into trouble updating it
this.fetchLatestGroupV2Data();
throw error;
}
// Update local conversation
this.set({
expireTimer: seconds || 0,
revision: actions.version,
});
window.Signal.Data.updateConversation(this.attributes);
// Create local notification
const timestamp = Date.now();
const id = window.getGuid();
const message = window.MessageController.register(
id,
new window.Whisper.Message(({
id,
conversationId: this.id,
sent_at: timestamp,
received_at: timestamp,
flags:
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: {
expireTimer: seconds,
sourceUuid: this.ourUuid,
},
// TODO: DESKTOP-722
} as unknown) as typeof window.Whisper.MessageAttributesType)
);
await window.Signal.Data.saveMessage(message.attributes, {
Message: window.Whisper.Message,
forceSave: true,
});
this.trigger('newmessage', message);
// Send message to all group members
const profileKey = this.get('profileSharing')
? window.storage.get('profileKey')
: undefined;
const sendOptions = this.getSendOptions();
const promise = window.textsecure.messaging.sendMessageToGroup(
{
groupV2: this.getGroupV2Info(signedGroupChange.toArrayBuffer()),
timestamp,
profileKey,
},
sendOptions
);
message.send(promise);
}
async updateExpirationTimer( async updateExpirationTimer(
providedExpireTimer: number | undefined, providedExpireTimer: number | undefined,
providedSource: unknown, providedSource: unknown,
@ -2580,7 +2932,11 @@ export class ConversationModel extends window.Backbone.Model<
'updateExpirationTimer: GroupV2 timers are not updated this way' 'updateExpirationTimer: GroupV2 timers are not updated this way'
); );
} }
await this.updateExpirationTimerInGroupV2(providedExpireTimer); await this.modifyGroupV2({
name: 'updateExpirationTimer',
createGroupChange: () =>
this.updateExpirationTimerInGroupV2(providedExpireTimer),
});
return false; return false;
} }
@ -3018,11 +3374,15 @@ export class ConversationModel extends window.Backbone.Model<
const profileKeyVersionHex = c.get('profileKeyVersion')!; const profileKeyVersionHex = c.get('profileKeyVersion')!;
const existingProfileKeyCredential = c.get('profileKeyCredential'); const existingProfileKeyCredential = c.get('profileKeyCredential');
const weHaveVersion = Boolean(profileKey && uuid && profileKeyVersionHex);
let profileKeyCredentialRequestHex; let profileKeyCredentialRequestHex;
let profileCredentialRequestContext; let profileCredentialRequestContext;
if (weHaveVersion && !existingProfileKeyCredential) { if (
profileKey &&
uuid &&
profileKeyVersionHex &&
!existingProfileKeyCredential
) {
window.log.info('Generating request...'); window.log.info('Generating request...');
({ ({
requestHex: profileKeyCredentialRequestHex, requestHex: profileKeyCredentialRequestHex,

View file

@ -115,6 +115,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
CURRENT_PROTOCOL_VERSION?: number; CURRENT_PROTOCOL_VERSION?: number;
// Set when sending some sync messages, so we get the functionality of
// send(), without zombie messages going into the database.
doNotSave?: boolean;
INITIAL_PROTOCOL_VERSION?: number; INITIAL_PROTOCOL_VERSION?: number;
OUR_NUMBER?: string; OUR_NUMBER?: string;
@ -1715,7 +1719,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.set({ errors }); this.set({ errors });
if (!skipSave) { if (!skipSave && !this.doNotSave) {
await window.Signal.Data.saveMessage(this.attributes, { await window.Signal.Data.saveMessage(this.attributes, {
Message: window.Whisper.Message, Message: window.Whisper.Message,
}); });
@ -2130,9 +2134,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
unidentifiedDeliveries: result.unidentifiedDeliveries, unidentifiedDeliveries: result.unidentifiedDeliveries,
}); });
if (!this.doNotSave) {
await window.Signal.Data.saveMessage(this.attributes, { await window.Signal.Data.saveMessage(this.attributes, {
Message: window.Whisper.Message, Message: window.Whisper.Message,
}); });
}
this.trigger('sent', this); this.trigger('sent', this);
this.sendSyncMessage(); this.sendSyncMessage();
@ -2315,9 +2321,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
synced: true, synced: true,
dataMessage: null, dataMessage: null,
}); });
return window.Signal.Data.saveMessage(this.attributes, {
// Return early, skip the save
if (this.doNotSave) {
return result;
}
await window.Signal.Data.saveMessage(this.attributes, {
Message: window.Whisper.Message, Message: window.Whisper.Message,
}).then(() => result); });
return result;
}); });
}; };

View file

@ -237,18 +237,21 @@ function applyMessageRequestState(
record: MessageRequestCapableRecord, record: MessageRequestCapableRecord,
conversation: ConversationModel conversation: ConversationModel
): void { ): void {
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
if (record.blocked) { if (record.blocked) {
conversation.applyMessageRequestResponse( conversation.applyMessageRequestResponse(messageRequestEnum.BLOCK, {
conversation.messageRequestEnum.BLOCK, fromSync: true,
{ fromSync: true, viaStorageServiceSync: true } viaStorageServiceSync: true,
); });
} else if (record.whitelisted) { } else if (record.whitelisted) {
// unblocking is also handled by this function which is why the next // unblocking is also handled by this function which is why the next
// condition is part of the else-if and not separate // condition is part of the else-if and not separate
conversation.applyMessageRequestResponse( conversation.applyMessageRequestResponse(messageRequestEnum.ACCEPT, {
conversation.messageRequestEnum.ACCEPT, fromSync: true,
{ fromSync: true, viaStorageServiceSync: true } viaStorageServiceSync: true,
); });
} else if (!record.blocked) { } else if (!record.blocked) {
// if the condition above failed the state could still be blocked=false // if the condition above failed the state could still be blocked=false
// in which case we should unblock the conversation // in which case we should unblock the conversation
@ -408,8 +411,9 @@ export async function mergeGroupV2Record(
const now = Date.now(); const now = Date.now();
const conversationId = window.ConversationController.ensureGroup(groupId, { const conversationId = window.ConversationController.ensureGroup(groupId, {
// We want this conversation to show in the left pane when we first learn about it // Note: We don't set active_at, because we don't want the group to show until
active_at: now, // we have information about it beyond these initial details.
// see maybeUpdateGroup().
timestamp: now, timestamp: now,
// Basic GroupV2 data // Basic GroupV2 data
groupVersion: 2, groupVersion: 2,

1
ts/textsecure.d.ts vendored
View file

@ -318,6 +318,7 @@ export declare class GroupChangeClass {
data: ArrayBuffer | ByteBufferClass, data: ArrayBuffer | ByteBufferClass,
encoding?: string encoding?: string
) => GroupChangeClass; ) => GroupChangeClass;
toArrayBuffer: () => ArrayBuffer;
actions?: ProtoBinaryType; actions?: ProtoBinaryType;
serverSignature?: ProtoBinaryType; serverSignature?: ProtoBinaryType;

View file

@ -90,7 +90,7 @@ type QuoteAttachmentType = {
attachmentPointer?: AttachmentPointerClass; attachmentPointer?: AttachmentPointerClass;
}; };
type GroupV2InfoType = { export type GroupV2InfoType = {
groupChange?: ArrayBuffer; groupChange?: ArrayBuffer;
masterKey: ArrayBuffer; masterKey: ArrayBuffer;
revision: number; revision: number;

View file

@ -9,6 +9,7 @@ import {
GroupSecretParams, GroupSecretParams,
ProfileKey, ProfileKey,
ProfileKeyCiphertext, ProfileKeyCiphertext,
ProfileKeyCredential,
ProfileKeyCredentialPresentation, ProfileKeyCredentialPresentation,
ProfileKeyCredentialRequestContext, ProfileKeyCredentialRequestContext,
ProfileKeyCredentialResponse, ProfileKeyCredentialResponse,
@ -220,6 +221,29 @@ export function getAuthCredentialPresentation(
return compatArrayToArrayBuffer(presentation.serialize()); return compatArrayToArrayBuffer(presentation.serialize());
} }
export function createProfileKeyCredentialPresentation(
clientZkProfileCipher: ClientZkProfileOperations,
profileKeyCredentialBase64: string,
groupSecretParamsBase64: string
): ArrayBuffer {
const profileKeyCredentialArray = base64ToCompatArray(
profileKeyCredentialBase64
);
const profileKeyCredential = new ProfileKeyCredential(
profileKeyCredentialArray
);
const secretParams = new GroupSecretParams(
base64ToCompatArray(groupSecretParamsBase64)
);
const presentation = clientZkProfileCipher.createProfileKeyCredentialPresentation(
secretParams,
profileKeyCredential
);
return compatArrayToArrayBuffer(presentation.serialize());
}
export function getClientZkAuthOperations( export function getClientZkAuthOperations(
serverPublicParamsBase64: string serverPublicParamsBase64: string
): ClientZkAuthOperations { ): ClientZkAuthOperations {

View file

@ -1,4 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
// Note: because this file is pulled in directly from background.html, we can't use any
// imports here aside from types. That means everything will have to be references via
// globals right on window.
interface GetLinkPreviewResult { interface GetLinkPreviewResult {
title: string; title: string;
url: string; url: string;
@ -249,10 +254,6 @@ Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: window.i18n('maximumAttachments'), template: window.i18n('maximumAttachments'),
}); });
Whisper.TimerConflictToast = Whisper.ToastView.extend({
template: window.i18n('GroupV2--timerConflict'),
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({ Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen', templateName: 'conversation-loading-screen',
className: 'conversation-loading-screen', className: 'conversation-loading-screen',
@ -592,26 +593,51 @@ Whisper.ConversationView = Whisper.View.extend({
clearQuotedMessage: () => this.setQuoteMessage(null), clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl, micCellEl,
attachmentListEl, attachmentListEl,
onAccept: this.model.syncMessageRequestResponse.bind( onAccept: () => {
this.longRunningTaskWrapper({
name: 'onAccept',
task: this.model.syncMessageRequestResponse.bind(
this.model, this.model,
messageRequestEnum.ACCEPT messageRequestEnum.ACCEPT
), ),
onBlock: this.model.syncMessageRequestResponse.bind( });
},
onBlock: () => {
this.longRunningTaskWrapper({
name: 'onBlock',
task: this.model.syncMessageRequestResponse.bind(
this.model, this.model,
messageRequestEnum.BLOCK messageRequestEnum.BLOCK
), ),
onUnblock: this.model.syncMessageRequestResponse.bind( });
},
onUnblock: () => {
this.longRunningTaskWrapper({
name: 'onUnblock',
task: this.model.syncMessageRequestResponse.bind(
this.model, this.model,
messageRequestEnum.ACCEPT messageRequestEnum.ACCEPT
), ),
onDelete: this.model.syncMessageRequestResponse.bind( });
},
onDelete: () => {
this.longRunningTaskWrapper({
name: 'onDelete',
task: this.model.syncMessageRequestResponse.bind(
this.model, this.model,
messageRequestEnum.DELETE messageRequestEnum.DELETE
), ),
onBlockAndDelete: this.model.syncMessageRequestResponse.bind( });
},
onBlockAndDelete: () => {
this.longRunningTaskWrapper({
name: 'onBlockAndDelete',
task: this.model.syncMessageRequestResponse.bind(
this.model, this.model,
messageRequestEnum.BLOCK_AND_DELETE messageRequestEnum.BLOCK_AND_DELETE
), ),
});
},
}; };
this.compositionAreaView = new Whisper.ReactWrapperView({ this.compositionAreaView = new Whisper.ReactWrapperView({
@ -626,6 +652,74 @@ Whisper.ConversationView = Whisper.View.extend({
this.$('.composition-area-placeholder').append(this.compositionAreaView.el); this.$('.composition-area-placeholder').append(this.compositionAreaView.el);
}, },
async longRunningTaskWrapper({
name,
task,
}: {
name: string;
task: () => Promise<void>;
}): Promise<void> {
const idLog = `${name}/${this.model.idForLogging()}`;
const ONE_SECOND = 1000;
let progressView: any | undefined;
let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => {
window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`);
// Note: this component uses a portal to render itself into the top-level DOM. No
// need to attach it to the DOM here.
progressView = new Whisper.ReactWrapperView({
className: 'progress-modal-wrapper',
Component: window.Signal.Components.ProgressModal,
});
}, ONE_SECOND);
// Note: any task we put here needs to have its own safety valve; this function will
// show a spinner until it's done
try {
window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`);
await task();
window.log.info(
`longRunningTaskWrapper/${idLog}: Task completed successfully`
);
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = undefined;
}
if (progressView) {
progressView.remove();
progressView = undefined;
}
} catch (error) {
window.log.error(
`longRunningTaskWrapper/${idLog}: Error!`,
error && error.stack ? error.stack : error
);
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = undefined;
}
if (progressView) {
progressView.remove();
progressView = undefined;
}
window.log.info(`longRunningTaskWrapper/${idLog}: Showing error dialog`);
// Note: this component uses a portal to render itself into the top-level DOM. No
// need to attach it to the DOM here.
const errorView = new Whisper.ReactWrapperView({
className: 'error-modal-wrapper',
Component: window.Signal.Components.ErrorModal,
props: {
onClose: () => errorView.remove(),
},
});
}
},
setupTimeline() { setupTimeline() {
const { id } = this.model; const { id } = this.model;
@ -2619,17 +2713,12 @@ Whisper.ConversationView = Whisper.View.extend({
}, },
async setDisappearingMessages(seconds: any) { async setDisappearingMessages(seconds: any) {
try { const valueToSet = seconds > 0 ? seconds : null;
if (seconds > 0) {
await this.model.updateExpirationTimer(seconds); await this.longRunningTaskWrapper({
} else { name: 'updateExpirationTimer',
await this.model.updateExpirationTimer(null); task: async () => this.model.updateExpirationTimer(valueToSet),
} });
} catch (error) {
if (error.code === 409) {
this.showToast(Whisper.TimerConflictToast);
}
}
}, },
setMuteNotifications(ms: number) { setMuteNotifications(ms: number) {

54
ts/window.d.ts vendored
View file

@ -16,8 +16,10 @@ import {
import { ContactRecordIdentityState, TextSecureType } from './textsecure.d'; import { ContactRecordIdentityState, TextSecureType } from './textsecure.d';
import { WebAPIConnectType } from './textsecure/WebAPI'; import { WebAPIConnectType } from './textsecure/WebAPI';
import { CallingClass } from './services/calling'; import { CallingClass } from './services/calling';
import * as Groups from './groups';
import * as Crypto from './Crypto'; import * as Crypto from './Crypto';
import * as RemoteConfig from './RemoteConfig'; import * as RemoteConfig from './RemoteConfig';
import * as zkgroup from './util/zkgroup';
import { LocalizerType, BodyRangesType } from './types/Util'; import { LocalizerType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling'; import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors'; import { ColorType } from './types/Colors';
@ -33,6 +35,8 @@ import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
import { combineNames } from './util'; import { combineNames } from './util';
import { BatcherType } from './util/batcher'; import { BatcherType } from './util/batcher';
import { ErrorModal } from './components/ErrorModal';
import { ProgressModal } from './components/ProgressModal';
export { Long } from 'long'; export { Long } from 'long';
@ -166,16 +170,7 @@ declare global {
}; };
Crypto: typeof Crypto; Crypto: typeof Crypto;
Data: typeof Data; Data: typeof Data;
Groups: { Groups: typeof Groups;
maybeUpdateGroup: (options: unknown) => Promise<void>;
waitThenMaybeUpdateGroup: (options: unknown) => Promise<void>;
uploadGroupChange: (
options: unknown
) => Promise<{ toArrayBuffer: () => ArrayBuffer }>;
buildDisappearingMessagesTimerChange: (
options: unknown
) => { version: number };
};
Metadata: { Metadata: {
SecretSessionCipher: typeof SecretSessionCipherClass; SecretSessionCipher: typeof SecretSessionCipherClass;
createCertificateValidator: ( createCertificateValidator: (
@ -383,23 +378,7 @@ declare global {
del: unknown, del: unknown,
bool: boolean bool: boolean
) => void; ) => void;
zkgroup: { zkgroup: typeof zkgroup;
generateProfileKeyCredentialRequest: (
clientZkProfileCipher: unknown,
uuid: string,
profileKey: unknown
) => { requestHex: string; context: unknown };
getClientZkProfileOperations: (params: unknown) => unknown;
handleProfileKeyCredential: (
clientZkProfileCipher: unknown,
profileCredentialRequestContext: unknown,
credential: unknown
) => unknown;
deriveProfileKeyVersion: (
profileKey: unknown,
uuid: string
) => string;
};
combineNames: typeof combineNames; combineNames: typeof combineNames;
migrateColor: (color: string) => ColorType; migrateColor: (color: string) => ColorType;
createBatcher: (options: WhatIsThis) => WhatIsThis; createBatcher: (options: WhatIsThis) => WhatIsThis;
@ -429,20 +408,23 @@ declare global {
renderChange: (change: unknown, things: unknown) => Array<string>; renderChange: (change: unknown, things: unknown) => Array<string>;
}; };
Components: { Components: {
StagedLinkPreview: any;
Quote: any;
ContactDetail: any;
MessageDetail: any;
Lightbox: any;
MediaGallery: any;
CaptionEditor: any;
ConversationHeader: any;
AttachmentList: any; AttachmentList: any;
CaptionEditor: any;
ContactDetail: any;
ConversationHeader: any;
ErrorModal: typeof ErrorModal;
Lightbox: any;
LightboxGallery: any;
MediaGallery: any;
MessageDetail: any;
ProgressModal: typeof ProgressModal;
Quote: any;
StagedLinkPreview: any;
getCallingNotificationText: ( getCallingNotificationText: (
callHistoryDetails: unknown, callHistoryDetails: unknown,
i18n: unknown i18n: unknown
) => string; ) => string;
LightboxGallery: any;
}; };
OS: { OS: {
isLinux: () => boolean; isLinux: () => boolean;