Support for GV1 -> GV2 migration
This commit is contained in:
parent
a0baa3e03f
commit
2c69f2c367
32 changed files with 2626 additions and 341 deletions
|
@ -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$ couldn’t 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 couldn’t 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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
15
ts/Crypto.ts
15
ts/Crypto.ts
|
@ -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);
|
||||
|
|
281
ts/background.ts
281
ts/background.ts
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -337,8 +337,9 @@ export const CompositionArea = ({
|
|||
}, [setLarge]);
|
||||
|
||||
if (
|
||||
messageRequestsEnabled &&
|
||||
(!acceptedMessageRequest || isBlocked || areWePending)
|
||||
isBlocked ||
|
||||
areWePending ||
|
||||
(messageRequestsEnabled && !acceptedMessageRequest)
|
||||
) {
|
||||
return (
|
||||
<MessageRequestActions
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
97
ts/components/GroupV1MigrationDialog.stories.tsx
Normal file
97
ts/components/GroupV1MigrationDialog.stories.tsx
Normal 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: [],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
180
ts/components/GroupV1MigrationDialog.tsx
Normal file
180
ts/components/GroupV1MigrationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
65
ts/components/ModalHost.tsx
Normal file
65
ts/components/ModalHost.tsx
Normal 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;
|
||||
});
|
78
ts/components/conversation/GroupV1Migration.stories.tsx
Normal file
78
ts/components/conversation/GroupV1Migration.stories.tsx
Normal 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: [],
|
||||
})}
|
||||
/>
|
||||
));
|
100
ts/components/conversation/GroupV1Migration.tsx
Normal file
100
ts/components/conversation/GroupV1Migration.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
|
|
1020
ts/groups.ts
1020
ts/groups.ts
File diff suppressed because it is too large
Load diff
10
ts/model-types.d.ts
vendored
10
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;`,
|
||||
{
|
||||
|
|
|
@ -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
6
ts/textsecure.d.ts
vendored
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
]
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -72,7 +72,7 @@ export { Long } from 'long';
|
|||
|
||||
type TaskResultType = any;
|
||||
|
||||
type WhatIsThis = any;
|
||||
export type WhatIsThis = any;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
Loading…
Reference in a new issue