Support for GV1 -> GV2 migration

This commit is contained in:
Scott Nonnenberg 2020-11-20 09:30:45 -08:00 committed by Josh Perez
parent a0baa3e03f
commit 2c69f2c367
32 changed files with 2626 additions and 341 deletions

View file

@ -3140,7 +3140,7 @@
"message": "Please try again or contact support.",
"description": "Description text in pop-up dialog when user-initiated task has gone wrong"
},
"ErrorModal--buttonText": {
"Confirmation--confirm": {
"message": "Okay",
"description": "Button to dismiss pop-up dialog when user-initiated task has gone wrong"
},
@ -3960,6 +3960,102 @@
}
}
},
"GroupV1--Migration--was-upgraded": {
"message": "This group was upgraded to a New Group.",
"description": "Shown in timeline when a legacy group (GV1) is upgraded to a new group (GV2)"
},
"GroupV1--Migration--learn-more": {
"message": "Learn More",
"description": "Shown on a bubble below a 'group was migrated' timeline notification, or as button on Migrate dialog"
},
"GroupV1--Migration--migrate": {
"message": "Migrate",
"description": "Shown on Migrate dialog to kick off the process"
},
"GroupV1--Migration--info--title": {
"message": "What are New Groups?",
"description": "Shown on Learn More popup after GV1 migration"
},
"GroupV1--Migration--migrate--title": {
"message": "Upgrade to New Group",
"description": "Shown on Migration popup after choosing to migrate group"
},
"GroupV1--Migration--info--summary": {
"message": "New Groups have features like @mentions and group admins, and will support more features in the future.",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--info--keep-history": {
"message": "All message history and media has been kept from before the upgrade.",
"description": "Shown on Learn More popup after GV1 migration"
},
"GroupV1--Migration--migrate--keep-history": {
"message": "All message history and media will be kept from before the upgrade.",
"description": "Shown on Migration popup before GV1 migration"
},
"GroupV1--Migration--info--invited--many": {
"message": "These members will need to accept an invite to join this group again, and will not receive group messages until they accept:",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--info--invited--one": {
"message": "This member will need to accept an invite to join this group again, and will not receive group messages until they accept:",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--info--removed--before--many": {
"message": "These members are not capable of joining New Groups, and will be removed from the group:",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--info--removed--before--one": {
"message": "This member is not capable of joining New Groups, and will be removed from the group:",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--info--removed--after--many": {
"message": "These members were not capable of joining New Groups, and were removed from the group:",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--info--removed--after--one": {
"message": "This member was not capable of joining New Groups, and was removed from the group:",
"description": "Shown on Learn More popup after or Migration popup before GV1 migration"
},
"GroupV1--Migration--invited--one": {
"message": "$contact$ couldnt be added to the New Group and has been invited to join.",
"description": "Shown in timeline when a group is upgraded and one person was invited, instead of added",
"placeholders": {
"contact": {
"content": "$1",
"example": "5"
}
}
},
"GroupV1--Migration--invited--many": {
"message": "$count$ members couldnt be added to the New Group and have been invited to join.",
"description": "Shown in timeline when a group is upgraded and some people were invited, instead of added",
"placeholders": {
"contact": {
"content": "$1",
"example": "5"
}
}
},
"GroupV1--Migration--removed--one": {
"message": "$contact$ was removed from the group.",
"description": "Shown in timeline when a group is upgraded and one person was removed entirely during the upgrade",
"placeholders": {
"contact": {
"content": "$1",
"example": "5"
}
}
},
"GroupV1--Migration--removed--many": {
"message": "$count$ members were removed from the group.",
"description": "Shown in timeline when a group is upgraded and some people were removed entirely during the upgrade",
"placeholders": {
"contact": {
"content": "$1",
"example": "5"
}
}
},
"close": {
"message": "Close",
"description": "Generic close label"

View file

@ -20,9 +20,7 @@
});
if (syncByE164) {
window.log.info(
`Found early message request response for E164 ${conversation.get(
'e164'
)}`
`Found early message request response for E164 ${conversation.idForLogging()}`
);
this.remove(syncByE164);
return syncByE164;
@ -35,24 +33,35 @@
});
if (syncByUuid) {
window.log.info(
`Found early message request response for UUID ${conversation.get(
'uuid'
)}`
`Found early message request response for UUID ${conversation.idForLogging()}`
);
this.remove(syncByUuid);
return syncByUuid;
}
}
// V1 Group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupId: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for GROUP ID ${conversation.get(
'groupId'
)}`
`Found early message request response for group v1 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
// V2 group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupV2Id: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for group v2 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
@ -66,19 +75,29 @@
const threadE164 = sync.get('threadE164');
const threadUuid = sync.get('threadUuid');
const groupId = sync.get('groupId');
const groupV2Id = sync.get('groupV2Id');
const conversation = groupId
? ConversationController.get(groupId)
: ConversationController.get(
ConversationController.ensureContactIds({
e164: threadE164,
uuid: threadUuid,
})
);
let conversation;
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) {
conversation = ConversationController.get(groupV2Id);
}
if (!conversation && groupId) {
conversation = ConversationController.get(groupId);
}
if (!conversation && (threadE164 || threadUuid)) {
conversation = ConversationController.get(
ConversationController.ensureContactIds({
e164: threadE164,
uuid: threadUuid,
})
);
}
if (!conversation) {
window.log(
`Received message request response for unknown conversation: ${groupId} ${threadUuid} ${threadE164}`
`Received message request response for unknown conversation: groupv2(${groupV2Id}) group(${groupId}) ${threadUuid} ${threadE164}`
);
return;
}

View file

@ -190,3 +190,118 @@
outline: inherit;
text-align: inherit;
}
// Buttons
@mixin button-primary {
background-color: $ultramarine-ui-light;
// Note: the background colors here need to match the parent component
@include light-theme {
color: $color-white;
border: 1px solid white;
}
@include dark-theme {
color: $color-white-alpha-90;
border: 1px solid $color-gray-95;
}
&:hover {
@include mouse-mode {
background-color: mix($color-black, $ultramarine-ui-light, 15%);
}
@include dark-mouse-mode {
background-color: mix($color-white, $ultramarine-ui-light, 15%);
}
}
&:active {
@include light-theme {
background-color: mix($color-black, $ultramarine-ui-light, 25%);
}
@include dark-theme {
background-color: mix($color-white, $ultramarine-ui-light, 25%);
}
}
&:focus {
@include keyboard-mode {
box-shadow: 0px 0px 0px 3px $ultramarine-ui-light;
}
@include dark-keyboard-mode {
box-shadow: 0px 0px 0px 3px $ultramarine-ui-dark;
}
}
}
@mixin button-secondary {
@include light-theme {
color: $color-gray-90;
background-color: $color-gray-05;
}
@include dark-theme {
color: $color-gray-05;
background-color: $color-gray-65;
}
&:hover {
@include mouse-mode {
background-color: mix($color-black, $color-gray-05, 15%);
}
@include dark-mouse-mode {
background-color: mix($color-white, $color-gray-65, 15%);
}
}
&:active {
@include light-theme {
background-color: mix($color-black, $color-gray-05, 25%);
}
@include dark-theme {
background-color: mix($color-white, $color-gray-65, 25%);
}
}
}
@mixin button-secondary-blue-text {
@include light-theme {
color: $ultramarine-ui-light;
}
@include dark-theme {
color: $ultramarine-ui-dark;
}
}
@mixin button-destructive {
@include light-theme {
color: $color-white;
background-color: $color-accent-red;
}
@include dark-theme {
color: $color-white-alpha-90;
background-color: $color-accent-red;
}
&:hover {
@include mouse-mode {
background-color: mix($color-black, $color-accent-red, 15%);
}
@include dark-mouse-mode {
background-color: mix($color-white, $color-accent-red, 15%);
}
}
&:active {
@include light-theme {
background-color: mix($color-black, $color-accent-red, 25%);
}
@include dark-theme {
background-color: mix($color-white, $color-accent-red, 25%);
}
}
}

View file

