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.",
|
"message": "Please try again or contact support.",
|
||||||
"description": "Description text in pop-up dialog when user-initiated task has gone wrong"
|
"description": "Description text in pop-up dialog when user-initiated task has gone wrong"
|
||||||
},
|
},
|
||||||
"ErrorModal--buttonText": {
|
"Confirmation--confirm": {
|
||||||
"message": "Okay",
|
"message": "Okay",
|
||||||
"description": "Button to dismiss pop-up dialog when user-initiated task has gone wrong"
|
"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": {
|
"close": {
|
||||||
"message": "Close",
|
"message": "Close",
|
||||||
"description": "Generic close label"
|
"description": "Generic close label"
|
||||||
|
|
|
@ -20,9 +20,7 @@
|
||||||
});
|
});
|
||||||
if (syncByE164) {
|
if (syncByE164) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
`Found early message request response for E164 ${conversation.get(
|
`Found early message request response for E164 ${conversation.idForLogging()}`
|
||||||
'e164'
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
this.remove(syncByE164);
|
this.remove(syncByE164);
|
||||||
return syncByE164;
|
return syncByE164;
|
||||||
|
@ -35,24 +33,35 @@
|
||||||
});
|
});
|
||||||
if (syncByUuid) {
|
if (syncByUuid) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
`Found early message request response for UUID ${conversation.get(
|
`Found early message request response for UUID ${conversation.idForLogging()}`
|
||||||
'uuid'
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
this.remove(syncByUuid);
|
this.remove(syncByUuid);
|
||||||
return syncByUuid;
|
return syncByUuid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V1 Group
|
||||||
if (conversation.get('groupId')) {
|
if (conversation.get('groupId')) {
|
||||||
const syncByGroupId = this.findWhere({
|
const syncByGroupId = this.findWhere({
|
||||||
groupId: conversation.get('groupId'),
|
groupId: conversation.get('groupId'),
|
||||||
});
|
});
|
||||||
if (syncByGroupId) {
|
if (syncByGroupId) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
`Found early message request response for GROUP ID ${conversation.get(
|
`Found early message request response for group v1 ID ${conversation.idForLogging()}`
|
||||||
'groupId'
|
);
|
||||||
)}`
|
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);
|
this.remove(syncByGroupId);
|
||||||
return syncByGroupId;
|
return syncByGroupId;
|
||||||
|
@ -66,19 +75,29 @@
|
||||||
const threadE164 = sync.get('threadE164');
|
const threadE164 = sync.get('threadE164');
|
||||||
const threadUuid = sync.get('threadUuid');
|
const threadUuid = sync.get('threadUuid');
|
||||||
const groupId = sync.get('groupId');
|
const groupId = sync.get('groupId');
|
||||||
|
const groupV2Id = sync.get('groupV2Id');
|
||||||
|
|
||||||
const conversation = groupId
|
let conversation;
|
||||||
? ConversationController.get(groupId)
|
|
||||||
: ConversationController.get(
|
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
|
||||||
ConversationController.ensureContactIds({
|
if (groupV2Id) {
|
||||||
e164: threadE164,
|
conversation = ConversationController.get(groupV2Id);
|
||||||
uuid: threadUuid,
|
}
|
||||||
})
|
if (!conversation && groupId) {
|
||||||
);
|
conversation = ConversationController.get(groupId);
|
||||||
|
}
|
||||||
|
if (!conversation && (threadE164 || threadUuid)) {
|
||||||
|
conversation = ConversationController.get(
|
||||||
|
ConversationController.ensureContactIds({
|
||||||
|
e164: threadE164,
|
||||||
|
uuid: threadUuid,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
window.log(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,3 +190,118 @@
|
||||||
outline: inherit;
|
outline: inherit;
|
||||||
text-align: 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;
|
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
|
||||||
|
|
||||||
.module-progress-dialog {
|
.module-progress-dialog {
|
||||||
|
@ -9778,6 +9991,7 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module: Group Contact Details
|
// Module: Group Contact Details
|
||||||
|
|
||||||
$contact-modal-padding: 18px;
|
$contact-modal-padding: 18px;
|
||||||
.module-contact-modal {
|
.module-contact-modal {
|
||||||
@include font-body-2;
|
@include font-body-2;
|
||||||
|
@ -9863,10 +10077,10 @@ $contact-modal-padding: 18px;
|
||||||
&:focus {
|
&:focus {
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
background-color: $color-gray-15;
|
background-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-keyboard-mode {
|
||||||
background-color: $color-gray-60;
|
background-color: $color-gray-60;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9943,8 +10157,8 @@ $contact-modal-padding: 18px;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
|
|
||||||
width: 16px;
|
width: 24px;
|
||||||
height: 16px;
|
height: 24px;
|
||||||
|
|
||||||
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-75);
|
@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', () => {
|
describe('symmetric encryption', () => {
|
||||||
it('roundtrips', async () => {
|
it('roundtrips', async () => {
|
||||||
const message = 'this is my message';
|
const message = 'this is my message';
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from './model-types.d';
|
} from './model-types.d';
|
||||||
import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage';
|
import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage';
|
||||||
import { ConversationModel } from './models/conversations';
|
import { ConversationModel } from './models/conversations';
|
||||||
|
import { maybeDeriveGroupV2Id } from './groups';
|
||||||
|
|
||||||
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
||||||
|
|
||||||
|
@ -222,6 +223,9 @@ export class ConversationController {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (conversation.isGroupV1()) {
|
||||||
|
await maybeDeriveGroupV2Id(conversation);
|
||||||
|
}
|
||||||
await saveConversation(conversation.attributes);
|
await saveConversation(conversation.attributes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.log.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> {
|
async loadPromise(): Promise<void> {
|
||||||
return this._initialPromise;
|
return this._initialPromise;
|
||||||
}
|
}
|
||||||
|
@ -710,6 +720,11 @@ export class ConversationController {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this._conversations.map(async conversation => {
|
this._conversations.map(async conversation => {
|
||||||
try {
|
try {
|
||||||
|
const isChanged = await maybeDeriveGroupV2Id(conversation);
|
||||||
|
if (isChanged) {
|
||||||
|
updateConversation(conversation.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
if (!conversation.get('lastMessage')) {
|
if (!conversation.get('lastMessage')) {
|
||||||
await conversation.updateLastMessage();
|
await conversation.updateLastMessage();
|
||||||
}
|
}
|
||||||
|
|
15
ts/Crypto.ts
15
ts/Crypto.ts
|
@ -59,6 +59,21 @@ export async function deriveStickerPackKey(
|
||||||
return concatenateBytes(part1, part2);
|
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> {
|
export async function computeHash(data: ArrayBuffer): Promise<string> {
|
||||||
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data);
|
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data);
|
||||||
return arrayBufferToBase64(hash);
|
return arrayBufferToBase64(hash);
|
||||||
|
|
281
ts/background.ts
281
ts/background.ts
|
@ -1,7 +1,12 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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
|
// eslint-disable-next-line func-names
|
||||||
(async function () {
|
(async function () {
|
||||||
|
@ -979,10 +984,6 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
if (className.includes('module-main-header__search__input')) {
|
if (className.includes('module-main-header__search__input')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (className.includes('module-contact-modal')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// These add listeners to document, but we'll run first
|
// These add listeners to document, but we'll run first
|
||||||
|
@ -1022,10 +1023,22 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactionPicker = document.querySelector('module-reaction-picker');
|
const reactionPicker = document.querySelector(
|
||||||
|
'.module-reaction-picker'
|
||||||
|
);
|
||||||
if (reactionPicker) {
|
if (reactionPicker) {
|
||||||
return;
|
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
|
// Close window.Backbone-based confirmation dialog
|
||||||
|
@ -1975,22 +1988,21 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to do this after fetching our UUID
|
if (connectCount === 1) {
|
||||||
const hasRegisteredGV23Support = 'hasRegisteredGV23Support';
|
|
||||||
if (
|
|
||||||
!window.storage.get(hasRegisteredGV23Support) &&
|
|
||||||
window.textsecure.storage.user.getUuid()
|
|
||||||
) {
|
|
||||||
const server = window.WebAPI.connect({
|
const server = window.WebAPI.connect({
|
||||||
username: USERNAME || OLD_USERNAME,
|
username: USERNAME || OLD_USERNAME,
|
||||||
password: PASSWORD,
|
password: PASSWORD,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await server.registerCapabilities({ 'gv2-3': true });
|
// Note: we always have to register our capabilities all at once, so we do this
|
||||||
window.storage.put(hasRegisteredGV23Support, true);
|
// after connect on every startup
|
||||||
|
await server.registerCapabilities({
|
||||||
|
'gv2-3': true,
|
||||||
|
'gv1-migration': true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'Error: Unable to register support for GV2.',
|
'Error: Unable to register our capabilities.',
|
||||||
error && error.stack ? error.stack : error
|
error && error.stack ? error.stack : error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2227,16 +2239,35 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let conversation;
|
||||||
|
|
||||||
const senderId = window.ConversationController.ensureContactIds({
|
const senderId = window.ConversationController.ensureContactIds({
|
||||||
e164: sender,
|
e164: sender,
|
||||||
uuid: senderUuid,
|
uuid: senderUuid,
|
||||||
highTrust: true,
|
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();
|
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) {
|
if (!conversation) {
|
||||||
window.log.warn(
|
window.log.warn(
|
||||||
`onTyping: Did not find conversation for typing indicator (groupv2(${groupV2Id}), group(${groupId}), ${sender}, ${senderUuid})`
|
`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
|
// 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(
|
window.log.warn(
|
||||||
`Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
|
`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({
|
conversation.notifyTyping({
|
||||||
isTyping: started,
|
isTyping: started,
|
||||||
isMe: ourId === senderId,
|
fromMe: senderId === ourId,
|
||||||
sender,
|
|
||||||
senderUuid,
|
|
||||||
senderId,
|
senderId,
|
||||||
senderDevice,
|
senderDevice,
|
||||||
} as WhatIsThis);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onStickerPack(ev: WhatIsThis) {
|
async function onStickerPack(ev: WhatIsThis) {
|
||||||
|
@ -2552,64 +2580,18 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
return confirm();
|
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
|
// 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
|
// inside a conversation-specific queue(). Any code here might run before an earlier
|
||||||
// message is processed in handleDataMessage().
|
// message is processed in handleDataMessage().
|
||||||
function onMessageReceived(event: WhatIsThis) {
|
function onMessageReceived(event: WhatIsThis) {
|
||||||
const { data, confirm } = event;
|
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;
|
const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags;
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
|
@ -2776,15 +2758,50 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
} as WhatIsThis);
|
} as WhatIsThis);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
|
// Works with 'sent' and 'message' data sent from MessageReceiver, with a little massage
|
||||||
const getDescriptorForSent = ({
|
// at callsites to make sure both source and destination are populated.
|
||||||
|
const getMessageDescriptor = ({
|
||||||
message,
|
message,
|
||||||
|
source,
|
||||||
|
sourceUuid,
|
||||||
destination,
|
destination,
|
||||||
destinationUuid,
|
destinationUuid,
|
||||||
}: WhatIsThis) => {
|
}: {
|
||||||
|
message: DataMessageClass;
|
||||||
|
source: string;
|
||||||
|
sourceUuid: string;
|
||||||
|
destination: string;
|
||||||
|
destinationUuid: string;
|
||||||
|
}): MessageDescriptor => {
|
||||||
if (message.groupV2) {
|
if (message.groupV2) {
|
||||||
const { id } = 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, {
|
const conversationId = window.ConversationController.ensureGroup(id, {
|
||||||
|
// Note: We don't set active_at, because we don't want the group to show until
|
||||||
|
// we have information about it beyond these initial details.
|
||||||
|
// see maybeUpdateGroup().
|
||||||
groupVersion: 2,
|
groupVersion: 2,
|
||||||
masterKey: message.groupV2.masterKey,
|
masterKey: message.groupV2.masterKey,
|
||||||
secretParams: message.groupV2.secretParams,
|
secretParams: message.groupV2.secretParams,
|
||||||
|
@ -2797,8 +2814,37 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (message.group) {
|
if (message.group) {
|
||||||
const { id } = message.group;
|
const { id, derivedGroupV2Id } = message.group;
|
||||||
const conversationId = window.ConversationController.ensureGroup(id);
|
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 {
|
return {
|
||||||
type: Message.GROUP,
|
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 {
|
return {
|
||||||
type: Message.PRIVATE,
|
type: Message.PRIVATE,
|
||||||
id: window.ConversationController.ensureContactIds({
|
id,
|
||||||
e164: destination,
|
|
||||||
uuid: destinationUuid,
|
|
||||||
highTrust: true,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2822,7 +2875,12 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
function onSentMessage(event: WhatIsThis) {
|
function onSentMessage(event: WhatIsThis) {
|
||||||
const { data, confirm } = event;
|
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;
|
const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags;
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
|
@ -2885,7 +2943,15 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
return Promise.resolve();
|
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({
|
return new window.Whisper.Message({
|
||||||
source: data.source,
|
source: data.source,
|
||||||
sourceUuid: data.sourceUuid,
|
sourceUuid: data.sourceUuid,
|
||||||
|
@ -2998,12 +3064,16 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
const envelope = ev.proto;
|
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, {
|
const message = initIncomingMessage(envelope, {
|
||||||
type: Message.PRIVATE,
|
type: Message.PRIVATE,
|
||||||
id: window.ConversationController.ensureContactIds({
|
id,
|
||||||
e164: envelope.source,
|
|
||||||
uuid: envelope.sourceUuid,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const conversationId = message.get('conversationId');
|
const conversationId = message.get('conversationId');
|
||||||
|
@ -3141,18 +3211,29 @@ type WhatIsThis = typeof window.WhatIsThis;
|
||||||
async function onMessageRequestResponse(ev: WhatIsThis) {
|
async function onMessageRequestResponse(ev: WhatIsThis) {
|
||||||
ev.confirm();
|
ev.confirm();
|
||||||
|
|
||||||
const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev;
|
const {
|
||||||
|
|
||||||
const args = {
|
|
||||||
threadE164,
|
threadE164,
|
||||||
threadUuid,
|
threadUuid,
|
||||||
groupId,
|
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,
|
type: messageRequestResponseType,
|
||||||
};
|
});
|
||||||
|
|
||||||
window.log.info('message request response', args);
|
|
||||||
|
|
||||||
const sync = window.Whisper.MessageRequests.add(args);
|
|
||||||
|
|
||||||
window.Whisper.MessageRequests.onResponse(sync);
|
window.Whisper.MessageRequests.onResponse(sync);
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,15 +86,9 @@ export class Avatar extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderNoImage(): JSX.Element {
|
public renderNoImage(): JSX.Element {
|
||||||
const {
|
const { conversationType, noteToSelf, size, title } = this.props;
|
||||||
conversationType,
|
|
||||||
name,
|
|
||||||
noteToSelf,
|
|
||||||
profileName,
|
|
||||||
size,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const initials = getInitials(name || profileName);
|
const initials = getInitials(title);
|
||||||
const isGroup = conversationType === 'group';
|
const isGroup = conversationType === 'group';
|
||||||
|
|
||||||
if (noteToSelf) {
|
if (noteToSelf) {
|
||||||
|
|
|
@ -337,8 +337,9 @@ export const CompositionArea = ({
|
||||||
}, [setLarge]);
|
}, [setLarge]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
messageRequestsEnabled &&
|
isBlocked ||
|
||||||
(!acceptedMessageRequest || isBlocked || areWePending)
|
areWePending ||
|
||||||
|
(messageRequestsEnabled && !acceptedMessageRequest)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<MessageRequestActions
|
<MessageRequestActions
|
||||||
|
|
|
@ -38,9 +38,9 @@ export type PropsData = {
|
||||||
isMe?: boolean;
|
isMe?: boolean;
|
||||||
muteExpiresAt?: number;
|
muteExpiresAt?: number;
|
||||||
|
|
||||||
lastUpdated: number;
|
lastUpdated?: number;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
markedUnread: boolean;
|
markedUnread?: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
||||||
acceptedMessageRequest?: boolean;
|
acceptedMessageRequest?: boolean;
|
||||||
|
@ -100,7 +100,7 @@ export class ConversationListItem extends React.PureComponent<Props> {
|
||||||
isUnread(): boolean {
|
isUnread(): boolean {
|
||||||
const { markedUnread, unreadCount } = this.props;
|
const { markedUnread, unreadCount } = this.props;
|
||||||
|
|
||||||
return (isNumber(unreadCount) && unreadCount > 0) || markedUnread;
|
return Boolean((isNumber(unreadCount) && unreadCount > 0) || markedUnread);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renderUnread(): JSX.Element | null {
|
public renderUnread(): JSX.Element | null {
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const ErrorModal = (props: PropsType): JSX.Element => {
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
ref={focusRef}
|
ref={focusRef}
|
||||||
>
|
>
|
||||||
{buttonText || i18n('ErrorModal--buttonText')}
|
{buttonText || i18n('Confirmation--confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</ConfirmationModal>
|
</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,
|
GroupV2Change,
|
||||||
PropsDataType as GroupV2ChangeProps,
|
PropsDataType as GroupV2ChangeProps,
|
||||||
} from './GroupV2Change';
|
} from './GroupV2Change';
|
||||||
|
import {
|
||||||
|
GroupV1Migration,
|
||||||
|
PropsDataType as GroupV1MigrationProps,
|
||||||
|
} from './GroupV1Migration';
|
||||||
import { SmartContactRendererType } from '../../groupChange';
|
import { SmartContactRendererType } from '../../groupChange';
|
||||||
import { ResetSessionNotification } from './ResetSessionNotification';
|
import { ResetSessionNotification } from './ResetSessionNotification';
|
||||||
import {
|
import {
|
||||||
|
@ -85,6 +89,10 @@ type GroupV2ChangeType = {
|
||||||
type: 'groupV2Change';
|
type: 'groupV2Change';
|
||||||
data: GroupV2ChangeProps;
|
data: GroupV2ChangeProps;
|
||||||
};
|
};
|
||||||
|
type GroupV1MigrationType = {
|
||||||
|
type: 'groupV1Migration';
|
||||||
|
data: GroupV1MigrationProps;
|
||||||
|
};
|
||||||
type ResetSessionNotificationType = {
|
type ResetSessionNotificationType = {
|
||||||
type: 'resetSessionNotification';
|
type: 'resetSessionNotification';
|
||||||
data: null;
|
data: null;
|
||||||
|
@ -97,6 +105,7 @@ type ProfileChangeNotificationType = {
|
||||||
export type TimelineItemType =
|
export type TimelineItemType =
|
||||||
| CallHistoryType
|
| CallHistoryType
|
||||||
| GroupNotificationType
|
| GroupNotificationType
|
||||||
|
| GroupV1MigrationType
|
||||||
| GroupV2ChangeType
|
| GroupV2ChangeType
|
||||||
| LinkNotificationType
|
| LinkNotificationType
|
||||||
| MessageType
|
| MessageType
|
||||||
|
@ -187,6 +196,10 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (item.type === 'groupV1Migration') {
|
||||||
|
notification = (
|
||||||
|
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
|
||||||
|
);
|
||||||
} else if (item.type === 'resetSessionNotification') {
|
} else if (item.type === 'resetSessionNotification') {
|
||||||
notification = (
|
notification = (
|
||||||
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
|
<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 { MessageModel } from './models/messages';
|
||||||
import { ConversationModel } from './models/conversations';
|
import { ConversationModel } from './models/conversations';
|
||||||
import { ProfileNameChangeType } from './util/getStringForProfileChange';
|
import { ProfileNameChangeType } from './util/getStringForProfileChange';
|
||||||
|
import { CapabilitiesType } from './textsecure/WebAPI';
|
||||||
|
|
||||||
interface ModelAttributesInterface {
|
interface ModelAttributesInterface {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
@ -56,6 +57,7 @@ export type MessageAttributesType = {
|
||||||
deletedForEveryoneTimestamp?: number;
|
deletedForEveryoneTimestamp?: number;
|
||||||
delivered: number;
|
delivered: number;
|
||||||
delivered_to: Array<string | null>;
|
delivered_to: Array<string | null>;
|
||||||
|
droppedGV2MemberIds?: Array<string>;
|
||||||
errors: Array<CustomError> | null;
|
errors: Array<CustomError> | null;
|
||||||
expirationStartTimestamp: number | null;
|
expirationStartTimestamp: number | null;
|
||||||
expireTimer: number;
|
expireTimer: number;
|
||||||
|
@ -72,6 +74,7 @@ export type MessageAttributesType = {
|
||||||
isErased: boolean;
|
isErased: boolean;
|
||||||
isTapToViewInvalid: boolean;
|
isTapToViewInvalid: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
|
invitedGV2Members?: Array<GroupV2PendingMemberType>;
|
||||||
key_changed: string;
|
key_changed: string;
|
||||||
local: boolean;
|
local: boolean;
|
||||||
logger: unknown;
|
logger: unknown;
|
||||||
|
@ -143,7 +146,7 @@ export type ConversationAttributesTypeType = 'private' | 'group';
|
||||||
export type ConversationAttributesType = {
|
export type ConversationAttributesType = {
|
||||||
accessKey: string | null;
|
accessKey: string | null;
|
||||||
addedBy?: string;
|
addedBy?: string;
|
||||||
capabilities: { uuid: string };
|
capabilities?: CapabilitiesType;
|
||||||
color?: string;
|
color?: string;
|
||||||
discoveredUnregisteredAt: number;
|
discoveredUnregisteredAt: number;
|
||||||
draftAttachments: Array<unknown>;
|
draftAttachments: Array<unknown>;
|
||||||
|
@ -202,6 +205,7 @@ export type ConversationAttributesType = {
|
||||||
|
|
||||||
// GroupV1 only
|
// GroupV1 only
|
||||||
members?: Array<string>;
|
members?: Array<string>;
|
||||||
|
derivedGroupV2Id?: string;
|
||||||
|
|
||||||
// GroupV2 core info
|
// GroupV2 core info
|
||||||
masterKey?: string;
|
masterKey?: string;
|
||||||
|
@ -222,6 +226,8 @@ export type ConversationAttributesType = {
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
membersV2?: Array<GroupV2MemberType>;
|
membersV2?: Array<GroupV2MemberType>;
|
||||||
pendingMembersV2?: Array<GroupV2PendingMemberType>;
|
pendingMembersV2?: Array<GroupV2PendingMemberType>;
|
||||||
|
previousGroupV1Id?: string;
|
||||||
|
previousGroupV1Members?: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupV2MemberType = {
|
export type GroupV2MemberType = {
|
||||||
|
@ -230,7 +236,7 @@ export type GroupV2MemberType = {
|
||||||
joinedAtVersion: number;
|
joinedAtVersion: number;
|
||||||
};
|
};
|
||||||
export type GroupV2PendingMemberType = {
|
export type GroupV2PendingMemberType = {
|
||||||
addedByUserId: string;
|
addedByUserId?: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
|
@ -278,10 +278,21 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
|
|
||||||
const groupVersion = this.get('groupVersion') || 0;
|
const groupVersion = this.get('groupVersion') || 0;
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
return (
|
return (
|
||||||
groupVersion === 2 &&
|
groupVersion === 2 &&
|
||||||
base64ToArrayBuffer(groupId).byteLength === window.Signal.Groups.ID_LENGTH
|
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 {
|
isMemberPending(conversationId: string): boolean {
|
||||||
|
@ -508,7 +519,6 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
const groupChange = await window.Signal.Groups.uploadGroupChange({
|
const groupChange = await window.Signal.Groups.uploadGroupChange({
|
||||||
actions,
|
actions,
|
||||||
group: this.attributes,
|
group: this.attributes,
|
||||||
serverPublicParamsBase64: window.getServerPublicParams(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const groupChangeBuffer = groupChange.toArrayBuffer();
|
const groupChangeBuffer = groupChange.toArrayBuffer();
|
||||||
|
@ -830,6 +840,21 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
return this.isPrivate() || this.isGroupV1() || this.isGroupV2();
|
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: {
|
maybeRepairGroupV2(data: {
|
||||||
masterKey: string;
|
masterKey: string;
|
||||||
secretParams: string;
|
secretParams: string;
|
||||||
|
@ -1509,21 +1534,33 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (result) {
|
} catch (result) {
|
||||||
if (result instanceof Error) {
|
this.processSendResponse(result);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
onMessageError(): void {
|
||||||
this.updateVerified();
|
this.updateVerified();
|
||||||
}
|
}
|
||||||
|
@ -2916,10 +2953,6 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getUuidCapable(): boolean {
|
|
||||||
return Boolean(window._.property('uuid')(this.get('capabilities')));
|
|
||||||
}
|
|
||||||
|
|
||||||
getSendMetadata(
|
getSendMetadata(
|
||||||
options: { syncMessage?: string; disableMeCheck?: boolean } = {}
|
options: { syncMessage?: string; disableMeCheck?: boolean } = {}
|
||||||
): WhatIsThis | null {
|
): WhatIsThis | null {
|
||||||
|
@ -2946,7 +2979,6 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
|
|
||||||
const accessKey = this.get('accessKey');
|
const accessKey = this.get('accessKey');
|
||||||
const sealedSender = this.get('sealedSender');
|
const sealedSender = this.get('sealedSender');
|
||||||
const uuidCapable = this.getUuidCapable();
|
|
||||||
|
|
||||||
// We never send sync messages as sealed sender
|
// We never send sync messages as sealed sender
|
||||||
if (syncMessage && this.isMe()) {
|
if (syncMessage && this.isMe()) {
|
||||||
|
@ -2960,9 +2992,6 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
||||||
const info = {
|
const info = {
|
||||||
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
|
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
|
||||||
// Indicates that a client is capable of receiving uuid-only messages.
|
|
||||||
// Not used yet.
|
|
||||||
uuidCapable,
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...(e164 ? { [e164]: info } : {}),
|
...(e164 ? { [e164]: info } : {}),
|
||||||
|
@ -2979,9 +3008,6 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
||||||
? accessKey
|
? accessKey
|
||||||
: arrayBufferToBase64(getRandomBytes(16)),
|
: arrayBufferToBase64(getRandomBytes(16)),
|
||||||
// Indicates that a client is capable of receiving uuid-only messages.
|
|
||||||
// Not used yet.
|
|
||||||
uuidCapable,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -4172,19 +4198,16 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyTyping(
|
notifyTyping(options: {
|
||||||
options: {
|
isTyping: boolean;
|
||||||
isTyping: boolean;
|
senderId: string;
|
||||||
senderId: string;
|
fromMe: boolean;
|
||||||
isMe: boolean;
|
senderDevice: string;
|
||||||
senderDevice: string;
|
}): void {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const { isTyping, senderId, fromMe, senderDevice } = options;
|
||||||
} = ({} as unknown) as any
|
|
||||||
): void {
|
|
||||||
const { isTyping, senderId, isMe, senderDevice } = options;
|
|
||||||
|
|
||||||
// We don't do anything with typing messages from our other devices
|
// We don't do anything with typing messages from our other devices
|
||||||
if (isMe) {
|
if (fromMe) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
CustomError,
|
CustomError,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
|
import { DataMessageClass } from '../textsecure.d';
|
||||||
import { ConversationModel } from './conversations';
|
import { ConversationModel } from './conversations';
|
||||||
import {
|
import {
|
||||||
LastMessageStatus,
|
LastMessageStatus,
|
||||||
|
@ -22,6 +23,7 @@ import {
|
||||||
} from '../components/conversation/TimerNotification';
|
} from '../components/conversation/TimerNotification';
|
||||||
import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification';
|
import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification';
|
||||||
import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification';
|
import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification';
|
||||||
|
import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration';
|
||||||
import {
|
import {
|
||||||
PropsData as GroupNotificationProps,
|
PropsData as GroupNotificationProps,
|
||||||
ChangeType,
|
ChangeType,
|
||||||
|
@ -195,6 +197,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
!this.isExpirationTimerUpdate() &&
|
!this.isExpirationTimerUpdate() &&
|
||||||
!this.isGroupUpdate() &&
|
!this.isGroupUpdate() &&
|
||||||
!this.isGroupV2Change() &&
|
!this.isGroupV2Change() &&
|
||||||
|
!this.isGroupV1Migration() &&
|
||||||
!this.isKeyChange() &&
|
!this.isKeyChange() &&
|
||||||
!this.isMessageHistoryUnsynced() &&
|
!this.isMessageHistoryUnsynced() &&
|
||||||
!this.isProfileChange() &&
|
!this.isProfileChange() &&
|
||||||
|
@ -217,6 +220,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
data: this.getPropsForGroupV2Change(),
|
data: this.getPropsForGroupV2Change(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (this.isGroupV1Migration()) {
|
||||||
|
return {
|
||||||
|
type: 'groupV1Migration',
|
||||||
|
data: this.getPropsForGroupV1Migration(),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (this.isMessageHistoryUnsynced()) {
|
if (this.isMessageHistoryUnsynced()) {
|
||||||
return {
|
return {
|
||||||
type: 'linkNotification',
|
type: 'linkNotification',
|
||||||
|
@ -428,6 +437,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return Boolean(this.get('groupV2Change'));
|
return Boolean(this.get('groupV2Change'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isGroupV1Migration(): boolean {
|
||||||
|
return this.get('type') === 'group-v1-migration';
|
||||||
|
}
|
||||||
|
|
||||||
isExpirationTimerUpdate(): boolean {
|
isExpirationTimerUpdate(): boolean {
|
||||||
const flag =
|
const flag =
|
||||||
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
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 {
|
getPropsForTimerNotification(): TimerNotificationProps | undefined {
|
||||||
const timerUpdate = this.get('expirationTimerUpdate');
|
const timerUpdate = this.get('expirationTimerUpdate');
|
||||||
if (!timerUpdate) {
|
if (!timerUpdate) {
|
||||||
|
@ -1082,9 +1112,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.get('deletedForEveryone')) {
|
if (this.isGroupV1Migration()) {
|
||||||
return {
|
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(
|
handleDataMessage(
|
||||||
initialMessage: typeof window.WhatIsThis,
|
initialMessage: DataMessageClass,
|
||||||
confirm: () => void,
|
confirm: () => void,
|
||||||
options: { data?: typeof window.WhatIsThis } = {}
|
options: { data?: typeof window.WhatIsThis } = {}
|
||||||
): 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
|
// GroupV2
|
||||||
if (isGroupV2) {
|
|
||||||
conversation.maybeRepairGroupV2(
|
|
||||||
_.pick(initialMessage.groupV2, [
|
|
||||||
'masterKey',
|
|
||||||
'secretParams',
|
|
||||||
'publicParams',
|
|
||||||
])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isV2GroupUpdate) {
|
if (initialMessage.groupV2) {
|
||||||
const { revision, groupChange } = initialMessage.groupV2;
|
if (conversation.isGroupV1()) {
|
||||||
try {
|
// If we received a GroupV2 message in a GroupV1 group, we migrate!
|
||||||
await window.Signal.Groups.maybeUpdateGroup({
|
|
||||||
|
const { revision, groupChange } = initialMessage.groupV2;
|
||||||
|
await window.Signal.Groups.respondToGroupV2Migration({
|
||||||
conversation,
|
conversation,
|
||||||
groupChangeBase64: groupChange,
|
groupChangeBase64: groupChange,
|
||||||
newRevision: revision,
|
newRevision: revision,
|
||||||
receivedAt: message.get('received_at'),
|
receivedAt: message.get('received_at'),
|
||||||
sentAt: message.get('sent_at'),
|
sentAt: message.get('sent_at'),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} else if (
|
||||||
const errorText = error && error.stack ? error.stack : error;
|
initialMessage.groupV2.masterKey &&
|
||||||
window.log.error(
|
initialMessage.groupV2.secretParams &&
|
||||||
`handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}`
|
initialMessage.groupV2.publicParams
|
||||||
);
|
) {
|
||||||
throw error;
|
// 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,
|
e164: source,
|
||||||
uuid: sourceUuid,
|
uuid: sourceUuid,
|
||||||
})!;
|
})!;
|
||||||
|
const isGroupV2 = Boolean(initialMessage.groupV2);
|
||||||
const isV1GroupUpdate =
|
const isV1GroupUpdate =
|
||||||
initialMessage.group &&
|
initialMessage.group &&
|
||||||
initialMessage.group.type !==
|
initialMessage.group.type !==
|
||||||
|
@ -2949,6 +2998,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return;
|
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
|
// Send delivery receipts, but only for incoming sealed sender messages
|
||||||
// and not for messages from unaccepted conversations
|
// and not for messages from unaccepted conversations
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -16,7 +16,11 @@ import {
|
||||||
GroupV2RecordClass,
|
GroupV2RecordClass,
|
||||||
PinnedConversationClass,
|
PinnedConversationClass,
|
||||||
} from '../textsecure.d';
|
} from '../textsecure.d';
|
||||||
import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups';
|
import {
|
||||||
|
deriveGroupFields,
|
||||||
|
waitThenMaybeUpdateGroup,
|
||||||
|
waitThenRespondToGroupV2Migration,
|
||||||
|
} from '../groups';
|
||||||
import { ConversationModel } from '../models/conversations';
|
import { ConversationModel } from '../models/conversations';
|
||||||
import { ConversationAttributesTypeType } from '../model-types.d';
|
import { ConversationAttributesTypeType } from '../model-types.d';
|
||||||
|
|
||||||
|
@ -414,6 +418,53 @@ export async function mergeGroupV1Record(
|
||||||
return hasPendingChanges;
|
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(
|
export async function mergeGroupV2Record(
|
||||||
storageID: string,
|
storageID: string,
|
||||||
groupV2Record: GroupV2RecordClass
|
groupV2Record: GroupV2RecordClass
|
||||||
|
@ -423,36 +474,7 @@ export async function mergeGroupV2Record(
|
||||||
}
|
}
|
||||||
|
|
||||||
const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer();
|
const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer();
|
||||||
const groupFields = deriveGroupFields(masterKeyBuffer);
|
const conversation = await getGroupV2Conversation(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,
|
|
||||||
});
|
|
||||||
|
|
||||||
conversation.set({
|
conversation.set({
|
||||||
isArchived: Boolean(groupV2Record.archived),
|
isArchived: Boolean(groupV2Record.archived),
|
||||||
|
@ -476,10 +498,22 @@ export async function mergeGroupV2Record(
|
||||||
const isFirstSync = !window.storage.get('storageFetchComplete');
|
const isFirstSync = !window.storage.get('storageFetchComplete');
|
||||||
const dropInitialJoinMessage = isFirstSync;
|
const dropInitialJoinMessage = isFirstSync;
|
||||||
|
|
||||||
// We don't need to update GroupV2 groups all the time. We fetch group state the first
|
if (conversation.isGroupV1()) {
|
||||||
// time we hear about these groups, from then on we rely on incoming messages or
|
// If we found a GroupV1 conversation from this incoming GroupV2 record, we need to
|
||||||
// the user opening that conversation.
|
// migrate it!
|
||||||
if (isGroupNewToUs) {
|
|
||||||
|
// 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({
|
waitThenMaybeUpdateGroup({
|
||||||
conversation,
|
conversation,
|
||||||
dropInitialJoinMessage,
|
dropInitialJoinMessage,
|
||||||
|
|
|
@ -2784,7 +2784,7 @@ async function getLastConversationActivity(
|
||||||
const row = await db.get(
|
const row = await db.get(
|
||||||
`SELECT * FROM messages WHERE
|
`SELECT * FROM messages WHERE
|
||||||
conversationId = $conversationId AND
|
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)
|
(json_extract(json, '$.expirationTimerUpdate.fromSync') IS NULL OR json_extract(json, '$.expirationTimerUpdate.fromSync') != 1)
|
||||||
ORDER BY received_at DESC
|
ORDER BY received_at DESC
|
||||||
LIMIT 1;`,
|
LIMIT 1;`,
|
||||||
|
@ -2806,7 +2806,7 @@ async function getLastConversationPreview(
|
||||||
const row = await db.get(
|
const row = await db.get(
|
||||||
`SELECT * FROM messages WHERE
|
`SELECT * FROM messages WHERE
|
||||||
conversationId = $conversationId AND
|
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
|
ORDER BY received_at DESC
|
||||||
LIMIT 1;`,
|
LIMIT 1;`,
|
||||||
{
|
{
|
||||||
|
|
|
@ -65,7 +65,7 @@ export type ConversationType = {
|
||||||
text: string;
|
text: string;
|
||||||
deletedForEveryone?: boolean;
|
deletedForEveryone?: boolean;
|
||||||
};
|
};
|
||||||
markedUnread: boolean;
|
markedUnread?: boolean;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
membersCount?: number;
|
membersCount?: number;
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
|
@ -73,7 +73,7 @@ export type ConversationType = {
|
||||||
muteExpiresAt?: number;
|
muteExpiresAt?: number;
|
||||||
type: ConversationTypeType;
|
type: ConversationTypeType;
|
||||||
isMe?: boolean;
|
isMe?: boolean;
|
||||||
lastUpdated: number;
|
lastUpdated?: number;
|
||||||
title: string;
|
title: string;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
isSelected?: boolean;
|
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 { WebAPIType } from './textsecure/WebAPI';
|
||||||
import utils from './textsecure/Helpers';
|
import utils from './textsecure/Helpers';
|
||||||
import { CallingMessage as CallingMessageClass } from 'ringrtc';
|
import { CallingMessage as CallingMessageClass } from 'ringrtc';
|
||||||
|
import { WhatIsThis } from './window.d';
|
||||||
|
|
||||||
type AttachmentType = any;
|
type AttachmentType = any;
|
||||||
|
|
||||||
|
@ -256,6 +257,8 @@ export declare class MemberClass {
|
||||||
profileKey?: ProtoBinaryType;
|
profileKey?: ProtoBinaryType;
|
||||||
presentation?: ProtoBinaryType;
|
presentation?: ProtoBinaryType;
|
||||||
joinedAtVersion?: number;
|
joinedAtVersion?: number;
|
||||||
|
|
||||||
|
// Note: only role and presentation are required when creating a group
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemberRoleEnum = number;
|
type MemberRoleEnum = number;
|
||||||
|
@ -719,6 +722,9 @@ export declare class GroupContextClass {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
membersE164?: Array<string>;
|
membersE164?: Array<string>;
|
||||||
avatar?: AttachmentPointerClass | null;
|
avatar?: AttachmentPointerClass | null;
|
||||||
|
|
||||||
|
// Note: these additional properties are added in the course of processing
|
||||||
|
derivedGroupV2Id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare class GroupContextV2Class {
|
export declare class GroupContextV2Class {
|
||||||
|
|
|
@ -23,6 +23,7 @@ import WebSocketResource, {
|
||||||
IncomingWebSocketRequest,
|
IncomingWebSocketRequest,
|
||||||
} from './WebsocketResources';
|
} from './WebsocketResources';
|
||||||
import Crypto from './Crypto';
|
import Crypto from './Crypto';
|
||||||
|
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
|
||||||
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
||||||
import { IncomingIdentityKeyError } from './Errors';
|
import { IncomingIdentityKeyError } from './Errors';
|
||||||
|
|
||||||
|
@ -43,6 +44,8 @@ import { WebSocket } from './WebSocket';
|
||||||
|
|
||||||
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
|
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
|
||||||
|
|
||||||
|
const GROUPV1_ID_LENGTH = 16;
|
||||||
|
const GROUPV2_ID_LENGTH = 32;
|
||||||
const RETRY_TIMEOUT = 2 * 60 * 1000;
|
const RETRY_TIMEOUT = 2 * 60 * 1000;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -58,6 +61,7 @@ declare global {
|
||||||
eventType?: string | number;
|
eventType?: string | number;
|
||||||
groupDetails?: any;
|
groupDetails?: any;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
groupV2Id?: string;
|
||||||
messageRequestResponseType?: number | null;
|
messageRequestResponseType?: number | null;
|
||||||
proto?: any;
|
proto?: any;
|
||||||
read?: any;
|
read?: any;
|
||||||
|
@ -273,6 +277,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
delete this.socket.onclose;
|
delete this.socket.onclose;
|
||||||
delete this.socket.onerror;
|
delete this.socket.onerror;
|
||||||
delete this.socket.onopen;
|
delete this.socket.onopen;
|
||||||
|
|
||||||
this.socket = undefined;
|
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 (
|
if (
|
||||||
msg.flags &&
|
msg.flags &&
|
||||||
|
@ -1377,7 +1388,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
return Promise.all(results);
|
return Promise.all(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTypingMessage(
|
async handleTypingMessage(
|
||||||
envelope: EnvelopeClass,
|
envelope: EnvelopeClass,
|
||||||
typingMessage: TypingMessageClass
|
typingMessage: TypingMessageClass
|
||||||
) {
|
) {
|
||||||
|
@ -1403,25 +1414,29 @@ class MessageReceiverInner extends EventTarget {
|
||||||
ev.senderUuid = envelope.sourceUuid;
|
ev.senderUuid = envelope.sourceUuid;
|
||||||
ev.senderDevice = envelope.sourceDevice;
|
ev.senderDevice = envelope.sourceDevice;
|
||||||
|
|
||||||
const groupIdBuffer = groupId ? groupId.toArrayBuffer() : null;
|
|
||||||
|
|
||||||
ev.typing = {
|
ev.typing = {
|
||||||
typingMessage,
|
typingMessage,
|
||||||
timestamp: timestamp ? timestamp.toNumber() : Date.now(),
|
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:
|
started:
|
||||||
action === window.textsecure.protobuf.TypingMessage.Action.STARTED,
|
action === window.textsecure.protobuf.TypingMessage.Action.STARTED,
|
||||||
stopped:
|
stopped:
|
||||||
action === window.textsecure.protobuf.TypingMessage.Action.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);
|
return this.dispatchEvent(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1430,7 +1445,76 @@ class MessageReceiverInner extends EventTarget {
|
||||||
this.removeFromCache(envelope);
|
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;
|
const { groupV2 } = message;
|
||||||
|
|
||||||
if (!groupV2) {
|
if (!groupV2) {
|
||||||
|
@ -1438,10 +1522,10 @@ class MessageReceiverInner extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNumber(groupV2.revision)) {
|
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) {
|
if (!groupV2.masterKey) {
|
||||||
throw new Error('deriveGroupsV2Data: had falsey masterKey');
|
throw new Error('deriveGroupV2Data: had falsey masterKey');
|
||||||
}
|
}
|
||||||
|
|
||||||
const toBase64 = MessageReceiverInner.arrayBufferToStringBase64;
|
const toBase64 = MessageReceiverInner.arrayBufferToStringBase64;
|
||||||
|
@ -1449,7 +1533,7 @@ class MessageReceiverInner extends EventTarget {
|
||||||
const length = masterKey.byteLength;
|
const length = masterKey.byteLength;
|
||||||
if (length !== MASTER_KEY_LENGTH) {
|
if (length !== MASTER_KEY_LENGTH) {
|
||||||
throw new Error(
|
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(
|
window.log.info(
|
||||||
'sent message to',
|
'sent message to',
|
||||||
|
@ -1630,14 +1720,32 @@ class MessageReceiverInner extends EventTarget {
|
||||||
ev.confirm = this.removeFromCache.bind(this, envelope);
|
ev.confirm = this.removeFromCache.bind(this, envelope);
|
||||||
ev.threadE164 = sync.threadE164;
|
ev.threadE164 = sync.threadE164;
|
||||||
ev.threadUuid = sync.threadUuid;
|
ev.threadUuid = sync.threadUuid;
|
||||||
ev.groupId = sync.groupId ? sync.groupId.toString('binary') : null;
|
|
||||||
ev.messageRequestResponseType = sync.type;
|
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(
|
window.normalizeUuids(
|
||||||
ev,
|
ev,
|
||||||
['threadUuid'],
|
['threadUuid'],
|
||||||
'MessageReceiver::handleMessageRequestResponse'
|
'MessageReceiver::handleMessageRequestResponse'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return this.dispatchAndWait(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleFetchLatest(
|
async handleFetchLatest(
|
||||||
|
|
|
@ -1705,6 +1705,20 @@ export default class MessageSender {
|
||||||
return this.sendMessage(attrs, options);
|
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> {
|
async getGroup(options: GroupCredentialsType): Promise<GroupClass> {
|
||||||
return this.server.getGroup(options);
|
return this.server.getGroup(options);
|
||||||
}
|
}
|
||||||
|
|
|
@ -620,7 +620,7 @@ const URL_CALLS = {
|
||||||
devices: 'v1/devices',
|
devices: 'v1/devices',
|
||||||
directoryAuth: 'v1/directory/auth',
|
directoryAuth: 'v1/directory/auth',
|
||||||
discovery: 'v1/discovery',
|
discovery: 'v1/discovery',
|
||||||
getGroupAvatarUpload: '/v1/groups/avatar/form',
|
getGroupAvatarUpload: 'v1/groups/avatar/form',
|
||||||
getGroupCredentials: 'v1/certificate/group',
|
getGroupCredentials: 'v1/certificate/group',
|
||||||
getIceServers: 'v1/accounts/turn',
|
getIceServers: 'v1/accounts/turn',
|
||||||
getStickerPackUpload: 'v1/sticker/pack/form',
|
getStickerPackUpload: 'v1/sticker/pack/form',
|
||||||
|
@ -689,6 +689,15 @@ export type WebAPIConnectType = {
|
||||||
connect: (options: ConnectParametersType) => WebAPIType;
|
connect: (options: ConnectParametersType) => WebAPIType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CapabilitiesType = {
|
||||||
|
gv2: boolean;
|
||||||
|
'gv1-migration': boolean;
|
||||||
|
};
|
||||||
|
export type CapabilitiesUploadType = {
|
||||||
|
'gv2-3': boolean;
|
||||||
|
'gv1-migration': boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type StickerPackManifestType = any;
|
type StickerPackManifestType = any;
|
||||||
|
|
||||||
export type GroupCredentialType = {
|
export type GroupCredentialType = {
|
||||||
|
@ -796,7 +805,7 @@ export type WebAPIType = {
|
||||||
) => Promise<GroupChangeClass>;
|
) => Promise<GroupChangeClass>;
|
||||||
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
||||||
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
||||||
registerCapabilities: (capabilities: Dictionary<boolean>) => Promise<void>;
|
registerCapabilities: (capabilities: CapabilitiesUploadType) => Promise<void>;
|
||||||
putStickers: (
|
putStickers: (
|
||||||
encryptedManifest: ArrayBuffer,
|
encryptedManifest: ArrayBuffer,
|
||||||
encryptedStickers: Array<ArrayBuffer>,
|
encryptedStickers: Array<ArrayBuffer>,
|
||||||
|
@ -1154,7 +1163,7 @@ export function initialize({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerCapabilities(capabilities: Dictionary<boolean>) {
|
async function registerCapabilities(capabilities: CapabilitiesUploadType) {
|
||||||
return _ajax({
|
return _ajax({
|
||||||
call: 'registerCapabilities',
|
call: 'registerCapabilities',
|
||||||
httpType: 'PUT',
|
httpType: 'PUT',
|
||||||
|
@ -1280,11 +1289,14 @@ export function initialize({
|
||||||
deviceName?: string | null,
|
deviceName?: string | null,
|
||||||
options: { accessKey?: ArrayBuffer } = {}
|
options: { accessKey?: ArrayBuffer } = {}
|
||||||
) {
|
) {
|
||||||
|
const capabilities: CapabilitiesUploadType = {
|
||||||
|
'gv2-3': true,
|
||||||
|
'gv1-migration': true,
|
||||||
|
};
|
||||||
|
|
||||||
const { accessKey } = options;
|
const { accessKey } = options;
|
||||||
const jsonData: any = {
|
const jsonData: any = {
|
||||||
capabilities: {
|
capabilities,
|
||||||
'gv2-3': true,
|
|
||||||
},
|
|
||||||
fetchesMessages: true,
|
fetchesMessages: true,
|
||||||
name: deviceName || undefined,
|
name: deviceName || undefined,
|
||||||
registrationId,
|
registrationId,
|
||||||
|
@ -2010,9 +2022,10 @@ export function initialize({
|
||||||
await _ajax({
|
await _ajax({
|
||||||
basicAuth,
|
basicAuth,
|
||||||
call: 'groups',
|
call: 'groups',
|
||||||
httpType: 'PUT',
|
contentType: 'application/x-protobuf',
|
||||||
data,
|
data,
|
||||||
host: storageUrl,
|
host: storageUrl,
|
||||||
|
httpType: 'PUT',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2027,10 +2040,10 @@ export function initialize({
|
||||||
const response: ArrayBuffer = await _ajax({
|
const response: ArrayBuffer = await _ajax({
|
||||||
basicAuth,
|
basicAuth,
|
||||||
call: 'groups',
|
call: 'groups',
|
||||||
httpType: 'GET',
|
|
||||||
contentType: 'application/x-protobuf',
|
contentType: 'application/x-protobuf',
|
||||||
responseType: 'arraybuffer',
|
|
||||||
host: storageUrl,
|
host: storageUrl,
|
||||||
|
httpType: 'GET',
|
||||||
|
responseType: 'arraybuffer',
|
||||||
});
|
});
|
||||||
|
|
||||||
return window.textsecure.protobuf.Group.decode(response);
|
return window.textsecure.protobuf.Group.decode(response);
|
||||||
|
@ -2049,11 +2062,11 @@ export function initialize({
|
||||||
const response: ArrayBuffer = await _ajax({
|
const response: ArrayBuffer = await _ajax({
|
||||||
basicAuth,
|
basicAuth,
|
||||||
call: 'groups',
|
call: 'groups',
|
||||||
httpType: 'PATCH',
|
|
||||||
data,
|
|
||||||
contentType: 'application/x-protobuf',
|
contentType: 'application/x-protobuf',
|
||||||
responseType: 'arraybuffer',
|
data,
|
||||||
host: storageUrl,
|
host: storageUrl,
|
||||||
|
httpType: 'PATCH',
|
||||||
|
responseType: 'arraybuffer',
|
||||||
});
|
});
|
||||||
|
|
||||||
return window.textsecure.protobuf.GroupChange.decode(response);
|
return window.textsecure.protobuf.GroupChange.decode(response);
|
||||||
|
@ -2071,11 +2084,11 @@ export function initialize({
|
||||||
const withDetails: ArrayBufferWithDetailsType = await _ajax({
|
const withDetails: ArrayBufferWithDetailsType = await _ajax({
|
||||||
basicAuth,
|
basicAuth,
|
||||||
call: 'groupLog',
|
call: 'groupLog',
|
||||||
urlParameters: `/${startVersion}`,
|
|
||||||
httpType: 'GET',
|
|
||||||
contentType: 'application/x-protobuf',
|
contentType: 'application/x-protobuf',
|
||||||
responseType: 'arraybufferwithdetails',
|
|
||||||
host: storageUrl,
|
host: storageUrl,
|
||||||
|
httpType: 'GET',
|
||||||
|
responseType: 'arraybufferwithdetails',
|
||||||
|
urlParameters: `/${startVersion}`,
|
||||||
});
|
});
|
||||||
const { data, response } = withDetails;
|
const { data, response } = withDetails;
|
||||||
const changes = window.textsecure.protobuf.GroupChanges.decode(data);
|
const changes = window.textsecure.protobuf.GroupChanges.decode(data);
|
||||||
|
|
|
@ -15164,7 +15164,7 @@
|
||||||
"rule": "jQuery-wrap(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "ts/textsecure/WebAPI.js",
|
"path": "ts/textsecure/WebAPI.js",
|
||||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
|
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
|
||||||
"lineNumber": 1260,
|
"lineNumber": 1263,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"updated": "2020-09-08T23:07:22.682Z"
|
||||||
},
|
},
|
||||||
|
@ -15172,8 +15172,8 @@
|
||||||
"rule": "jQuery-wrap(",
|
"rule": "jQuery-wrap(",
|
||||||
"path": "ts/textsecure/WebAPI.ts",
|
"path": "ts/textsecure/WebAPI.ts",
|
||||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
|
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
|
||||||
"lineNumber": 2158,
|
"lineNumber": 2171,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"updated": "2020-09-08T23:07:22.682Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
|
@ -369,6 +369,9 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
this.model.fetchLatestGroupV2Data.bind(this.model),
|
this.model.fetchLatestGroupV2Data.bind(this.model),
|
||||||
FIVE_MINUTES
|
FIVE_MINUTES
|
||||||
);
|
);
|
||||||
|
this.model.throttledMaybeMigrateV1Group =
|
||||||
|
this.model.throttledMaybeMigrateV1Group ||
|
||||||
|
_.throttle(this.model.maybeMigrateV1Group.bind(this.model), FIVE_MINUTES);
|
||||||
|
|
||||||
this.debouncedMaybeGrabLinkPreview = _.debounce(
|
this.debouncedMaybeGrabLinkPreview = _.debounce(
|
||||||
this.maybeGrabLinkPreview.bind(this),
|
this.maybeGrabLinkPreview.bind(this),
|
||||||
|
@ -2035,6 +2038,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
this.model.throttledFetchLatestGroupV2Data();
|
this.model.throttledFetchLatestGroupV2Data();
|
||||||
|
this.model.throttledMaybeMigrateV1Group();
|
||||||
|
|
||||||
const statusPromise = this.model.throttledGetProfiles();
|
const statusPromise = this.model.throttledGetProfiles();
|
||||||
// eslint-disable-next-line more/no-then
|
// 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 TaskResultType = any;
|
||||||
|
|
||||||
type WhatIsThis = any;
|
export type WhatIsThis = any;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
Loading…
Add table
Reference in a new issue