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",
|
||||
"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": {
|
||||
"message": "Admin",
|
||||
"description": "Shown next to the set of administrators in a group"
|
||||
},
|
||||
"GroupV2--timerConflict": {
|
||||
"message": "Failed to update disappearing message timer. Please try again later.",
|
||||
"description": "Shown if the user runs into a group update conflict attempting to update a GroupV2 message timer"
|
||||
"updating": {
|
||||
"message": "Updating...",
|
||||
"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": {
|
||||
"message": "$memberName$ changed the group name to \"$newTitle$\".",
|
||||
|
@ -3253,7 +3286,7 @@
|
|||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"GroupV2--member-remove--other--other": {
|
||||
|
@ -3271,7 +3304,7 @@
|
|||
}
|
||||
},
|
||||
"GroupV2--member-remove--other--self": {
|
||||
"message": "$memberName$ left.",
|
||||
"message": "$memberName$ left the group.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes",
|
||||
"placeholders": {
|
||||
"memberName": {
|
||||
|
@ -3311,11 +3344,11 @@
|
|||
}
|
||||
},
|
||||
"GroupV2--member-remove--you--you": {
|
||||
"message": "You left.",
|
||||
"message": "You left the group.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
|
@ -3437,7 +3470,7 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
"placeholders": {
|
||||
"inviteeName": {
|
||||
|
@ -3457,7 +3490,7 @@
|
|||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"GroupV2--pending-add--many--other": {
|
||||
|
@ -3485,7 +3518,7 @@
|
|||
}
|
||||
},
|
||||
"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",
|
||||
"placeholders": {
|
||||
"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": {
|
||||
"message": "1 person declined their invitation to the group.",
|
||||
"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": {
|
||||
"message": "An admin revoked an invitation to the group for 1 person.",
|
||||
"description": "Shown in timeline or conversation preview when v2 group changes",
|
||||
|
|
|
@ -28,6 +28,7 @@ const {
|
|||
AttachmentList,
|
||||
} = require('../../ts/components/conversation/AttachmentList');
|
||||
const { CaptionEditor } = require('../../ts/components/CaptionEditor');
|
||||
const { ConfirmationModal } = require('../../ts/components/ConfirmationModal');
|
||||
const {
|
||||
ContactDetail,
|
||||
} = require('../../ts/components/conversation/ContactDetail');
|
||||
|
@ -36,6 +37,7 @@ const {
|
|||
ConversationHeader,
|
||||
} = require('../../ts/components/conversation/ConversationHeader');
|
||||
const { Emojify } = require('../../ts/components/conversation/Emojify');
|
||||
const { ErrorModal } = require('../../ts/components/ErrorModal');
|
||||
const { Lightbox } = require('../../ts/components/Lightbox');
|
||||
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
|
||||
const {
|
||||
|
@ -45,6 +47,7 @@ const {
|
|||
MessageDetail,
|
||||
} = require('../../ts/components/conversation/MessageDetail');
|
||||
const { Quote } = require('../../ts/components/conversation/Quote');
|
||||
const { ProgressModal } = require('../../ts/components/ProgressModal');
|
||||
const {
|
||||
SafetyNumberChangeDialog,
|
||||
} = require('../../ts/components/SafetyNumberChangeDialog');
|
||||
|
@ -289,16 +292,19 @@ exports.setup = (options = {}) => {
|
|||
const Components = {
|
||||
AttachmentList,
|
||||
CaptionEditor,
|
||||
ConfirmationModal,
|
||||
ContactDetail,
|
||||
ContactListItem,
|
||||
ConversationHeader,
|
||||
Emojify,
|
||||
ErrorModal,
|
||||
getCallingNotificationText,
|
||||
Lightbox,
|
||||
LightboxGallery,
|
||||
MediaGallery,
|
||||
MessageDetail,
|
||||
Quote,
|
||||
ProgressModal,
|
||||
SafetyNumberChangeDialog,
|
||||
StagedLinkPreview,
|
||||
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-message-body__highlight {
|
||||
|
@ -6659,7 +6671,6 @@ button.module-image__border-overlay:focus {
|
|||
border-top: 1px solid $color-gray-05;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: -18px;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
margin-top: -14px;
|
||||
|
@ -7808,10 +7819,11 @@ button.module-image__border-overlay:focus {
|
|||
|
||||
&__content {
|
||||
@include font-body-1;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
margin-top: 22px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
|
@ -9276,6 +9288,60 @@ button.module-image__border-overlay:focus {
|
|||
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 */
|
||||
|
||||
.react-tooltip-lite {
|
||||
|
|
|
@ -2546,6 +2546,9 @@ type WhatIsThis = typeof window.WhatIsThis;
|
|||
if (message.groupV2) {
|
||||
const { id } = message.groupV2;
|
||||
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,
|
||||
masterKey: message.groupV2.masterKey,
|
||||
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',
|
||||
'incoming',
|
||||
'on-background',
|
||||
'on-progress-dialog',
|
||||
] as const;
|
||||
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', () => {
|
||||
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({
|
||||
from: CONTACT_C,
|
||||
details: [
|
||||
|
|
|
@ -65,10 +65,6 @@ story.add('No Image', () => {
|
|||
return <StagedLinkPreview {...createProps()} />;
|
||||
});
|
||||
|
||||
story.add('No Image', () => {
|
||||
return <StagedLinkPreview {...createProps()} />;
|
||||
});
|
||||
|
||||
story.add('Image', () => {
|
||||
const props = createProps({
|
||||
image: createAttachment({
|
||||
|
@ -102,18 +98,6 @@ story.add('No Image, Long Title With Description', () => {
|
|||
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', () => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
|
@ -123,7 +107,7 @@ story.add('No Image, Long Title Without Description', () => {
|
|||
return <StagedLinkPreview {...props} />;
|
||||
});
|
||||
|
||||
story.add('Image, Long Title With Description', () => {
|
||||
story.add('Image, Long Title Without Description', () => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
image: createAttachment({
|
||||
|
|
|
@ -52,6 +52,17 @@ export function renderChangeDetail(
|
|||
} = options;
|
||||
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') {
|
||||
const { newTitle } = detail;
|
||||
|
||||
|
@ -406,10 +417,12 @@ export function renderChangeDetail(
|
|||
} else if (detail.type === 'pending-remove-one') {
|
||||
const { inviter, conversationId } = detail;
|
||||
const weAreInviter = Boolean(inviter && inviter === ourConversationId);
|
||||
const weAreInvited = conversationId === ourConversationId;
|
||||
const sentByInvited = Boolean(from && from === conversationId);
|
||||
const sentByInviter = Boolean(from && inviter && from === inviter);
|
||||
|
||||
if (weAreInviter) {
|
||||
if (inviter && sentByInvited) {
|
||||
if (sentByInvited) {
|
||||
return renderString('GroupV2--pending-remove--decline--you', i18n, [
|
||||
renderContact(conversationId),
|
||||
]);
|
||||
|
@ -438,6 +451,9 @@ export function renderChangeDetail(
|
|||
);
|
||||
}
|
||||
if (sentByInvited) {
|
||||
if (fromYou) {
|
||||
return renderString('GroupV2--pending-remove--decline--from-you', i18n);
|
||||
}
|
||||
if (inviter) {
|
||||
return renderString('GroupV2--pending-remove--decline--other', i18n, [
|
||||
renderContact(inviter),
|
||||
|
@ -445,6 +461,20 @@ export function renderChangeDetail(
|
|||
}
|
||||
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 (fromYou) {
|
||||
return renderString(
|
||||
|
|
326
ts/groups.ts
326
ts/groups.ts
|
@ -20,6 +20,7 @@ import {
|
|||
MessageAttributesType,
|
||||
} from './model-types.d';
|
||||
import {
|
||||
createProfileKeyCredentialPresentation,
|
||||
decryptGroupBlob,
|
||||
decryptProfileKey,
|
||||
decryptProfileKeyCredentialPresentation,
|
||||
|
@ -28,9 +29,11 @@ import {
|
|||
deriveGroupPublicParams,
|
||||
deriveGroupSecretParams,
|
||||
encryptGroupBlob,
|
||||
encryptUuid,
|
||||
getAuthCredentialPresentation,
|
||||
getClientZkAuthOperations,
|
||||
getClientZkGroupCipher,
|
||||
getClientZkProfileOperations,
|
||||
} from './util/zkgroup';
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
|
@ -51,6 +54,9 @@ import { GroupCredentialsType } from './textsecure/WebAPI';
|
|||
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
||||
import { ConversationModel } from './models/conversations';
|
||||
|
||||
export type GroupV2AccessCreateChangeType = {
|
||||
type: 'create';
|
||||
};
|
||||
export type GroupV2AccessAttributesChangeType = {
|
||||
type: 'access-attributes';
|
||||
newPrivilege: number;
|
||||
|
@ -112,6 +118,7 @@ export type GroupV2PendingRemoveManyChangeType = {
|
|||
};
|
||||
|
||||
export type GroupV2ChangeDetailType =
|
||||
| GroupV2AccessCreateChangeType
|
||||
| GroupV2TitleChangeType
|
||||
| GroupV2AvatarChangeType
|
||||
| GroupV2AccessAttributesChangeType
|
||||
|
@ -156,7 +163,7 @@ export const MASTER_KEY_LENGTH = 32;
|
|||
const TEMPORAL_AUTH_REJECTED_CODE = 401;
|
||||
const GROUP_ACCESS_DENIED_CODE = 403;
|
||||
|
||||
// Group Changes
|
||||
// Group Modifications
|
||||
|
||||
export function buildDisappearingMessagesTimerChange({
|
||||
expireTimer,
|
||||
|
@ -189,6 +196,91 @@ export function buildDisappearingMessagesTimerChange({
|
|||
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({
|
||||
actions,
|
||||
group,
|
||||
|
@ -309,11 +401,7 @@ export async function maybeUpdateGroup({
|
|||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
await maybeFetchNewCredentials();
|
||||
|
||||
const {
|
||||
newAttributes,
|
||||
groupChangeMessages,
|
||||
members,
|
||||
} = await getGroupUpdates({
|
||||
const updates = await getGroupUpdates({
|
||||
group: conversation.attributes,
|
||||
serverPublicParamsBase64: window.getServerPublicParams(),
|
||||
newRevision,
|
||||
|
@ -321,7 +409,34 @@ export async function maybeUpdateGroup({
|
|||
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.
|
||||
// Before the provided timestamp so update messages appear before the
|
||||
|
@ -329,6 +444,18 @@ export async function maybeUpdateGroup({
|
|||
let syntheticTimestamp = receivedAt
|
||||
? receivedAt - (groupChangeMessages.length + 1)
|
||||
: 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
|
||||
const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
|
||||
// We do this to preserve the order of the timeline
|
||||
|
@ -362,14 +489,7 @@ export async function maybeUpdateGroup({
|
|||
}
|
||||
});
|
||||
|
||||
await conversation.updateLastMessage();
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`maybeUpdateGroup/${logId}: Failed to update group:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
// No need for convo.updateLastMessage(), 'newmessage' handler does that
|
||||
}
|
||||
|
||||
function idForLogging(group: ConversationAttributesType) {
|
||||
|
@ -396,12 +516,16 @@ async function getGroupUpdates({
|
|||
const currentRevision = group.revision;
|
||||
const isFirstFetch = !isNumber(group.revision);
|
||||
|
||||
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
||||
const isOneVersionUp =
|
||||
isNumber(currentRevision) &&
|
||||
isNumber(newRevision) &&
|
||||
newRevision === currentRevision + 1;
|
||||
|
||||
if (
|
||||
groupChangeBase64 &&
|
||||
((isFirstFetch && newRevision === 0) ||
|
||||
(isNumber(newRevision) &&
|
||||
isNumber(currentRevision) &&
|
||||
newRevision === currentRevision + 1))
|
||||
isNumber(newRevision) &&
|
||||
(isInitialCreationMessage || isOneVersionUp)
|
||||
) {
|
||||
window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
|
||||
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
|
||||
|
@ -700,9 +824,12 @@ async function integrateGroupChanges({
|
|||
for (let j = 0; j < jmax; j += 1) {
|
||||
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
|
||||
continue;
|
||||
}
|
||||
|
@ -717,6 +844,7 @@ async function integrateGroupChanges({
|
|||
group: attributes,
|
||||
newRevision,
|
||||
groupChange,
|
||||
groupState,
|
||||
});
|
||||
|
||||
attributes = newAttributes;
|
||||
|
@ -768,15 +896,19 @@ async function integrateGroupChanges({
|
|||
async function integrateGroupChange({
|
||||
group,
|
||||
groupChange,
|
||||
groupState,
|
||||
newRevision,
|
||||
}: {
|
||||
group: ConversationAttributesType;
|
||||
groupChange: GroupChangeClass;
|
||||
groupState?: GroupClass;
|
||||
newRevision: number;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
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(
|
||||
|
@ -804,9 +936,48 @@ async function integrateGroupChange({
|
|||
);
|
||||
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({
|
||||
group,
|
||||
actions: decryptedChangeActions,
|
||||
sourceConversationId,
|
||||
});
|
||||
const groupChangeMessages = extractDiffs({
|
||||
old: group,
|
||||
|
@ -861,7 +1032,10 @@ export async function getCurrentGroupState({
|
|||
logId
|
||||
);
|
||||
|
||||
const newAttributes = await applyGroupState(group, decryptedGroupState);
|
||||
const newAttributes = await applyGroupState({
|
||||
group,
|
||||
groupState: decryptedGroupState,
|
||||
});
|
||||
|
||||
return {
|
||||
newAttributes,
|
||||
|
@ -888,7 +1062,10 @@ function extractDiffs({
|
|||
const logId = idForLogging(old);
|
||||
const details: Array<GroupV2ChangeDetailType> = [];
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
let areWeInGroup = false;
|
||||
let areWeInvitedToGroup = false;
|
||||
let whoInvitedUsUserId = null;
|
||||
|
||||
if (
|
||||
current.accessControl &&
|
||||
|
@ -988,6 +1165,11 @@ function extractDiffs({
|
|||
const { conversationId } = currentPendingMember;
|
||||
const oldPendingMember = oldPendingMemberLookup[conversationId];
|
||||
|
||||
if (ourConversationId && conversationId === ourConversationId) {
|
||||
areWeInvitedToGroup = true;
|
||||
whoInvitedUsUserId = currentPendingMember.addedByUserId;
|
||||
}
|
||||
|
||||
if (!oldPendingMember) {
|
||||
lastPendingConversationId = conversationId;
|
||||
count += 1;
|
||||
|
@ -1049,16 +1231,50 @@ function extractDiffs({
|
|||
const sourceUuid = conversation ? conversation.get('uuid') : undefined;
|
||||
|
||||
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) {
|
||||
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) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
type: 'group-v2-change',
|
||||
groupV2Change: {
|
||||
from: firstEventSourceId,
|
||||
from: sourceConversationId,
|
||||
details: [
|
||||
{
|
||||
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) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
|
@ -1132,18 +1361,22 @@ type GroupChangeResultType = {
|
|||
};
|
||||
|
||||
async function applyGroupChange({
|
||||
group,
|
||||
actions,
|
||||
group,
|
||||
sourceConversationId,
|
||||
}: {
|
||||
sourceConversationId?: string;
|
||||
group: ConversationAttributesType;
|
||||
actions: GroupChangeClass.Actions;
|
||||
group: ConversationAttributesType;
|
||||
sourceConversationId: string;
|
||||
}): Promise<GroupChangeResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||
|
||||
const version = actions.version || 0;
|
||||
const result = {
|
||||
const result: ConversationAttributesType = {
|
||||
...group,
|
||||
};
|
||||
const newProfileKeys: Array<GroupChangeMemberType> = [];
|
||||
|
@ -1198,6 +1431,15 @@ async function applyGroupChange({
|
|||
delete pendingMembers[conversation.id];
|
||||
}
|
||||
|
||||
// Capture who added us
|
||||
if (
|
||||
ourConversationId &&
|
||||
sourceConversationId &&
|
||||
conversation.id === ourConversationId
|
||||
) {
|
||||
result.addedBy = sourceConversationId;
|
||||
}
|
||||
|
||||
if (added.profileKey) {
|
||||
newProfileKeys.push({
|
||||
profileKey: added.profileKey,
|
||||
|
@ -1438,7 +1680,6 @@ async function applyGroupChange({
|
|||
};
|
||||
}
|
||||
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
if (ourConversationId) {
|
||||
result.left = !members[ourConversationId];
|
||||
}
|
||||
|
@ -1524,14 +1765,19 @@ async function applyNewAvatar(
|
|||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
|
||||
async function applyGroupState(
|
||||
group: ConversationAttributesType,
|
||||
groupState: GroupClass
|
||||
): Promise<ConversationAttributesType> {
|
||||
async function applyGroupState({
|
||||
group,
|
||||
groupState,
|
||||
sourceConversationId,
|
||||
}: {
|
||||
group: ConversationAttributesType;
|
||||
groupState: GroupClass;
|
||||
sourceConversationId?: string;
|
||||
}): Promise<ConversationAttributesType> {
|
||||
const logId = idForLogging(group);
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const version = groupState.version || 0;
|
||||
const result = {
|
||||
const result: ConversationAttributesType = {
|
||||
...group,
|
||||
};
|
||||
|
||||
|
@ -1589,6 +1835,16 @@ async function applyGroupState(
|
|||
|
||||
if (ourConversationId && conversation.id === ourConversationId) {
|
||||
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 (
|
||||
|
|
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 = {
|
||||
accessKey: string | null;
|
||||
addedBy: string;
|
||||
addedBy?: string;
|
||||
capabilities: { uuid: string };
|
||||
color?: ColorType;
|
||||
discoveredUnregisteredAt: number;
|
||||
|
@ -155,8 +155,8 @@ export type ConversationAttributesType = {
|
|||
muteExpiresAt: number;
|
||||
pinIndex?: number;
|
||||
profileAvatar: WhatIsThis;
|
||||
profileKeyCredential: unknown | null;
|
||||
profileKeyVersion: string;
|
||||
profileKeyCredential: string | null;
|
||||
profileKeyVersion: string | null;
|
||||
quotedMessageId: string;
|
||||
sealedSender: unknown;
|
||||
sentMessageCount: number;
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
ConversationAttributesType,
|
||||
VerificationOptions,
|
||||
} from '../model-types.d';
|
||||
import { CallbackResultType } from '../textsecure/SendMessage';
|
||||
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
|
||||
import {
|
||||
ConversationType,
|
||||
ConversationTypeType,
|
||||
|
@ -25,6 +25,7 @@ import {
|
|||
stringFromBytes,
|
||||
verifyAccessKey,
|
||||
} from '../Crypto';
|
||||
import { GroupChangeClass } from '../textsecure.d';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
@ -107,11 +108,6 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
|
||||
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;
|
||||
|
||||
ourUuid?: string;
|
||||
|
@ -192,8 +188,6 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
this.ourNumber = window.textsecure.storage.user.getNumber();
|
||||
this.ourUuid = window.textsecure.storage.user.getUuid();
|
||||
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
|
||||
// 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 {
|
||||
const groupID = this.get('groupId');
|
||||
if (!groupID) {
|
||||
const groupId = this.get('groupId');
|
||||
if (!groupId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fromEncodedBinaryToArrayBuffer(groupID).byteLength === 16;
|
||||
return fromEncodedBinaryToArrayBuffer(groupId).byteLength === 16;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -522,7 +834,11 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
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()) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -533,7 +849,9 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
revision: this.get('revision')!,
|
||||
members: this.getRecipients(),
|
||||
members: this.getRecipients({
|
||||
includePendingMembers,
|
||||
}),
|
||||
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
|
||||
* 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;
|
||||
do {
|
||||
const first = messages ? messages.first() : undefined;
|
||||
|
@ -887,8 +1209,12 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
timestamp: m.get('sent_at'),
|
||||
hasErrors: m.hasErrors(),
|
||||
}));
|
||||
|
||||
if (isLocalAction) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.sendReadReceiptsFor(receiptSpecs);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all(readMessages.map(m => m.queueAttachmentDownloads()));
|
||||
} while (messages.length > 0);
|
||||
|
@ -898,56 +1224,128 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
response: number,
|
||||
{ fromSync = false, viaStorageServiceSync = false } = {}
|
||||
): Promise<void> {
|
||||
const messageRequestEnum =
|
||||
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
||||
const isLocalAction = !fromSync && !viaStorageServiceSync;
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
// Apply message request response locally
|
||||
this.set({
|
||||
messageRequestResponseType: response,
|
||||
});
|
||||
window.Signal.Data.updateConversation(this.attributes);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (response === this.messageRequestEnum!.ACCEPT) {
|
||||
if (response === messageRequestEnum.ACCEPT) {
|
||||
this.unblock({ viaStorageServiceSync });
|
||||
this.enableProfileSharing({ viaStorageServiceSync });
|
||||
|
||||
if (!fromSync) {
|
||||
await this.handleReadAndDownloadAttachments({ isLocalAction });
|
||||
|
||||
if (isLocalAction) {
|
||||
if (this.isGroupV1() || this.isPrivate()) {
|
||||
this.sendProfileKeyUpdate();
|
||||
// Locally accepted
|
||||
await this.handleReadAndDownloadAttachments();
|
||||
} else if (
|
||||
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
|
||||
this.block({ 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
|
||||
// the sync message
|
||||
this.destroyMessages();
|
||||
this.disableProfileSharing({ viaStorageServiceSync });
|
||||
await this.destroyMessages();
|
||||
this.updateLastMessage();
|
||||
if (!fromSync) {
|
||||
|
||||
if (isLocalAction) {
|
||||
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) {
|
||||
// Delete messages locally, other devices should delete upon receiving
|
||||
// the sync message
|
||||
this.destroyMessages();
|
||||
this.disableProfileSharing({ viaStorageServiceSync });
|
||||
this.updateLastMessage();
|
||||
}
|
||||
} else if (response === messageRequestEnum.BLOCK_AND_DELETE) {
|
||||
// Block locally, other devices should block upon receiving the sync message
|
||||
this.block({ viaStorageServiceSync });
|
||||
// Leave group if this was a local action
|
||||
if (!fromSync) {
|
||||
// TODO: DESKTOP-721
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
this.leaveGroup();
|
||||
this.disableProfileSharing({ viaStorageServiceSync });
|
||||
|
||||
// Delete messages locally, other devices should delete upon receiving
|
||||
// the sync message
|
||||
await this.destroyMessages();
|
||||
this.updateLastMessage();
|
||||
|
||||
if (isLocalAction) {
|
||||
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> {
|
||||
// Let this run, no await
|
||||
|
@ -1302,10 +1700,9 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.getMessageRequestResponseType() === this.messageRequestEnum!.ACCEPT
|
||||
) {
|
||||
const messageRequestEnum =
|
||||
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
||||
if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -1611,15 +2008,24 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
return this.jobQueue.add(taskWithTimeout);
|
||||
}
|
||||
|
||||
getMembers(): Array<WhatIsThis> {
|
||||
getMembers(
|
||||
options: { includePendingMembers?: boolean } = {}
|
||||
): Array<WhatIsThis> {
|
||||
if (this.isPrivate()) {
|
||||
return [this];
|
||||
}
|
||||
|
||||
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(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.get('membersV2')!.map(member => {
|
||||
members.map(member => {
|
||||
const c = window.ConversationController.get(member.conversationId);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
getRecipients(): Array<string> {
|
||||
const members = this.getMembers();
|
||||
getRecipients(
|
||||
options: { includePendingMembers?: boolean } = {}
|
||||
): Array<string> {
|
||||
const { includePendingMembers } = options;
|
||||
|
||||
const members = this.getMembers({ includePendingMembers });
|
||||
|
||||
// Eliminate our
|
||||
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));
|
||||
}).catch(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));
|
||||
}).catch(error => {
|
||||
window.log.error('Error sending reaction', reaction, target, error);
|
||||
|
@ -2436,7 +2854,9 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
return false;
|
||||
}
|
||||
|
||||
return Boolean(conv.get('name')) || conv.get('profileSharing');
|
||||
return Boolean(
|
||||
conv.isMe() || conv.get('name') || conv.get('profileSharing')
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
providedExpireTimer: number | undefined,
|
||||
providedSource: unknown,
|
||||
|
@ -2580,7 +2932,11 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
'updateExpirationTimer: GroupV2 timers are not updated this way'
|
||||
);
|
||||
}
|
||||
await this.updateExpirationTimerInGroupV2(providedExpireTimer);
|
||||
await this.modifyGroupV2({
|
||||
name: 'updateExpirationTimer',
|
||||
createGroupChange: () =>
|
||||
this.updateExpirationTimerInGroupV2(providedExpireTimer),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -3018,11 +3374,15 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
const profileKeyVersionHex = c.get('profileKeyVersion')!;
|
||||
const existingProfileKeyCredential = c.get('profileKeyCredential');
|
||||
|
||||
const weHaveVersion = Boolean(profileKey && uuid && profileKeyVersionHex);
|
||||
let profileKeyCredentialRequestHex;
|
||||
let profileCredentialRequestContext;
|
||||
|
||||
if (weHaveVersion && !existingProfileKeyCredential) {
|
||||
if (
|
||||
profileKey &&
|
||||
uuid &&
|
||||
profileKeyVersionHex &&
|
||||
!existingProfileKeyCredential
|
||||
) {
|
||||
window.log.info('Generating request...');
|
||||
({
|
||||
requestHex: profileKeyCredentialRequestHex,
|
||||
|
|
|
@ -115,6 +115,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
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;
|
||||
|
||||
OUR_NUMBER?: string;
|
||||
|
@ -1715,7 +1719,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
this.set({ errors });
|
||||
|
||||
if (!skipSave) {
|
||||
if (!skipSave && !this.doNotSave) {
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: window.Whisper.Message,
|
||||
});
|
||||
|
@ -2130,9 +2134,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
unidentifiedDeliveries: result.unidentifiedDeliveries,
|
||||
});
|
||||
|
||||
if (!this.doNotSave) {
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: window.Whisper.Message,
|
||||
});
|
||||
}
|
||||
|
||||
this.trigger('sent', this);
|
||||
this.sendSyncMessage();
|
||||
|
@ -2315,9 +2321,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
synced: true,
|
||||
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,
|
||||
}).then(() => result);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -237,18 +237,21 @@ function applyMessageRequestState(
|
|||
record: MessageRequestCapableRecord,
|
||||
conversation: ConversationModel
|
||||
): void {
|
||||
const messageRequestEnum =
|
||||
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
||||
|
||||
if (record.blocked) {
|
||||
conversation.applyMessageRequestResponse(
|
||||
conversation.messageRequestEnum.BLOCK,
|
||||
{ fromSync: true, viaStorageServiceSync: true }
|
||||
);
|
||||
conversation.applyMessageRequestResponse(messageRequestEnum.BLOCK, {
|
||||
fromSync: true,
|
||||
viaStorageServiceSync: true,
|
||||
});
|
||||
} else if (record.whitelisted) {
|
||||
// unblocking is also handled by this function which is why the next
|
||||
// condition is part of the else-if and not separate
|
||||
conversation.applyMessageRequestResponse(
|
||||
conversation.messageRequestEnum.ACCEPT,
|
||||
{ fromSync: true, viaStorageServiceSync: true }
|
||||
);
|
||||
conversation.applyMessageRequestResponse(messageRequestEnum.ACCEPT, {
|
||||
fromSync: true,
|
||||
viaStorageServiceSync: true,
|
||||
});
|
||||
} else if (!record.blocked) {
|
||||
// if the condition above failed the state could still be blocked=false
|
||||
// in which case we should unblock the conversation
|
||||
|
@ -408,8 +411,9 @@ export async function mergeGroupV2Record(
|
|||
|
||||
const now = Date.now();
|
||||
const conversationId = window.ConversationController.ensureGroup(groupId, {
|
||||
// We want this conversation to show in the left pane when we first learn about it
|
||||
active_at: now,
|
||||
// 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().
|
||||
timestamp: now,
|
||||
// Basic GroupV2 data
|
||||
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,
|
||||
encoding?: string
|
||||
) => GroupChangeClass;
|
||||
toArrayBuffer: () => ArrayBuffer;
|
||||
|
||||
actions?: ProtoBinaryType;
|
||||
serverSignature?: ProtoBinaryType;
|
||||
|
|
|
@ -90,7 +90,7 @@ type QuoteAttachmentType = {
|
|||
attachmentPointer?: AttachmentPointerClass;
|
||||
};
|
||||
|
||||
type GroupV2InfoType = {
|
||||
export type GroupV2InfoType = {
|
||||
groupChange?: ArrayBuffer;
|
||||
masterKey: ArrayBuffer;
|
||||
revision: number;
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
GroupSecretParams,
|
||||
ProfileKey,
|
||||
ProfileKeyCiphertext,
|
||||
ProfileKeyCredential,
|
||||
ProfileKeyCredentialPresentation,
|
||||
ProfileKeyCredentialRequestContext,
|
||||
ProfileKeyCredentialResponse,
|
||||
|
@ -220,6 +221,29 @@ export function getAuthCredentialPresentation(
|
|||
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(
|
||||
serverPublicParamsBase64: string
|
||||
): ClientZkAuthOperations {
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
/* 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 {
|
||||
title: string;
|
||||
url: string;
|
||||
|
@ -249,10 +254,6 @@ Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
|
|||
template: window.i18n('maximumAttachments'),
|
||||
});
|
||||
|
||||
Whisper.TimerConflictToast = Whisper.ToastView.extend({
|
||||
template: window.i18n('GroupV2--timerConflict'),
|
||||
});
|
||||
|
||||
Whisper.ConversationLoadingScreen = Whisper.View.extend({
|
||||
templateName: 'conversation-loading-screen',
|
||||
className: 'conversation-loading-screen',
|
||||
|
@ -592,26 +593,51 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
clearQuotedMessage: () => this.setQuoteMessage(null),
|
||||
micCellEl,
|
||||
attachmentListEl,
|
||||
onAccept: this.model.syncMessageRequestResponse.bind(
|
||||
onAccept: () => {
|
||||
this.longRunningTaskWrapper({
|
||||
name: 'onAccept',
|
||||
task: this.model.syncMessageRequestResponse.bind(
|
||||
this.model,
|
||||
messageRequestEnum.ACCEPT
|
||||
),
|
||||
onBlock: this.model.syncMessageRequestResponse.bind(
|
||||
});
|
||||
},
|
||||
onBlock: () => {
|
||||
this.longRunningTaskWrapper({
|
||||
name: 'onBlock',
|
||||
task: this.model.syncMessageRequestResponse.bind(
|
||||
this.model,
|
||||
messageRequestEnum.BLOCK
|
||||
),
|
||||
onUnblock: this.model.syncMessageRequestResponse.bind(
|
||||
});
|
||||
},
|
||||
onUnblock: () => {
|
||||
this.longRunningTaskWrapper({
|
||||
name: 'onUnblock',
|
||||
task: this.model.syncMessageRequestResponse.bind(
|
||||
this.model,
|
||||
messageRequestEnum.ACCEPT
|
||||
),
|
||||
onDelete: this.model.syncMessageRequestResponse.bind(
|
||||
});
|
||||
},
|
||||
onDelete: () => {
|
||||
this.longRunningTaskWrapper({
|
||||
name: 'onDelete',
|
||||
task: this.model.syncMessageRequestResponse.bind(
|
||||
this.model,
|
||||
messageRequestEnum.DELETE
|
||||
),
|
||||
onBlockAndDelete: this.model.syncMessageRequestResponse.bind(
|
||||
});
|
||||
},
|
||||
onBlockAndDelete: () => {
|
||||
this.longRunningTaskWrapper({
|
||||
name: 'onBlockAndDelete',
|
||||
task: this.model.syncMessageRequestResponse.bind(
|
||||
this.model,
|
||||
messageRequestEnum.BLOCK_AND_DELETE
|
||||
),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
this.compositionAreaView = new Whisper.ReactWrapperView({
|
||||
|
@ -626,6 +652,74 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
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() {
|
||||
const { id } = this.model;
|
||||
|
||||
|
@ -2619,17 +2713,12 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
},
|
||||
|
||||
async setDisappearingMessages(seconds: any) {
|
||||
try {
|
||||
if (seconds > 0) {
|
||||
await this.model.updateExpirationTimer(seconds);
|
||||
} else {
|
||||
await this.model.updateExpirationTimer(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 409) {
|
||||
this.showToast(Whisper.TimerConflictToast);
|
||||
}
|
||||
}
|
||||
const valueToSet = seconds > 0 ? seconds : null;
|
||||
|
||||
await this.longRunningTaskWrapper({
|
||||
name: 'updateExpirationTimer',
|
||||
task: async () => this.model.updateExpirationTimer(valueToSet),
|
||||
});
|
||||
},
|
||||
|
||||
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 { WebAPIConnectType } from './textsecure/WebAPI';
|
||||
import { CallingClass } from './services/calling';
|
||||
import * as Groups from './groups';
|
||||
import * as Crypto from './Crypto';
|
||||
import * as RemoteConfig from './RemoteConfig';
|
||||
import * as zkgroup from './util/zkgroup';
|
||||
import { LocalizerType, BodyRangesType } from './types/Util';
|
||||
import { CallHistoryDetailsType } from './types/Calling';
|
||||
import { ColorType } from './types/Colors';
|
||||
|
@ -33,6 +35,8 @@ import { MessageModel } from './models/messages';
|
|||
import { ConversationModel } from './models/conversations';
|
||||
import { combineNames } from './util';
|
||||
import { BatcherType } from './util/batcher';
|
||||
import { ErrorModal } from './components/ErrorModal';
|
||||
import { ProgressModal } from './components/ProgressModal';
|
||||
|
||||
export { Long } from 'long';
|
||||
|
||||
|
@ -166,16 +170,7 @@ declare global {
|
|||
};
|
||||
Crypto: typeof Crypto;
|
||||
Data: typeof Data;
|
||||
Groups: {
|
||||
maybeUpdateGroup: (options: unknown) => Promise<void>;
|
||||
waitThenMaybeUpdateGroup: (options: unknown) => Promise<void>;
|
||||
uploadGroupChange: (
|
||||
options: unknown
|
||||
) => Promise<{ toArrayBuffer: () => ArrayBuffer }>;
|
||||
buildDisappearingMessagesTimerChange: (
|
||||
options: unknown
|
||||
) => { version: number };
|
||||
};
|
||||
Groups: typeof Groups;
|
||||
Metadata: {
|
||||
SecretSessionCipher: typeof SecretSessionCipherClass;
|
||||
createCertificateValidator: (
|
||||
|
@ -383,23 +378,7 @@ declare global {
|
|||
del: unknown,
|
||||
bool: boolean
|
||||
) => void;
|
||||
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;
|
||||
};
|
||||
zkgroup: typeof zkgroup;
|
||||
combineNames: typeof combineNames;
|
||||
migrateColor: (color: string) => ColorType;
|
||||
createBatcher: (options: WhatIsThis) => WhatIsThis;
|
||||
|
@ -429,20 +408,23 @@ declare global {
|
|||
renderChange: (change: unknown, things: unknown) => Array<string>;
|
||||
};
|
||||
Components: {
|
||||
StagedLinkPreview: any;
|
||||
Quote: any;
|
||||
ContactDetail: any;
|
||||
MessageDetail: any;
|
||||
Lightbox: any;
|
||||
MediaGallery: any;
|
||||
CaptionEditor: any;
|
||||
ConversationHeader: 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: (
|
||||
callHistoryDetails: unknown,
|
||||
i18n: unknown
|
||||
) => string;
|
||||
LightboxGallery: any;
|
||||
};
|
||||
OS: {
|
||||
isLinux: () => boolean;
|
||||
|
|
Loading…
Reference in a new issue