@ -9687,6 +9687,219 @@ button.module-image__border-overlay:focus {
margin-right: auto;
}
// Module: GV1 Migration
.module-group-v1-migration {
@include font-body-1;
margin-left: 2em;
margin-right: 2em;
text-align: center;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-group-v1-migration--icon {
@include light-theme {
@include color-svg(
'../images/icons/v2/group-outline-20.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-20.svg',
$color-gray-05
);
}
height: 20px;
width: 20px;
margin-left: auto;
margin-right: auto;
}
.module-group-v1-migration--text {
margin-top: 8px;
margin-bottom: 8px;
}
.module-group-v1-migration--button {
@include button-reset;
@include font-body-2-bold;
border-radius: 4px;
padding: 8px;
padding-left: 40px;
padding-right: 40px;
@include button-primary;
@include button-secondary;
@include button-secondary-blue-text;
}
// Module: Modal Host
.module-modal-host__overlay {
background: $color-black-alpha-40;
position: absolute;
height: 100vh;
width: 100vw;
left: 0;
top: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
padding: 20px;
}
// Module: GV1 Migration Dialog
.module-group-v2-migration-dialog {
@include font-body-1;
border-radius: 8px;
width: 360px;
margin-left: auto;
margin-right: auto;
padding: 20px;
max-height: 100%;
display: flex;
flex-direction: column;
position: relative;
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-95;
}
}
.module-group-v2-migration-dialog__close-button {
@include button-reset;
position: absolute;
right: 12px;
top: 12px;
height: 24px;
width: 24px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-15);
}
&:focus {
@include keyboard-mode {
background-color: $ultramarine-ui-light;
}
@include dark-keyboard-mode {
background-color: $ultramarine-ui-dark;
}
}
}
.module-group-v2-migration-dialog__title {
@include font-title-2;
text-align: center;
margin-bottom: 20px;
flex-grow: 0;
flex-shrink: 0;
}
.module-group-v2-migration-dialog__scrollable {
overflow-x: scroll;
flex-grow: 1;
flex-shrink: 1;
}
.module-group-v2-migration-dialog__item {
display: flex;
flex-direction: row;
align-items: start;
margin-bottom: 16px;
}
.module-group-v2-migration-dialog__item__bullet {
width: 4px;
height: 11px;
flex-grow: 0;
flex-shrink: 0;
margin-top: 5px;
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-65;
}
}
.module-group-v2-migration-dialog__item__content {
margin-left: 16px;
}
.module-group-v2-migration-dialog__member {
margin-top: 16px;
}
.module-group-v2-migration-dialog__member__name {
margin-left: 6px;
}
.module-group-v2-migration-dialog__buttons {
text-align: center;
flex-grow: 0;
flex-shrink: 0;
display: flex;
}
.module-group-v2-migration-dialog__buttons--narrow {
margin-left: auto;
margin-right: auto;
width: 152px;
}
.module-group-v2-migration-dialog__button {
@include button-reset;
@include font-body-1-bold;
// Start flex basis at zero so text width doesn't affect layout. We want the buttons
// evenly distributed.
flex: 1 1 0px;
border-radius: 4px;
padding: 8px;
padding-left: 30px;
padding-right: 30px;
@include button-primary;
&:not(:first-of-type) {
margin-left: 16px;
}
}
.module-group-v2-migration-dialog__button--secondary {
@include button-secondary;
}
// Module: Progress Dialog
.module-progress-dialog {
@ -9778,6 +9991,7 @@ button.module-image__border-overlay:focus {
}
// Module: Group Contact Details
$contact-modal-padding: 18px;
.module-contact-modal {
@include font-body-2;
@ -9863,10 +10077,10 @@ $contact-modal-padding: 18px;
&:focus {
@include keyboard-mode {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-60;
}
@include dark-keyboard-mode {
background-color: $color-gray-60;
}
}
}
@ -9943,8 +10157,8 @@ $contact-modal-padding: 18px;
top: 10px;
right: 12px;
width: 16px;
height: 16px;
width: 24px;
height: 24px;
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);

View file

@ -19,6 +19,43 @@ describe('Crypto', () => {
});
});
describe('deriveMasterKeyFromGroupV1', () => {
const vectors = [
{
gv1: '00000000000000000000000000000000',
masterKey:
'dbde68f4ee9169081f8814eabc65523fea1359235c8cfca32b69e31dce58b039',
},
{
gv1: '000102030405060708090a0b0c0d0e0f',
masterKey:
'70884f78f07a94480ee36b67a4b5e975e92e4a774561e3df84c9076e3be4b9bf',
},
{
gv1: '7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f',
masterKey:
'e69bf7c183b288b4ea5745b7c52b651a61e57769fafde683a6fdf1240f1905f2',
},
{
gv1: 'ffffffffffffffffffffffffffffffff',
masterKey:
'dd3a7de23d10f18b64457fbeedc76226c112a730e4b76112e62c36c4432eb37d',
},
];
vectors.forEach((vector, index) => {
it(`vector ${index}`, async () => {
const gv1 = Signal.Crypto.hexToArrayBuffer(vector.gv1);
const expectedHex = vector.masterKey;
const actual = await Signal.Crypto.deriveMasterKeyFromGroupV1(gv1);
const actualHex = Signal.Crypto.arrayBufferToHex(actual);
assert.strictEqual(actualHex, expectedHex);
});
});
});
describe('symmetric encryption', () => {
it('roundtrips', async () => {
const message = 'this is my message';

View file

@ -10,6 +10,7 @@ import {
} from './model-types.d';
import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage';
import { ConversationModel } from './models/conversations';
import { maybeDeriveGroupV2Id } from './groups';
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
@ -222,6 +223,9 @@ export class ConversationController {
}
try {
if (conversation.isGroupV1()) {
await maybeDeriveGroupV2Id(conversation);
}
await saveConversation(conversation.attributes);
} catch (error) {
window.log.error(
@ -676,6 +680,12 @@ export class ConversationController {
});
}
getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined {
return this._conversations.find(
item => item.get('derivedGroupV2Id') === groupId
);
}
async loadPromise(): Promise<void> {
return this._initialPromise;
}
@ -710,6 +720,11 @@ export class ConversationController {
await Promise.all(
this._conversations.map(async conversation => {
try {
const isChanged = await maybeDeriveGroupV2Id(conversation);
if (isChanged) {
updateConversation(conversation.attributes);
}
if (!conversation.get('lastMessage')) {
await conversation.updateLastMessage();
}

View file

@ -59,6 +59,21 @@ export async function deriveStickerPackKey(
return concatenateBytes(part1, part2);
}
export async function deriveMasterKeyFromGroupV1(
groupV1Id: ArrayBuffer
): Promise<ArrayBuffer> {
const salt = getZeroes(32);
const info = bytesFromString('GV2 Migration');
const [part1] = await window.libsignal.HKDF.deriveSecrets(
groupV1Id,
salt,
info
);
return part1;
}
export async function computeHash(data: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data);
return arrayBufferToBase64(hash);

View file

@ -1,7 +1,12 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
type WhatIsThis = typeof window.WhatIsThis;
// This allows us to pull in types despite the fact that this is not a module. We can't
// use normal import syntax, nor can we use 'import type' syntax, or this will be turned
// into a module, and we'll get the dreaded 'exports is not defined' error.
// see https://github.com/microsoft/TypeScript/issues/41562
type DataMessageClass = import('./textsecure.d').DataMessageClass;
type WhatIsThis = import('./window.d').WhatIsThis;
// eslint-disable-next-line func-names
(async function () {
@ -979,10 +984,6 @@ type WhatIsThis = typeof window.WhatIsThis;
if (className.includes('module-main-header__search__input')) {
return;
}
if (className.includes('module-contact-modal')) {
return;
}
}
// These add listeners to document, but we'll run first
@ -1022,10 +1023,22 @@ type WhatIsThis = typeof window.WhatIsThis;
return;
}
const reactionPicker = document.querySelector('module-reaction-picker');
const reactionPicker = document.querySelector(
'.module-reaction-picker'
);
if (reactionPicker) {
return;
}
const contactModal = document.querySelector('.module-contact-modal');
if (contactModal) {
return;
}
const modalHost = document.querySelector('.module-modal-host__overlay');
if (modalHost) {
return;
}
}
// Close window.Backbone-based confirmation dialog
@ -1975,22 +1988,21 @@ type WhatIsThis = typeof window.WhatIsThis;
}
}
// We need to do this after fetching our UUID
const hasRegisteredGV23Support = 'hasRegisteredGV23Support';
if (
!window.storage.get(hasRegisteredGV23Support) &&
window.textsecure.storage.user.getUuid()
) {
if (connectCount === 1) {
const server = window.WebAPI.connect({
username: USERNAME || OLD_USERNAME,
password: PASSWORD,
});
try {
await server.registerCapabilities({ 'gv2-3': true });
window.storage.put(hasRegisteredGV23Support, true);
// Note: we always have to register our capabilities all at once, so we do this
// after connect on every startup
await server.registerCapabilities({
'gv2-3': true,
'gv1-migration': true,
});
} catch (error) {
window.log.error(
'Error: Unable to register support for GV2.',
'Error: Unable to register our capabilities.',
error && error.stack ? error.stack : error
);
}
@ -2227,16 +2239,35 @@ type WhatIsThis = typeof window.WhatIsThis;
return;
}
let conversation;
const senderId = window.ConversationController.ensureContactIds({
e164: sender,
uuid: senderUuid,
highTrust: true,
});
const conversation = window.ConversationController.get(
groupV2Id || groupId || senderId
);
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) {
conversation = window.ConversationController.get(groupV2Id);
}
if (!conversation && groupId) {
conversation = window.ConversationController.get(groupId);
}
if (!groupV2Id && !groupId && senderId) {
conversation = window.ConversationController.get(senderId);
}
const ourId = window.ConversationController.getOurConversationId();
if (!senderId) {
window.log.warn('onTyping: ensureContactIds returned falsey senderId!');
return;
}
if (!ourId) {
window.log.warn("onTyping: Couldn't get our own id!");
return;
}
if (!conversation) {
window.log.warn(
`onTyping: Did not find conversation for typing indicator (groupv2(${groupV2Id}), group(${groupId}), ${sender}, ${senderUuid})`
@ -2245,8 +2276,7 @@ type WhatIsThis = typeof window.WhatIsThis;
}
// We drop typing notifications in groups we're not a part of
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!conversation.isPrivate() && !conversation.hasMember(ourId!)) {
if (!conversation.isPrivate() && !conversation.hasMember(ourId)) {
window.log.warn(
`Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
);
@ -2255,12 +2285,10 @@ type WhatIsThis = typeof window.WhatIsThis;
conversation.notifyTyping({
isTyping: started,
isMe: ourId === senderId,
sender,
senderUuid,
fromMe: senderId === ourId,
senderId,
senderDevice,
} as WhatIsThis);
});
}
async function onStickerPack(ev: WhatIsThis) {
@ -2552,64 +2580,18 @@ type WhatIsThis = typeof window.WhatIsThis;
return confirm();
}
// Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`:
const getDescriptorForReceived = ({
message,
source,
sourceUuid,
}: 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,
publicParams: message.groupV2.publicParams,
});
return {
type: Message.GROUP,
id: conversationId,
};
}
if (message.group) {
const { id } = message.group;
const fromContactId = window.ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
});
const conversationId = window.ConversationController.ensureGroup(id, {
addedBy: fromContactId,
});
return {
type: Message.GROUP,
id: conversationId,
};
}
return {
type: Message.PRIVATE,
id: window.ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
}),
};
};
// Note: We do very little in this function, since everything in handleDataMessage is
// inside a conversation-specific queue(). Any code here might run before an earlier
// message is processed in handleDataMessage().
function onMessageReceived(event: WhatIsThis) {
const { data, confirm } = event;
const messageDescriptor = getDescriptorForReceived(data);
const messageDescriptor = getMessageDescriptor({
...data,
// 'message' event: for 1:1 converations, the conversation is same as sender
destination: data.source,
destinationUuid: data.sourceUuid,
});
const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
@ -2776,15 +2758,50 @@ type WhatIsThis = typeof window.WhatIsThis;
} as WhatIsThis);
}
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
const getDescriptorForSent = ({
// Works with 'sent' and 'message' data sent from MessageReceiver, with a little massage
// at callsites to make sure both source and destination are populated.
const getMessageDescriptor = ({
message,
source,
sourceUuid,
destination,
destinationUuid,
}: WhatIsThis) => {
}: {
message: DataMessageClass;
source: string;
sourceUuid: string;
destination: string;
destinationUuid: string;
}): MessageDescriptor => {
if (message.groupV2) {
const { id } = message.groupV2;
if (!id) {
throw new Error('getMessageDescriptor: GroupV2 data was missing an id');
}
// First we check for an existing GroupV2 group
const groupV2 = window.ConversationController.get(id);
if (groupV2) {
return {
type: Message.GROUP,
id: groupV2.id,
};
}
// Then check for V1 group with matching derived GV2 id
const groupV1 = window.ConversationController.getByDerivedGroupV2Id(id);
if (groupV1) {
return {
type: Message.GROUP,
id: groupV1.id,
};
}
// Finally create the V2 group normally
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,
@ -2797,8 +2814,37 @@ type WhatIsThis = typeof window.WhatIsThis;
};
}
if (message.group) {
const { id } = message.group;
const conversationId = window.ConversationController.ensureGroup(id);
const { id, derivedGroupV2Id } = message.group;
if (!id) {
throw new Error('getMessageDescriptor: GroupV1 data was missing id');
}
if (!derivedGroupV2Id) {
window.log.warn(
'getMessageDescriptor: GroupV1 data was missing derivedGroupV2Id'
);
} else {
// First we check for an already-migrated GroupV2 group
const migratedGroup = window.ConversationController.get(
derivedGroupV2Id
);
if (migratedGroup) {
return {
type: Message.GROUP,
id: migratedGroup.id,
};
}
}
// If we can't find one, we treat this as a normal GroupV1 group
const fromContactId = window.ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
});
const conversationId = window.ConversationController.ensureGroup(id, {
addedBy: fromContactId,
});
return {
type: Message.GROUP,
@ -2806,13 +2852,20 @@ type WhatIsThis = typeof window.WhatIsThis;
};
}
const id = window.ConversationController.ensureContactIds({
e164: destination,
uuid: destinationUuid,
highTrust: true,
});
if (!id) {
throw new Error(
'getMessageDescriptor: ensureContactIds returned falsey id'
);
}
return {
type: Message.PRIVATE,
id: window.ConversationController.ensureContactIds({
e164: destination,
uuid: destinationUuid,
highTrust: true,
}),
id,
};
};
@ -2822,7 +2875,12 @@ type WhatIsThis = typeof window.WhatIsThis;
function onSentMessage(event: WhatIsThis) {
const { data, confirm } = event;
const messageDescriptor = getDescriptorForSent(data);
const messageDescriptor = getMessageDescriptor({
...data,
// 'sent' event: the sender is always us!
source: window.textsecure.storage.user.getNumber(),
sourceUuid: window.textsecure.storage.user.getUuid(),
});
const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
@ -2885,7 +2943,15 @@ type WhatIsThis = typeof window.WhatIsThis;
return Promise.resolve();
}
function initIncomingMessage(data: WhatIsThis, descriptor: WhatIsThis) {
type MessageDescriptor = {
type: 'private' | 'group';
id: string;
};
function initIncomingMessage(
data: WhatIsThis,
descriptor: MessageDescriptor
) {
return new window.Whisper.Message({
source: data.source,
sourceUuid: data.sourceUuid,
@ -2998,12 +3064,16 @@ type WhatIsThis = typeof window.WhatIsThis;
return Promise.resolve();
}
const envelope = ev.proto;
const id = window.ConversationController.ensureContactIds({
e164: envelope.source,
uuid: envelope.sourceUuid,
});
if (!id) {
throw new Error('onError: ensureContactIds returned falsey id!');
}
const message = initIncomingMessage(envelope, {
type: Message.PRIVATE,
id: window.ConversationController.ensureContactIds({
e164: envelope.source,
uuid: envelope.sourceUuid,
}),
id,
});
const conversationId = message.get('conversationId');
@ -3141,18 +3211,29 @@ type WhatIsThis = typeof window.WhatIsThis;
async function onMessageRequestResponse(ev: WhatIsThis) {
ev.confirm();
const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev;
const args = {
const {
threadE164,
threadUuid,
groupId,
groupV2Id,
messageRequestResponseType,
} = ev;
window.log.info('onMessageRequestResponse', {
threadE164,
threadUuid,
groupId: `group(${groupId})`,
groupV2Id: `groupv2(${groupV2Id})`,
messageRequestResponseType,
});
const sync = window.Whisper.MessageRequests.add({
threadE164,
threadUuid,
groupId,
groupV2Id,
type: messageRequestResponseType,
};
window.log.info('message request response', args);
const sync = window.Whisper.MessageRequests.add(args);
});
window.Whisper.MessageRequests.onResponse(sync);
}

View file

@ -86,15 +86,9 @@ export class Avatar extends React.Component<Props, State> {
}
public renderNoImage(): JSX.Element {
const {
conversationType,
name,
noteToSelf,
profileName,
size,
} = this.props;
const { conversationType, noteToSelf, size, title } = this.props;
const initials = getInitials(name || profileName);
const initials = getInitials(title);
const isGroup = conversationType === 'group';
if (noteToSelf) {

View file

@ -337,8 +337,9 @@ export const CompositionArea = ({
}, [setLarge]);
if (
messageRequestsEnabled &&
(!acceptedMessageRequest || isBlocked || areWePending)
isBlocked ||
areWePending ||
(messageRequestsEnabled && !acceptedMessageRequest)
) {
return (
<MessageRequestActions

View file

@ -38,9 +38,9 @@ export type PropsData = {
isMe?: boolean;
muteExpiresAt?: number;
lastUpdated: number;
lastUpdated?: number;
unreadCount?: number;
markedUnread: boolean;
markedUnread?: boolean;
isSelected: boolean;
acceptedMessageRequest?: boolean;
@ -100,7 +100,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
isUnread(): boolean {
const { markedUnread, unreadCount } = this.props;
return (isNumber(unreadCount) && unreadCount > 0) || markedUnread;
return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread);
}
public renderUnread(): JSX.Element | null {

View file

@ -41,7 +41,7 @@ export const ErrorModal = (props: PropsType): JSX.Element => {
onClick={onClose}
ref={focusRef}
>
{buttonText || i18n('ErrorModal--buttonText')}
{buttonText || i18n('Confirmation--confirm')}
</button>
</div>
</ConfirmationModal>

View file

@ -0,0 +1,97 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { isBoolean } from 'lodash';
import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { GroupV1MigrationDialog, PropsType } from './GroupV1MigrationDialog';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const contact1 = {
title: 'Alice',
number: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
const contact2 = {
title: 'Bob',
number: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
function booleanOr(value: boolean | undefined, defaultValue: boolean): boolean {
return isBoolean(value) ? value : defaultValue;
}
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
droppedMembers: overrideProps.droppedMembers || [contact1],
hasMigrated: boolean(
'hasMigrated',
booleanOr(overrideProps.hasMigrated, false)
),
i18n,
invitedMembers: overrideProps.invitedMembers || [contact2],
learnMore: action('learnMore'),
migrate: action('migrate'),
onClose: action('onClose'),
});
const stories = storiesOf('Components/GroupV1MigrationDialog', module);
stories.add('Not yet migrated, basic', () => {
return <GroupV1MigrationDialog {...createProps()} />;
});
stories.add('Migrated, basic', () => {
return (
<GroupV1MigrationDialog
{...createProps({
hasMigrated: true,
})}
/>
);
});
stories.add('Not yet migrated, multiple dropped and invited members', () => {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [contact1, contact2],
invitedMembers: [contact1, contact2],
})}
/>
);
});
stories.add('Not yet migrated, no members', () => {
return (
<GroupV1MigrationDialog
{...createProps({
droppedMembers: [],
invitedMembers: [],
})}
/>
);
});
stories.add('Not yet migrated, just dropped member', () => {
return (
<GroupV1MigrationDialog
{...createProps({
invitedMembers: [],
})}
/>
);
});

View file

@ -0,0 +1,180 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations';
import { Avatar } from './Avatar';
export type ActionSpec = {
text: string;
action: () => unknown;
style?: 'affirmative' | 'negative';
};
type CallbackType = () => unknown;
export type DataPropsType = {
readonly droppedMembers: Array<ConversationType>;
readonly hasMigrated: boolean;
readonly invitedMembers: Array<ConversationType>;
readonly learnMore: CallbackType;
readonly migrate: CallbackType;
readonly onClose: CallbackType;
};
export type HousekeepingPropsType = {
readonly i18n: LocalizerType;
};
export type PropsType = DataPropsType & HousekeepingPropsType;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const GroupV1MigrationDialog = React.memo((props: PropsType) => {
const {
droppedMembers,
hasMigrated,
i18n,
invitedMembers,
learnMore,
migrate,
onClose,
} = props;
const title = hasMigrated
? i18n('GroupV1--Migration--info--title')
: i18n('GroupV1--Migration--migrate--title');
const keepHistory = hasMigrated
? i18n('GroupV1--Migration--info--keep-history')
: i18n('GroupV1--Migration--migrate--keep-history');
const migrationKey = hasMigrated ? 'after' : 'before';
const droppedMembersKey = `GroupV1--Migration--info--removed--${migrationKey}`;
return (
<div className="module-group-v2-migration-dialog">
<button
aria-label={i18n('close')}
type="button"
className="module-group-v2-migration-dialog__close-button"
onClick={onClose}
/>
<div className="module-group-v2-migration-dialog__title">{title}</div>
<div className="module-group-v2-migration-dialog__scrollable">
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{i18n('GroupV1--Migration--info--summary')}
</div>
</div>
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
{keepHistory}
</div>
</div>
{renderMembers(
invitedMembers,
'GroupV1--Migration--info--invited',
i18n
)}
{renderMembers(droppedMembers, droppedMembersKey, i18n)}
</div>
{renderButtons(hasMigrated, onClose, learnMore, migrate, i18n)}
</div>
);
});
function renderButtons(
hasMigrated: boolean,
onClose: CallbackType,
learnMore: CallbackType,
migrate: CallbackType,
i18n: LocalizerType
) {
if (hasMigrated) {
return (
<div
className={classNames(
'module-group-v2-migration-dialog__buttons',
'module-group-v2-migration-dialog__buttons--narrow'
)}
>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={onClose}
>
{i18n('Confirmation--confirm')}
</button>
</div>
);
}
return (
<div className="module-group-v2-migration-dialog__buttons">
<button
className={classNames(
'module-group-v2-migration-dialog__button',
'module-group-v2-migration-dialog__button--secondary'
)}
type="button"
onClick={learnMore}
>
{i18n('GroupV1--Migration--learn-more')}
</button>
<button
className="module-group-v2-migration-dialog__button"
ref={focusRef}
type="button"
onClick={migrate}
>
{i18n('GroupV1--Migration--migrate')}
</button>
</div>
);
}
function renderMembers(
members: Array<ConversationType>,
prefix: string,
i18n: LocalizerType
): React.ReactElement | null {
if (!members.length) {
return null;
}
const postfix = members.length === 1 ? '--one' : '--many';
const key = `${prefix}${postfix}`;
return (
<div className="module-group-v2-migration-dialog__item">
<div className="module-group-v2-migration-dialog__item__bullet" />
<div className="module-group-v2-migration-dialog__item__content">
<div>{i18n(key)}</div>
{members.map(member => (
<div
key={member.id}
className="module-group-v2-migration-dialog__member"
>
<Avatar
{...member}
conversationType={member.type}
size={28}
i18n={i18n}
/>{' '}
<span className="module-group-v2-migration-dialog__member__name">
{member.title}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { createPortal } from 'react-dom';
export type PropsType = {
readonly onClose: () => unknown;
readonly children: React.ReactElement;
};
export const ModalHost = React.memo(({ onClose, children }: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setRoot(div);
return () => {
document.body.removeChild(div);
setRoot(null);
};
}, []);
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
event.preventDefault();
event.stopPropagation();
}
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, [onClose]);
// This makes it easier to write dialogs to be hosted here; they won't have to worry
// as much about preventing propagation of mouse events.
const handleCancel = React.useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
},
[onClose]
);
return root
? createPortal(
<div
role="presentation"
className="module-modal-host__overlay"
onClick={handleCancel}
>
{children}
</div>,
root
)
: null;
});

View file

@ -0,0 +1,78 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable-next-line max-classes-per-file */
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { GroupV1Migration, PropsType } from './GroupV1Migration';
const i18n = setupI18n('en', enMessages);
const contact1 = {
title: 'Alice',
number: '+1 (300) 555-000',
id: 'guid-1',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
const contact2 = {
title: 'Bob',
number: '+1 (300) 555-000',
id: 'guid-2',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
droppedMembers: overrideProps.droppedMembers || [contact1],
i18n,
invitedMembers: overrideProps.invitedMembers || [contact2],
});
const stories = storiesOf('Components/Conversation/GroupV1Migration', module);
stories.add('Single dropped and single invited member', () => (
<GroupV1Migration {...createProps()} />
));
stories.add('Multiple dropped and invited members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [contact1, contact2],
droppedMembers: [contact1, contact2],
})}
/>
));
stories.add('Just invited members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [contact1, contact1, contact2, contact2],
droppedMembers: [],
})}
/>
));
stories.add('Just dropped members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [],
droppedMembers: [contact1, contact1, contact2, contact2],
})}
/>
));
stories.add('No dropped or invited members', () => (
<GroupV1Migration
{...createProps({
invitedMembers: [],
droppedMembers: [],
})}
/>
));

