GroupsV2: Better group invite behavior
This commit is contained in:
parent
b9ff4f07d3
commit
d51a0b5ece
24 changed files with 1408 additions and 313 deletions
|
@ -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",
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
35
ts/components/ErrorModal.stories.tsx
Normal file
35
ts/components/ErrorModal.stories.tsx
Normal 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',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
46
ts/components/ErrorModal.tsx
Normal file
46
ts/components/ErrorModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
21
ts/components/ProgressDialog.stories.tsx
Normal file
21
ts/components/ProgressDialog.stories.tsx
Normal 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} />;
|
||||||
|
});
|
18
ts/components/ProgressDialog.tsx
Normal file
18
ts/components/ProgressDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
13
ts/components/ProgressModal.stories.tsx
Normal file
13
ts/components/ProgressModal.stories.tsx
Normal 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} />;
|
||||||
|
});
|
35
ts/components/ProgressModal.tsx
Normal file
35
ts/components/ProgressModal.tsx
Normal 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;
|
||||||
|
});
|
|
@ -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];
|
||||||
|
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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(
|
||||||
|
|
326
ts/groups.ts
326
ts/groups.ts
|
@ -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
6
ts/model-types.d.ts
vendored
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
1
ts/textsecure.d.ts
vendored
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
54
ts/window.d.ts
vendored
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue