GroupsV2: Better group invite behavior

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

View file

@ -2951,13 +2951,46 @@
"message": "Emoji",
"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",

View file

@ -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: {

View file

@ -5293,6 +5293,18 @@ button.module-image__border-overlay:focus {
}
}
.module-spinner__circle--on-progress-dialog {
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-80;
}
}
.module-spinner__arc--on-progress-dialog {
background-color: $ultramarine-ui-light;
}
// Module: Highlighted Message Body
.module-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 {

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,6 +81,35 @@ storiesOf('Components/Conversation/GroupV2Change', module)
</>
);
})
.add('Create', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'create',
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'create',
},
],
})}
{renderChange({
details: [
{
type: 'create',
},
],
})}
</>
);
})
.add('Title', () => {
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: [

View file

@ -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({

View file

@ -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(

View file

@ -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,48 +409,7 @@ export async function maybeUpdateGroup({
dropInitialJoinMessage,
});
conversation.set(newAttributes);
// Ensure that all generated messages are ordered properly.
// Before the provided timestamp so update messages appear before the
// initiating message, or after now().
let syntheticTimestamp = receivedAt
? receivedAt - (groupChangeMessages.length + 1)
: Date.now();
// Save all synthetic messages describing group changes
const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
// We do this to preserve the order of the timeline
syntheticTimestamp += 1;
return {
...changeMessage,
conversationId: conversation.id,
received_at: syntheticTimestamp,
sent_at: sentAt,
};
});
if (changeMessagesToSave.length > 0) {
await window.Signal.Data.saveMessages(changeMessagesToSave, {
forceSave: true,
});
changeMessagesToSave.forEach(changeMessage => {
const model = new window.Whisper.Message(changeMessage);
window.MessageController.register(model.id, model);
conversation.trigger('newmessage', model);
});
}
// Capture profile key for each member in the group, if we don't have it yet
members.forEach(member => {
const contact = window.ConversationController.get(member.uuid);
if (member.profileKey && contact && !contact.get('profileKey')) {
contact.setProfileKey(member.profileKey);
}
});
await conversation.updateLastMessage();
await updateGroup({ conversation, receivedAt, sentAt, updates });
} catch (error) {
window.log.error(
`maybeUpdateGroup/${logId}: Failed to update group:`,
@ -372,6 +419,79 @@ export async function maybeUpdateGroup({
}
}
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
// initiating message, or after now().
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
syntheticTimestamp += 1;
return {
...changeMessage,
conversationId: conversation.id,
received_at: syntheticTimestamp,
sent_at: sentAt,
};
});
if (changeMessagesToSave.length > 0) {
await window.Signal.Data.saveMessages(changeMessagesToSave, {
forceSave: true,
});
changeMessagesToSave.forEach(changeMessage => {
const model = new window.Whisper.Message(changeMessage);
window.MessageController.register(model.id, model);
conversation.trigger('newmessage', model);
});
}
// Capture profile key for each member in the group, if we don't have it yet
members.forEach(member => {
const contact = window.ConversationController.get(member.uuid);
if (member.profileKey && contact && !contact.get('profileKey')) {
contact.setProfileKey(member.profileKey);
}
});
// No need for convo.updateLastMessage(), 'newmessage' handler does that
}
function idForLogging(group: ConversationAttributesType) {
return `groupv2(${group.groupId})`;
}
@ -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
View file

@ -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;

View file

@ -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(),
}));
// eslint-disable-next-line no-await-in-loop
await this.sendReadReceiptsFor(receiptSpecs);
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,57 +1224,129 @@ 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) {
this.sendProfileKeyUpdate();
// Locally accepted
await this.handleReadAndDownloadAttachments();
await this.handleReadAndDownloadAttachments({ isLocalAction });
if (isLocalAction) {
if (this.isGroupV1() || this.isPrivate()) {
this.sendProfileKeyUpdate();
} 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
this.applyMessageRequestResponse(response);
@ -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,

View file

@ -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,
});
await window.Signal.Data.saveMessage(this.attributes, {
Message: window.Whisper.Message,
});
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;
});
};

View file

@ -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
View file

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

View file

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

View file

@ -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 {

View file

@ -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(
this.model,
messageRequestEnum.ACCEPT
),
onBlock: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.BLOCK
),
onUnblock: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.ACCEPT
),
onDelete: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.DELETE
),
onBlockAndDelete: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.BLOCK_AND_DELETE
),
onAccept: () => {
this.longRunningTaskWrapper({
name: 'onAccept',
task: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.ACCEPT
),
});
},
onBlock: () => {
this.longRunningTaskWrapper({
name: 'onBlock',
task: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.BLOCK
),
});
},
onUnblock: () => {
this.longRunningTaskWrapper({
name: 'onUnblock',
task: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.ACCEPT
),
});
},
onDelete: () => {
this.longRunningTaskWrapper({
name: 'onDelete',
task: this.model.syncMessageRequestResponse.bind(
this.model,
messageRequestEnum.DELETE
),
});
},
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
View file

@ -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;