View file

@ -0,0 +1,100 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { Intl } from '../Intl';
import { ContactName } from './ContactName';
import { ModalHost } from '../ModalHost';
import { GroupV1MigrationDialog } from '../GroupV1MigrationDialog';
export type PropsDataType = {
droppedMembers: Array<ConversationType>;
invitedMembers: Array<ConversationType>;
};
export type PropsHousekeepingType = {
i18n: LocalizerType;
};
export type PropsType = PropsDataType & PropsHousekeepingType;
export function GroupV1Migration(props: PropsType): React.ReactElement {
const { droppedMembers, i18n, invitedMembers } = props;
const [showingDialog, setShowingDialog] = React.useState(false);
const showDialog = React.useCallback(() => {
setShowingDialog(true);
}, [setShowingDialog]);
const dismissDialog = React.useCallback(() => {
setShowingDialog(false);
}, [setShowingDialog]);
return (
<div className="module-group-v1-migration">
<div className="module-group-v1-migration--icon" />
<div className="module-group-v1-migration--text">
{i18n('GroupV1--Migration--was-upgraded')}
</div>
{renderUsers(invitedMembers, i18n, 'GroupV1--Migration--invited')}
{renderUsers(droppedMembers, i18n, 'GroupV1--Migration--removed')}
<button
type="button"
className="module-group-v1-migration--button"
onClick={showDialog}
>
{i18n('GroupV1--Migration--learn-more')}
</button>
{showingDialog ? (
<ModalHost onClose={dismissDialog}>
<GroupV1MigrationDialog
droppedMembers={droppedMembers}
hasMigrated
i18n={i18n}
invitedMembers={invitedMembers}
learnMore={() =>
window.log.warn('GroupV1Migration: Modal called learnMore()')
}
migrate={() =>
window.log.warn('GroupV1Migration: Modal called migrate()')
}
onClose={dismissDialog}
/>
</ModalHost>
) : null}
</div>
);
}
function renderUsers(
members: Array<ConversationType>,
i18n: LocalizerType,
keyPrefix: string
): React.ReactElement | null {
if (!members || members.length === 0) {
return null;
}
const className = 'module-group-v1-migration--text';
if (members.length === 1) {
return (
<div className={className}>
<Intl
i18n={i18n}
id={`${keyPrefix}--one`}
components={[<ContactName title={members[0].title} i18n={i18n} />]}
/>
</div>
);
}
return (
<div className={className}>
{i18n(`${keyPrefix}--many`, [members.length.toString()])}
</div>
);
}

View file

@ -42,6 +42,10 @@ import {
GroupV2Change,
PropsDataType as GroupV2ChangeProps,
} from './GroupV2Change';
import {
GroupV1Migration,
PropsDataType as GroupV1MigrationProps,
} from './GroupV1Migration';
import { SmartContactRendererType } from '../../groupChange';
import { ResetSessionNotification } from './ResetSessionNotification';
import {
@ -85,6 +89,10 @@ type GroupV2ChangeType = {
type: 'groupV2Change';
data: GroupV2ChangeProps;
};
type GroupV1MigrationType = {
type: 'groupV1Migration';
data: GroupV1MigrationProps;
};
type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
@ -97,6 +105,7 @@ type ProfileChangeNotificationType = {
export type TimelineItemType =
| CallHistoryType
| GroupNotificationType
| GroupV1MigrationType
| GroupV2ChangeType
| LinkNotificationType
| MessageType
@ -187,6 +196,10 @@ export class TimelineItem extends React.PureComponent<PropsType> {
i18n={i18n}
/>
);
} else if (item.type === 'groupV1Migration') {
notification = (
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'resetSessionNotification') {
notification = (
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />

File diff suppressed because it is too large Load diff

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

@ -18,6 +18,7 @@ import { UserMessage } from './types/Message';
import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations';
import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI';
interface ModelAttributesInterface {
[key: string]: any;
@ -56,6 +57,7 @@ export type MessageAttributesType = {
deletedForEveryoneTimestamp?: number;
delivered: number;
delivered_to: Array<string | null>;
droppedGV2MemberIds?: Array<string>;
errors: Array<CustomError> | null;
expirationStartTimestamp: number | null;
expireTimer: number;
@ -72,6 +74,7 @@ export type MessageAttributesType = {
isErased: boolean;
isTapToViewInvalid: boolean;
isViewOnce: boolean;
invitedGV2Members?: Array<GroupV2PendingMemberType>;
key_changed: string;
local: boolean;
logger: unknown;
@ -143,7 +146,7 @@ export type ConversationAttributesTypeType = 'private' | 'group';
export type ConversationAttributesType = {
accessKey: string | null;
addedBy?: string;
capabilities: { uuid: string };
capabilities?: CapabilitiesType;
color?: string;
discoveredUnregisteredAt: number;
draftAttachments: Array<unknown>;
@ -202,6 +205,7 @@ export type ConversationAttributesType = {
// GroupV1 only
members?: Array<string>;
derivedGroupV2Id?: string;
// GroupV2 core info
masterKey?: string;
@ -222,6 +226,8 @@ export type ConversationAttributesType = {
expireTimer?: number;
membersV2?: Array<GroupV2MemberType>;
pendingMembersV2?: Array<GroupV2PendingMemberType>;
previousGroupV1Id?: string;
previousGroupV1Members?: Array<string>;
};
export type GroupV2MemberType = {
@ -230,7 +236,7 @@ export type GroupV2MemberType = {
joinedAtVersion: number;
};
export type GroupV2PendingMemberType = {
addedByUserId: string;
addedByUserId?: string;
conversationId: string;
timestamp: number;
};

View file

@ -278,10 +278,21 @@ export class ConversationModel extends window.Backbone.Model<
const groupVersion = this.get('groupVersion') || 0;
<<<<<<< HEAD
return (
groupVersion === 2 &&
base64ToArrayBuffer(groupId).byteLength === window.Signal.Groups.ID_LENGTH
);
=======
try {
return (
groupVersion === 2 && base64ToArrayBuffer(groupId).byteLength === 32
);
} catch (error) {
window.log.error('isGroupV2: Failed to process groupId in base64!');
return false;
}
>>>>>>> Support for GV1 -> GV2 migration
}
isMemberPending(conversationId: string): boolean {
@ -508,7 +519,6 @@ export class ConversationModel extends window.Backbone.Model<
const groupChange = await window.Signal.Groups.uploadGroupChange({
actions,
group: this.attributes,
serverPublicParamsBase64: window.getServerPublicParams(),
});
const groupChangeBuffer = groupChange.toArrayBuffer();
@ -830,6 +840,21 @@ export class ConversationModel extends window.Backbone.Model<
return this.isPrivate() || this.isGroupV1() || this.isGroupV2();
}
async maybeMigrateV1Group(): Promise<void> {
if (!this.isGroupV1()) {
return;
}
const isMigrated = await window.Signal.Groups.hasV1GroupBeenMigrated(this);
if (!isMigrated) {
return;
}
await window.Signal.Groups.waitThenRespondToGroupV2Migration({
conversation: this,
});
}
maybeRepairGroupV2(data: {
masterKey: string;
secretParams: string;
@ -1509,21 +1534,33 @@ export class ConversationModel extends window.Backbone.Model<
)
);
} catch (result) {
if (result instanceof Error) {
throw result;
} else if (result && result.errors) {
// We filter out unregistered user errors, because we ignore those in groups
const wasThereARealError = window._.some(
result.errors,
error => error.name !== 'UnregisteredUserError'
);
if (wasThereARealError) {
throw result;
}
}
this.processSendResponse(result);
}
}
// We only want to throw if there's a 'real' error contained with this information
// coming back from our low-level send infrastructure.
processSendResponse(
result: Error | CallbackResultType
): result is CallbackResultType {
if (result instanceof Error) {
throw result;
} else if (result && result.errors) {
// We filter out unregistered user errors, because we ignore those in groups
const wasThereARealError = window._.some(
result.errors,
error => error.name !== 'UnregisteredUserError'
);
if (wasThereARealError) {
throw result;
}
return true;
}
return true;
}
onMessageError(): void {
this.updateVerified();
}
@ -2916,10 +2953,6 @@ export class ConversationModel extends window.Backbone.Model<
};
}
getUuidCapable(): boolean {
return Boolean(window._.property('uuid')(this.get('capabilities')));
}
getSendMetadata(
options: { syncMessage?: string; disableMeCheck?: boolean } = {}
): WhatIsThis | null {
@ -2946,7 +2979,6 @@ export class ConversationModel extends window.Backbone.Model<
const accessKey = this.get('accessKey');
const sealedSender = this.get('sealedSender');
const uuidCapable = this.getUuidCapable();
// We never send sync messages as sealed sender
if (syncMessage && this.isMe()) {
@ -2960,9 +2992,6 @@ export class ConversationModel extends window.Backbone.Model<
if (sealedSender === SEALED_SENDER.UNKNOWN) {
const info = {
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
// Indicates that a client is capable of receiving uuid-only messages.
// Not used yet.
uuidCapable,
};
return {
...(e164 ? { [e164]: info } : {}),
@ -2979,9 +3008,6 @@ export class ConversationModel extends window.Backbone.Model<
accessKey && sealedSender === SEALED_SENDER.ENABLED
? accessKey
: arrayBufferToBase64(getRandomBytes(16)),
// Indicates that a client is capable of receiving uuid-only messages.
// Not used yet.
uuidCapable,
};
return {
@ -4172,19 +4198,16 @@ export class ConversationModel extends window.Backbone.Model<
});
}
notifyTyping(
options: {
isTyping: boolean;
senderId: string;
isMe: boolean;
senderDevice: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} = ({} as unknown) as any
): void {
const { isTyping, senderId, isMe, senderDevice } = options;
notifyTyping(options: {
isTyping: boolean;
senderId: string;
fromMe: boolean;
senderDevice: string;
}): void {
const { isTyping, senderId, fromMe, senderDevice } = options;
// We don't do anything with typing messages from our other devices
if (isMe) {
if (fromMe) {
return;
}

View file

@ -6,6 +6,7 @@ import {
MessageAttributesType,
CustomError,
} from '../model-types.d';
import { DataMessageClass } from '../textsecure.d';
import { ConversationModel } from './conversations';
import {
LastMessageStatus,
@ -22,6 +23,7 @@ import {
} from '../components/conversation/TimerNotification';
import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification';
import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification';
import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration';
import {
PropsData as GroupNotificationProps,
ChangeType,
@ -195,6 +197,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
!this.isExpirationTimerUpdate() &&
!this.isGroupUpdate() &&
!this.isGroupV2Change() &&
!this.isGroupV1Migration() &&
!this.isKeyChange() &&
!this.isMessageHistoryUnsynced() &&
!this.isProfileChange() &&
@ -217,6 +220,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
data: this.getPropsForGroupV2Change(),
};
}
if (this.isGroupV1Migration()) {
return {
type: 'groupV1Migration',
data: this.getPropsForGroupV1Migration(),
};
}
if (this.isMessageHistoryUnsynced()) {
return {
type: 'linkNotification',
@ -428,6 +437,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return Boolean(this.get('groupV2Change'));
}
isGroupV1Migration(): boolean {
return this.get('type') === 'group-v1-migration';
}
isExpirationTimerUpdate(): boolean {
const flag =
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
@ -501,6 +514,23 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
};
}
getPropsForGroupV1Migration(): GroupV1MigrationPropsType {
const invitedGV2Members = this.get('invitedGV2Members') || [];
const droppedGV2MemberIds = this.get('droppedGV2MemberIds') || [];
const invitedMembers = invitedGV2Members.map(item =>
this.findAndFormatContact(item.conversationId)
);
const droppedMembers = droppedGV2MemberIds.map(conversationId =>
this.findAndFormatContact(conversationId)
);
return {
droppedMembers,
invitedMembers,
};
}
getPropsForTimerNotification(): TimerNotificationProps | undefined {
const timerUpdate = this.get('expirationTimerUpdate');
if (!timerUpdate) {
@ -1082,9 +1112,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
};
}
if (this.get('deletedForEveryone')) {
if (this.isGroupV1Migration()) {
return {
text: window.i18n('message--deletedForEveryone'),
text: window.i18n('GroupV1--Migration--was-upgraded'),
};
}
@ -2771,7 +2801,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
handleDataMessage(
initialMessage: typeof window.WhatIsThis,
initialMessage: DataMessageClass,
confirm: () => void,
options: { data?: typeof window.WhatIsThis } = {}
): WhatIsThis {
@ -2863,40 +2893,58 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
const existingRevision = conversation.get('revision');
const isGroupV2 = Boolean(initialMessage.groupV2);
const isV2GroupUpdate =
initialMessage.groupV2 &&
(!existingRevision ||
initialMessage.groupV2.revision > existingRevision);
// GroupV2
if (isGroupV2) {
conversation.maybeRepairGroupV2(
_.pick(initialMessage.groupV2, [
'masterKey',
'secretParams',
'publicParams',
])
);
}
if (isV2GroupUpdate) {
const { revision, groupChange } = initialMessage.groupV2;
try {
await window.Signal.Groups.maybeUpdateGroup({
if (initialMessage.groupV2) {
if (conversation.isGroupV1()) {
// If we received a GroupV2 message in a GroupV1 group, we migrate!
const { revision, groupChange } = initialMessage.groupV2;
await window.Signal.Groups.respondToGroupV2Migration({
conversation,
groupChangeBase64: groupChange,
newRevision: revision,
receivedAt: message.get('received_at'),
sentAt: message.get('sent_at'),
});
} catch (error) {
const errorText = error && error.stack ? error.stack : error;
window.log.error(
`handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}`
);
throw error;
} else if (
initialMessage.groupV2.masterKey &&
initialMessage.groupV2.secretParams &&
initialMessage.groupV2.publicParams
) {
// Repair core GroupV2 data if needed
await conversation.maybeRepairGroupV2({
masterKey: initialMessage.groupV2.masterKey,
secretParams: initialMessage.groupV2.secretParams,
publicParams: initialMessage.groupV2.publicParams,
});
// Standard GroupV2 modification codepath
const existingRevision = conversation.get('revision');
const isV2GroupUpdate =
initialMessage.groupV2 &&
_.isNumber(initialMessage.groupV2.revision) &&
(!existingRevision ||
initialMessage.groupV2.revision > existingRevision);
if (isV2GroupUpdate && initialMessage.groupV2) {
const { revision, groupChange } = initialMessage.groupV2;
try {
await window.Signal.Groups.maybeUpdateGroup({
conversation,
groupChangeBase64: groupChange,
newRevision: revision,
receivedAt: message.get('received_at'),
sentAt: message.get('sent_at'),
});
} catch (error) {
const errorText = error && error.stack ? error.stack : error;
window.log.error(
`handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}`
);
throw error;
}
}
}
}
@ -2907,6 +2955,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
e164: source,
uuid: sourceUuid,
})!;
const isGroupV2 = Boolean(initialMessage.groupV2);
const isV1GroupUpdate =
initialMessage.group &&
initialMessage.group.type !==
@ -2949,6 +2998,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
// Because GroupV1 messages can now be multiplexed into GroupV2 conversations, we
// drop GroupV1 updates in GroupV2 groups.
if (isV1GroupUpdate && conversation.isGroupV2()) {
window.log.warn(
`Received GroupV1 update in GroupV2 conversation ${conversation.idForLogging()}. Dropping.`
);
confirm();
return;
}
// Send delivery receipts, but only for incoming sealed sender messages
// and not for messages from unaccepted conversations
if (

View file

@ -16,7 +16,11 @@ import {
GroupV2RecordClass,
PinnedConversationClass,
} from '../textsecure.d';
import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups';
import {
deriveGroupFields,
waitThenMaybeUpdateGroup,
waitThenRespondToGroupV2Migration,
} from '../groups';
import { ConversationModel } from '../models/conversations';
import { ConversationAttributesTypeType } from '../model-types.d';
@ -414,6 +418,53 @@ export async function mergeGroupV1Record(
return hasPendingChanges;
}
async function getGroupV2Conversation(
masterKeyBuffer: ArrayBuffer
): Promise<ConversationModel> {
const groupFields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(groupFields.id);
const masterKey = arrayBufferToBase64(masterKeyBuffer);
const secretParams = arrayBufferToBase64(groupFields.secretParams);
const publicParams = arrayBufferToBase64(groupFields.publicParams);
// First we check for an existing GroupV2 group
const groupV2 = window.ConversationController.get(groupId);
if (groupV2) {
await groupV2.maybeRepairGroupV2({
masterKey,
secretParams,
publicParams,
});
return groupV2;
}
// Then check for V1 group with matching derived GV2 id
const groupV1 = window.ConversationController.getByDerivedGroupV2Id(groupId);
if (groupV1) {
return groupV1;
}
const conversationId = window.ConversationController.ensureGroup(groupId, {
// 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,
secretParams,
publicParams,
});
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`getGroupV2Conversation: Failed to create conversation for groupv2(${groupId})`
);
}
return conversation;
}
export async function mergeGroupV2Record(
storageID: string,
groupV2Record: GroupV2RecordClass
@ -423,36 +474,7 @@ export async function mergeGroupV2Record(
}
const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer();
const groupFields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(groupFields.id);
const masterKey = arrayBufferToBase64(masterKeyBuffer);
const secretParams = arrayBufferToBase64(groupFields.secretParams);
const publicParams = arrayBufferToBase64(groupFields.publicParams);
const now = Date.now();
const conversationId = window.ConversationController.ensureGroup(groupId, {
// 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,
masterKey,
secretParams,
publicParams,
});
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(`No conversation for groupv2(${groupId})`);
}
conversation.maybeRepairGroupV2({
masterKey,
secretParams,
publicParams,
});
const conversation = await getGroupV2Conversation(masterKeyBuffer);
conversation.set({
isArchived: Boolean(groupV2Record.archived),
@ -476,10 +498,22 @@ export async function mergeGroupV2Record(
const isFirstSync = !window.storage.get('storageFetchComplete');
const dropInitialJoinMessage = isFirstSync;
// We don't need to update GroupV2 groups all the time. We fetch group state the first
// time we hear about these groups, from then on we rely on incoming messages or
// the user opening that conversation.
if (isGroupNewToUs) {
if (conversation.isGroupV1()) {
// If we found a GroupV1 conversation from this incoming GroupV2 record, we need to
// migrate it!
// We don't await this because this could take a very long time, waiting for queues to
// empty, etc.
waitThenRespondToGroupV2Migration({
conversation,
});
} else if (isGroupNewToUs) {
// We don't need to update GroupV2 groups all the time. We fetch group state the first
// time we hear about these groups, from then on we rely on incoming messages or
// the user opening that conversation.
// We don't await this because this could take a very long time, waiting for queues to
// empty, etc.
waitThenMaybeUpdateGroup({
conversation,
dropInitialJoinMessage,

View file

@ -2784,7 +2784,7 @@ async function getLastConversationActivity(
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId AND
(type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced', 'keychange')) AND
(type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced', 'keychange', 'group-v1-migration')) AND
(json_extract(json, '$.expirationTimerUpdate.fromSync') IS NULL OR json_extract(json, '$.expirationTimerUpdate.fromSync') != 1)
ORDER BY received_at DESC
LIMIT 1;`,
@ -2806,7 +2806,7 @@ async function getLastConversationPreview(
const row = await db.get(
`SELECT * FROM messages WHERE
conversationId = $conversationId AND
(type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced'))
(type IS NULL OR type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced', 'group-v1-migration'))
ORDER BY received_at DESC
LIMIT 1;`,
{

View file

@ -65,7 +65,7 @@ export type ConversationType = {
text: string;
deletedForEveryone?: boolean;
};
markedUnread: boolean;
markedUnread?: boolean;
phoneNumber?: string;
membersCount?: number;
expireTimer?: number;
@ -73,7 +73,7 @@ export type ConversationType = {
muteExpiresAt?: number;
type: ConversationTypeType;
isMe?: boolean;
lastUpdated: number;
lastUpdated?: number;
title: string;
unreadCount?: number;
isSelected?: boolean;

6
ts/textsecure.d.ts vendored
View file

@ -16,6 +16,7 @@ import SendMessage, { SendOptionsType } from './textsecure/SendMessage';
import { WebAPIType } from './textsecure/WebAPI';
import utils from './textsecure/Helpers';
import { CallingMessage as CallingMessageClass } from 'ringrtc';
import { WhatIsThis } from './window.d';
type AttachmentType = any;
@ -256,6 +257,8 @@ export declare class MemberClass {
profileKey?: ProtoBinaryType;
presentation?: ProtoBinaryType;
joinedAtVersion?: number;
// Note: only role and presentation are required when creating a group
}
type MemberRoleEnum = number;
@ -719,6 +722,9 @@ export declare class GroupContextClass {
name?: string | null;
membersE164?: Array<string>;
avatar?: AttachmentPointerClass | null;
// Note: these additional properties are added in the course of processing
derivedGroupV2Id?: string;
}
export declare class GroupContextV2Class {

View file

@ -23,6 +23,7 @@ import WebSocketResource, {
IncomingWebSocketRequest,
} from './WebsocketResources';
import Crypto from './Crypto';
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
import { ContactBuffer, GroupBuffer } from './ContactsParser';
import { IncomingIdentityKeyError } from './Errors';
@ -43,6 +44,8 @@ import { WebSocket } from './WebSocket';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;
declare global {
@ -58,6 +61,7 @@ declare global {
eventType?: string | number;
groupDetails?: any;
groupId?: string;
groupV2Id?: string;
messageRequestResponseType?: number | null;
proto?: any;
read?: any;
@ -273,6 +277,7 @@ class MessageReceiverInner extends EventTarget {
delete this.socket.onclose;
delete this.socket.onerror;
delete this.socket.onopen;
this.socket = undefined;
}
@ -1201,7 +1206,13 @@ class MessageReceiverInner extends EventTarget {
);
}
this.deriveGroupsV2Data(msg);
if (this.isInvalidGroupData(msg, envelope)) {
this.removeFromCache(envelope);
return undefined;
}
this.deriveGroupV1Data(msg);
this.deriveGroupV2Data(msg);
if (
msg.flags &&
@ -1377,7 +1388,7 @@ class MessageReceiverInner extends EventTarget {
return Promise.all(results);
}
handleTypingMessage(
async handleTypingMessage(
envelope: EnvelopeClass,
typingMessage: TypingMessageClass
) {
@ -1403,25 +1414,29 @@ class MessageReceiverInner extends EventTarget {
ev.senderUuid = envelope.sourceUuid;
ev.senderDevice = envelope.sourceDevice;
const groupIdBuffer = groupId ? groupId.toArrayBuffer() : null;
ev.typing = {
typingMessage,
timestamp: timestamp ? timestamp.toNumber() : Date.now(),
groupId:
groupIdBuffer && groupIdBuffer.byteLength <= 16
? groupId.toString('binary')
: null,
groupV2Id:
groupIdBuffer && groupIdBuffer.byteLength > 16
? groupId.toString('base64')
: null,
started:
action === window.textsecure.protobuf.TypingMessage.Action.STARTED,
stopped:
action === window.textsecure.protobuf.TypingMessage.Action.STOPPED,
};
const groupIdBuffer = groupId ? groupId.toArrayBuffer() : null;
if (groupIdBuffer && groupIdBuffer.byteLength > 0) {
if (groupIdBuffer.byteLength === GROUPV1_ID_LENGTH) {
ev.typing.groupId = groupId.toString('binary');
ev.typing.groupV2Id = await this.deriveGroupV2FromV1(groupIdBuffer);
} else if (groupIdBuffer.byteLength === GROUPV2_ID_LENGTH) {
ev.typing.groupV2Id = groupId.toString('base64');
} else {
window.log.error('handleTypingMessage: Received invalid groupId value');
this.removeFromCache(envelope);
}
}
return this.dispatchEvent(ev);
}
@ -1430,7 +1445,76 @@ class MessageReceiverInner extends EventTarget {
this.removeFromCache(envelope);
}
deriveGroupsV2Data(message: DataMessageClass) {
isInvalidGroupData(
message: DataMessageClass,
envelope: EnvelopeClass
): boolean {
const { group, groupV2 } = message;
if (group) {
const id = group.id.toArrayBuffer();
const isInvalid = id.byteLength !== GROUPV1_ID_LENGTH;
if (isInvalid) {
window.log.info(
'isInvalidGroupData: invalid GroupV1 message from',
this.getEnvelopeId(envelope)
);
}
return isInvalid;
}
if (groupV2) {
const masterKey = groupV2.masterKey.toArrayBuffer();
const isInvalid = masterKey.byteLength !== MASTER_KEY_LENGTH;
if (isInvalid) {
window.log.info(
'isInvalidGroupData: invalid GroupV2 message from',
this.getEnvelopeId(envelope)
);
}
return isInvalid;
}
return false;
}
async deriveGroupV2FromV1(groupId: ArrayBuffer): Promise<string> {
if (groupId.byteLength !== GROUPV1_ID_LENGTH) {
throw new Error(
`deriveGroupV2FromV1: had id with wrong byteLength: ${groupId.byteLength}`
);
}
const masterKey = await deriveMasterKeyFromGroupV1(groupId);
const data = deriveGroupFields(masterKey);
const toBase64 = MessageReceiverInner.arrayBufferToStringBase64;
return toBase64(data.id);
}
async deriveGroupV1Data(message: DataMessageClass) {
const { group } = message;
if (!group) {
return;
}
if (!group.id) {
throw new Error('deriveGroupV1Data: had falsey id');
}
const id = group.id.toArrayBuffer();
if (id.byteLength !== GROUPV1_ID_LENGTH) {
throw new Error(
`deriveGroupV1Data: had id with wrong byteLength: ${id.byteLength}`
);
}
group.derivedGroupV2Id = await this.deriveGroupV2FromV1(id);
}
deriveGroupV2Data(message: DataMessageClass) {
const { groupV2 } = message;
if (!groupV2) {
@ -1438,10 +1522,10 @@ class MessageReceiverInner extends EventTarget {
}
if (!isNumber(groupV2.revision)) {
throw new Error('deriveGroupsV2Data: revision was not a number');
throw new Error('deriveGroupV2Data: revision was not a number');
}
if (!groupV2.masterKey) {
throw new Error('deriveGroupsV2Data: had falsey masterKey');
throw new Error('deriveGroupV2Data: had falsey masterKey');
}
const toBase64 = MessageReceiverInner.arrayBufferToStringBase64;
@ -1449,7 +1533,7 @@ class MessageReceiverInner extends EventTarget {
const length = masterKey.byteLength;
if (length !== MASTER_KEY_LENGTH) {
throw new Error(
`deriveGroupsV2Data: masterKey had length ${length}, expected ${MASTER_KEY_LENGTH}`
`deriveGroupV2Data: masterKey had length ${length}, expected ${MASTER_KEY_LENGTH}`
);
}
@ -1522,7 +1606,13 @@ class MessageReceiverInner extends EventTarget {
);
}
this.deriveGroupsV2Data(sentMessage.message);
if (this.isInvalidGroupData(sentMessage.message, envelope)) {
this.removeFromCache(envelope);
return undefined;
}
this.deriveGroupV1Data(sentMessage.message);
this.deriveGroupV2Data(sentMessage.message);
window.log.info(
'sent message to',
@ -1630,14 +1720,32 @@ class MessageReceiverInner extends EventTarget {
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.threadE164 = sync.threadE164;
ev.threadUuid = sync.threadUuid;
ev.groupId = sync.groupId ? sync.groupId.toString('binary') : null;
ev.messageRequestResponseType = sync.type;
const idBuffer: ArrayBuffer = sync.groupId
? sync.groupId.toArrayBuffer()
: null;
if (idBuffer && idBuffer.byteLength > 0) {
if (idBuffer.byteLength === GROUPV1_ID_LENGTH) {
ev.groupId = sync.groupId.toString('binary');
ev.groupV2Id = await this.deriveGroupV2FromV1(idBuffer);
} else if (idBuffer.byteLength === GROUPV2_ID_LENGTH) {
ev.groupV2Id = sync.groupId.toString('base64');
} else {
this.removeFromCache(envelope);
window.log.error('Received message request with invalid groupId');
return undefined;
}
}
window.normalizeUuids(
ev,
['threadUuid'],
'MessageReceiver::handleMessageRequestResponse'
);
return this.dispatchAndWait(ev);
}
async handleFetchLatest(

View file

@ -1705,6 +1705,20 @@ export default class MessageSender {
return this.sendMessage(attrs, options);
}
async createGroup(
group: GroupClass,
options: GroupCredentialsType
): Promise<void> {
return this.server.createGroup(group, options);
}
async uploadGroupAvatar(
avatar: ArrayBuffer,
options: GroupCredentialsType
): Promise<string> {
return this.server.uploadGroupAvatar(avatar, options);
}
async getGroup(options: GroupCredentialsType): Promise<GroupClass> {
return this.server.getGroup(options);
}

View file

@ -620,7 +620,7 @@ const URL_CALLS = {
devices: 'v1/devices',
directoryAuth: 'v1/directory/auth',
discovery: 'v1/discovery',
getGroupAvatarUpload: '/v1/groups/avatar/form',
getGroupAvatarUpload: 'v1/groups/avatar/form',
getGroupCredentials: 'v1/certificate/group',
getIceServers: 'v1/accounts/turn',
getStickerPackUpload: 'v1/sticker/pack/form',
@ -689,6 +689,15 @@ export type WebAPIConnectType = {
connect: (options: ConnectParametersType) => WebAPIType;
};
export type CapabilitiesType = {
gv2: boolean;
'gv1-migration': boolean;
};
export type CapabilitiesUploadType = {
'gv2-3': boolean;
'gv1-migration': boolean;
};
type StickerPackManifestType = any;
export type GroupCredentialType = {
@ -796,7 +805,7 @@ export type WebAPIType = {
) => Promise<GroupChangeClass>;
modifyStorageRecords: MessageSender['modifyStorageRecords'];
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
registerCapabilities: (capabilities: Dictionary<boolean>) => Promise<void>;
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
putStickers: (
encryptedManifest: ArrayBuffer,
encryptedStickers: Array<ArrayBuffer>,
@ -1154,7 +1163,7 @@ export function initialize({
});
}
async function registerCapabilities(capabilities: Dictionary<boolean>) {
async function registerCapabilities(capabilities: CapabilitiesUploadType) {
return _ajax({
call: 'registerCapabilities',
httpType: 'PUT',
@ -1280,11 +1289,14 @@ export function initialize({
deviceName?: string | null,
options: { accessKey?: ArrayBuffer } = {}
) {
const capabilities: CapabilitiesUploadType = {
'gv2-3': true,
'gv1-migration': true,
};
const { accessKey } = options;
const jsonData: any = {
capabilities: {
'gv2-3': true,
},
capabilities,
fetchesMessages: true,
name: deviceName || undefined,
registrationId,
@ -2010,9 +2022,10 @@ export function initialize({
await _ajax({
basicAuth,
call: 'groups',
httpType: 'PUT',
contentType: 'application/x-protobuf',
data,
host: storageUrl,
httpType: 'PUT',
});
}
@ -2027,10 +2040,10 @@ export function initialize({
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'groups',
httpType: 'GET',
contentType: 'application/x-protobuf',
responseType: 'arraybuffer',
host: storageUrl,
httpType: 'GET',
responseType: 'arraybuffer',
});
return window.textsecure.protobuf.Group.decode(response);
@ -2049,11 +2062,11 @@ export function initialize({
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'groups',
httpType: 'PATCH',
data,
contentType: 'application/x-protobuf',
responseType: 'arraybuffer',
data,
host: storageUrl,
httpType: 'PATCH',
responseType: 'arraybuffer',
});
return window.textsecure.protobuf.GroupChange.decode(response);
@ -2071,11 +2084,11 @@ export function initialize({
const withDetails: ArrayBufferWithDetailsType = await _ajax({
basicAuth,
call: 'groupLog',
urlParameters: `/${startVersion}`,
httpType: 'GET',
contentType: 'application/x-protobuf',
responseType: 'arraybufferwithdetails',
host: storageUrl,
httpType: 'GET',
responseType: 'arraybufferwithdetails',
urlParameters: `/${startVersion}`,
});
const { data, response } = withDetails;
const changes = window.textsecure.protobuf.GroupChanges.decode(data);

View file

@ -15164,7 +15164,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.js",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
"lineNumber": 1260,
"lineNumber": 1263,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
},
@ -15172,8 +15172,8 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 2158,
"lineNumber": 2171,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}
]
]

View file

@ -369,6 +369,9 @@ Whisper.ConversationView = Whisper.View.extend({
this.model.fetchLatestGroupV2Data.bind(this.model),
FIVE_MINUTES
);
this.model.throttledMaybeMigrateV1Group =
this.model.throttledMaybeMigrateV1Group ||
_.throttle(this.model.maybeMigrateV1Group.bind(this.model), FIVE_MINUTES);
this.debouncedMaybeGrabLinkPreview = _.debounce(
this.maybeGrabLinkPreview.bind(this),
@ -2035,6 +2038,7 @@ Whisper.ConversationView = Whisper.View.extend({
}
this.model.throttledFetchLatestGroupV2Data();
this.model.throttledMaybeMigrateV1Group();
const statusPromise = this.model.throttledGetProfiles();
// eslint-disable-next-line more/no-then

2
ts/window.d.ts vendored
View file

@ -72,7 +72,7 @@ export { Long } from 'long';
type TaskResultType = any;
type WhatIsThis = any;
export type WhatIsThis = any;
declare global {
interface Window {