Support for joining New Groups via invite links
This commit is contained in:
parent
c0510b08a5
commit
a48b3e381e
41 changed files with 2532 additions and 381 deletions
|
@ -3246,6 +3246,114 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"unknown-sgnl-link": {
|
||||||
|
"message": "Sorry, that sgnl:// link didn't make sense!",
|
||||||
|
"description": "Shown if you click on a sgnl:// link not currently supported by Desktop"
|
||||||
|
},
|
||||||
|
|
||||||
|
"GroupV2--join--invalid-link--title": {
|
||||||
|
"message": "Invalid Link",
|
||||||
|
"description": "Shown if we are unable to parse a group link"
|
||||||
|
},
|
||||||
|
"GroupV2--join--invalid-link": {
|
||||||
|
"message": "This is not a valid group link. Make sure the entire link is intact and correct before attempting to join.",
|
||||||
|
"description": "Shown if we are unable to parse a group link"
|
||||||
|
},
|
||||||
|
"GroupV2--join--prompt": {
|
||||||
|
"message": "Do you want to join this group and share your name and photo with its members?",
|
||||||
|
"description": "Shown when you click on a group link to confirm"
|
||||||
|
},
|
||||||
|
"GroupV2--join--already-in-group": {
|
||||||
|
"message": "You're already in this group.",
|
||||||
|
"description": "Shown if you click a group link for a group where you're already a member"
|
||||||
|
},
|
||||||
|
"GroupV2--join--already-awaiting-approval": {
|
||||||
|
"message": "You have already requested approval to join this group.",
|
||||||
|
"description": "Shown if you click a group link for a group where you've already requested approval'"
|
||||||
|
},
|
||||||
|
|
||||||
|
"GroupV2--join--unknown-link-version--title": {
|
||||||
|
"message": "Unknown link version",
|
||||||
|
"description": "This group link is no longer valid."
|
||||||
|
},
|
||||||
|
"GroupV2--join--unknown-link-version": {
|
||||||
|
"message": "This link is not supported by this version of Signal Desktop.",
|
||||||
|
"description": "Shown if you click a group link and we can't get information about it"
|
||||||
|
},
|
||||||
|
"GroupV2--join--link-revoked--title": {
|
||||||
|
"message": "Can’t Join Group",
|
||||||
|
"description": "Shown if you click a group link and we can't get information about it"
|
||||||
|
},
|
||||||
|
"GroupV2--join--link-revoked": {
|
||||||
|
"message": "This group link is no longer valid.",
|
||||||
|
"description": "Shown if you click a group link and we can't get information about it"
|
||||||
|
},
|
||||||
|
"GroupV2--join--prompt-with-approval": {
|
||||||
|
"message": "An admin of this group must approve your request before you can join this group. If approved, your name and photo will be shared with its members.",
|
||||||
|
"description": "Shown when you click on a group link to confirm, if it requires admin approval"
|
||||||
|
},
|
||||||
|
"GroupV2--join--join-button": {
|
||||||
|
"message": "Join",
|
||||||
|
"description": "The button to join the group"
|
||||||
|
},
|
||||||
|
"GroupV2--join--request-to-join-button": {
|
||||||
|
"message": "Request to Join",
|
||||||
|
"description": "The button to join the group, if approval is required"
|
||||||
|
},
|
||||||
|
"GroupV2--join--cancel-request-to-join": {
|
||||||
|
"message": "Cancel Request",
|
||||||
|
"description": "The button to cancel request to join the group"
|
||||||
|
},
|
||||||
|
"GroupV2--join--cancel-request-to-join--confirmation": {
|
||||||
|
"message": "Cancel your request to join this group?",
|
||||||
|
"description": "A confirmation message that shows after you click the button"
|
||||||
|
},
|
||||||
|
"GroupV2--join--cancel-request-to-join--yes": {
|
||||||
|
"message": "Yes",
|
||||||
|
"description": "Choosing to continue in the cancel join confirmation dialog"
|
||||||
|
},
|
||||||
|
"GroupV2--join--cancel-request-to-join--no": {
|
||||||
|
"message": "No",
|
||||||
|
"description": "Choosing not to continue in the cancel join confirmation dialog"
|
||||||
|
},
|
||||||
|
"GroupV2--join--member-count--single": {
|
||||||
|
"message": "1 member",
|
||||||
|
"description": "Shown in the metadata section if group has just one member"
|
||||||
|
},
|
||||||
|
"GroupV2--join--member-count--multiple": {
|
||||||
|
"message": "$count$ members",
|
||||||
|
"description": "Shown in the metadata section if group has more than one member",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GroupV2--join--group-metadata": {
|
||||||
|
"message": "Group · $memberCount$",
|
||||||
|
"description": "A holder for two pieces of information - the type of conversation, and the member count",
|
||||||
|
"placeholders": {
|
||||||
|
"memberCount": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "12 members"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GroupV2--join--requested": {
|
||||||
|
"message": "Your request to join has been sent to the group admin. You’ll be notified when they take action.",
|
||||||
|
"description": "Shown in composition area when you've requested to join a group"
|
||||||
|
},
|
||||||
|
|
||||||
|
"GroupV2--join--general-join-failure--title": {
|
||||||
|
"message": "Link Error",
|
||||||
|
"description": "Shown if something went wrong when you try to join via a group link"
|
||||||
|
},
|
||||||
|
"GroupV2--join--general-join-failure": {
|
||||||
|
"message": "Joining via this link failed. Try joining again later.",
|
||||||
|
"description": "Shown if something went wrong when you try to join via a group link"
|
||||||
|
},
|
||||||
|
|
||||||
"GroupV2--admin": {
|
"GroupV2--admin": {
|
||||||
"message": "Admin",
|
"message": "Admin",
|
||||||
"description": "Label for a group administrator"
|
"description": "Label for a group administrator"
|
||||||
|
|
|
@ -79,6 +79,9 @@ const {
|
||||||
const {
|
const {
|
||||||
createGroupV1MigrationModal,
|
createGroupV1MigrationModal,
|
||||||
} = require('../../ts/state/roots/createGroupV1MigrationModal');
|
} = require('../../ts/state/roots/createGroupV1MigrationModal');
|
||||||
|
const {
|
||||||
|
createGroupV2JoinModal,
|
||||||
|
} = require('../../ts/state/roots/createGroupV2JoinModal');
|
||||||
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
|
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
|
||||||
const {
|
const {
|
||||||
createGroupV2Permissions,
|
createGroupV2Permissions,
|
||||||
|
@ -340,6 +343,7 @@ exports.setup = (options = {}) => {
|
||||||
createConversationHeader,
|
createConversationHeader,
|
||||||
createGroupLinkManagement,
|
createGroupLinkManagement,
|
||||||
createGroupV1MigrationModal,
|
createGroupV1MigrationModal,
|
||||||
|
createGroupV2JoinModal,
|
||||||
createGroupV2Permissions,
|
createGroupV2Permissions,
|
||||||
createLeftPane,
|
createLeftPane,
|
||||||
createPendingInvites,
|
createPendingInvites,
|
||||||
|
|
13
main.js
13
main.js
|
@ -1428,7 +1428,7 @@ function getIncomingHref(argv) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSgnlHref(incomingHref) {
|
function handleSgnlHref(incomingHref) {
|
||||||
const { command, args } = parseSgnlHref(incomingHref, logger);
|
const { command, args, hash } = parseSgnlHref(incomingHref, logger);
|
||||||
if (command === 'addstickers' && mainWindow && mainWindow.webContents) {
|
if (command === 'addstickers' && mainWindow && mainWindow.webContents) {
|
||||||
console.log('Opening sticker pack from sgnl protocol link');
|
console.log('Opening sticker pack from sgnl protocol link');
|
||||||
const packId = args.get('pack_id');
|
const packId = args.get('pack_id');
|
||||||
|
@ -1437,6 +1437,17 @@ function handleSgnlHref(incomingHref) {
|
||||||
? Buffer.from(packKeyHex, 'hex').toString('base64')
|
? Buffer.from(packKeyHex, 'hex').toString('base64')
|
||||||
: '';
|
: '';
|
||||||
mainWindow.webContents.send('show-sticker-pack', { packId, packKey });
|
mainWindow.webContents.send('show-sticker-pack', { packId, packKey });
|
||||||
|
} else if (
|
||||||
|
command === 'signal.group' &&
|
||||||
|
hash &&
|
||||||
|
mainWindow &&
|
||||||
|
mainWindow.webContents
|
||||||
|
) {
|
||||||
|
console.log('Showing group from sgnl protocol link');
|
||||||
|
mainWindow.webContents.send('show-group-via-link', { hash });
|
||||||
|
} else if (mainWindow && mainWindow.webContents) {
|
||||||
|
console.log('Showing warning that we cannot process link');
|
||||||
|
mainWindow.webContents.send('unknown-sgnl-link');
|
||||||
} else {
|
} else {
|
||||||
console.error('Unhandled sgnl link');
|
console.error('Unhandled sgnl link');
|
||||||
}
|
}
|
||||||
|
|
15
preload.js
15
preload.js
|
@ -327,6 +327,21 @@ try {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.on('show-group-via-link', (_event, info) => {
|
||||||
|
const { hash } = info;
|
||||||
|
const { showGroupViaLink } = window.Events;
|
||||||
|
if (showGroupViaLink) {
|
||||||
|
showGroupViaLink(hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipc.on('unknown-sgnl-link', () => {
|
||||||
|
const { unknownSignalLink } = window.Events;
|
||||||
|
if (unknownSignalLink) {
|
||||||
|
unknownSignalLink();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipc.on('install-sticker-pack', (_event, info) => {
|
ipc.on('install-sticker-pack', (_event, info) => {
|
||||||
const { packId, packKey } = info;
|
const { packId, packKey } = info;
|
||||||
const { installStickerPack } = window.Events;
|
const { installStickerPack } = window.Events;
|
||||||
|
|
|
@ -206,3 +206,13 @@ message GroupInviteLink {
|
||||||
GroupInviteLinkContentsV1 v1Contents = 1;
|
GroupInviteLinkContentsV1 v1Contents = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GroupJoinInfo {
|
||||||
|
bytes publicKey = 1;
|
||||||
|
bytes title = 2;
|
||||||
|
string avatar = 3;
|
||||||
|
uint32 memberCount = 4;
|
||||||
|
AccessControl.AccessRequired addFromInviteLink = 5;
|
||||||
|
uint32 version = 6;
|
||||||
|
bool pendingAdminApproval = 7;
|
||||||
|
}
|
||||||
|
|
|
@ -4762,6 +4762,10 @@ button.module-conversation-details__action-button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-avatar__spinner-container {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-avatar--signal-blue {
|
.module-avatar--signal-blue {
|
||||||
background-color: $ultramarine-ui-light;
|
background-color: $ultramarine-ui-light;
|
||||||
}
|
}
|
||||||
|
@ -5846,6 +5850,9 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--on-avatar {
|
||||||
|
background-color: $color-white-alpha-40;
|
||||||
|
}
|
||||||
.module-spinner__circle--on-background {
|
.module-spinner__circle--on-background {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
background-color: $color-gray-05;
|
background-color: $color-gray-05;
|
||||||
|
@ -5874,6 +5881,9 @@ button.module-image__border-overlay:focus {
|
||||||
.module-spinner__arc--on-progress-dialog {
|
.module-spinner__arc--on-progress-dialog {
|
||||||
background-color: $ultramarine-ui-light;
|
background-color: $ultramarine-ui-light;
|
||||||
}
|
}
|
||||||
|
.module-spinner__arc--on-avatar {
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Highlighted Message Body
|
// Module: Highlighted Message Body
|
||||||
|
|
||||||
|
@ -10576,6 +10586,60 @@ button.module-image__border-overlay:focus {
|
||||||
@include button-primary;
|
@include button-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module: GroupV2 Pending Approval Actions
|
||||||
|
|
||||||
|
.module-group-v2-pending-approval-actions {
|
||||||
|
padding: 8px 16px 12px 16px;
|
||||||
|
max-width: 650px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-group-v2-pending-approval-actions__message {
|
||||||
|
@include font-body-2;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-gray-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-group-v2-pending-approval-actions__buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.module-group-v2-pending-approval-actions__buttons__button {
|
||||||
|
@include button-reset;
|
||||||
|
@include font-body-1-bold;
|
||||||
|
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
padding: 8px;
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
|
||||||
|
@include button-secondary;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-gray-60;
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Modal Host
|
// Module: Modal Host
|
||||||
|
|
||||||
.module-modal-host__overlay {
|
.module-modal-host__overlay {
|
||||||
|
@ -10735,6 +10799,99 @@ button.module-image__border-overlay:focus {
|
||||||
@include button-secondary;
|
@include button-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module: GroupV2 Join Dialog
|
||||||
|
|
||||||
|
.module-group-v2-join-dialog {
|
||||||
|
@include font-body-1;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 360px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.module-group-v2-join-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-join-dialog__title {
|
||||||
|
@include font-title-2;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.module-group-v2-join-dialog__avatar {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.module-group-v2-join-dialog__metadata {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.module-group-v2-join-dialog__prompt {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.module-group-v2-join-dialog__buttons {
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.module-group-v2-join-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: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
|
||||||
|
@include button-primary;
|
||||||
|
|
||||||
|
&:not(:first-of-type) {
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-group-v2-join-dialog__button--secondary {
|
||||||
|
@include button-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Progress Dialog
|
// Module: Progress Dialog
|
||||||
|
|
||||||
.module-progress-dialog {
|
.module-progress-dialog {
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { debounce, reduce, uniq, without } from 'lodash';
|
import { debounce, reduce, uniq, without } from 'lodash';
|
||||||
|
import PQueue from 'p-queue';
|
||||||
|
|
||||||
import dataInterface from './sql/Client';
|
import dataInterface from './sql/Client';
|
||||||
import {
|
import {
|
||||||
ConversationModelCollectionType,
|
ConversationModelCollectionType,
|
||||||
|
@ -150,6 +152,11 @@ export class ConversationController {
|
||||||
return this._conversations.add(attributes);
|
return this._conversations.add(attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dangerouslyRemoveById(id: string): void {
|
||||||
|
this._conversations.remove(id);
|
||||||
|
this._conversations.resetLookups();
|
||||||
|
}
|
||||||
|
|
||||||
getOrCreate(
|
getOrCreate(
|
||||||
identifier: string | null,
|
identifier: string | null,
|
||||||
type: ConversationAttributesTypeType,
|
type: ConversationAttributesTypeType,
|
||||||
|
@ -283,6 +290,16 @@ export class ConversationController {
|
||||||
return this.ensureContactIds({ e164, uuid, highTrust: true });
|
return this.ensureContactIds({ e164, uuid, highTrust: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOurConversationIdOrThrow(): string {
|
||||||
|
const conversationId = this.getOurConversationId();
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error(
|
||||||
|
'getOurConversationIdOrThrow: Failed to fetch ourConversationId'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a UUID and/or an E164, resolves to a string representing the local
|
* Given a UUID and/or an E164, resolves to a string representing the local
|
||||||
* database id of the given contact. In high trust mode, it may create new contacts,
|
* database id of the given contact. In high trust mode, it may create new contacts,
|
||||||
|
@ -713,7 +730,30 @@ export class ConversationController {
|
||||||
ConversationCollection: window.Whisper.ConversationCollection,
|
ConversationCollection: window.Whisper.ConversationCollection,
|
||||||
});
|
});
|
||||||
|
|
||||||
this._conversations.add(collection.models);
|
// Get rid of temporary conversations
|
||||||
|
const temporaryConversations = collection.filter(conversation =>
|
||||||
|
Boolean(conversation.get('isTemporary'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (temporaryConversations.length) {
|
||||||
|
window.log.warn(
|
||||||
|
`ConversationController: Removing ${temporaryConversations.length} temporary conversations`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const queue = new PQueue({ concurrency: 3, timeout: 1000 * 60 * 2 });
|
||||||
|
queue.addAll(
|
||||||
|
temporaryConversations.map(item => async () => {
|
||||||
|
await removeConversation(item.id, {
|
||||||
|
Conversation: window.Whisper.Conversation,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await queue.onIdle();
|
||||||
|
|
||||||
|
// Hydrate the final set of conversations
|
||||||
|
this._conversations.add(
|
||||||
|
collection.filter(conversation => !conversation.get('isTemporary'))
|
||||||
|
);
|
||||||
|
|
||||||
this._initialFetchComplete = true;
|
this._initialFetchComplete = true;
|
||||||
|
|
||||||
|
@ -725,10 +765,6 @@ export class ConversationController {
|
||||||
updateConversation(conversation.attributes);
|
updateConversation(conversation.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!conversation.get('lastMessage')) {
|
|
||||||
await conversation.updateLastMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// In case a too-large draft was saved to the database
|
// In case a too-large draft was saved to the database
|
||||||
const draft = conversation.get('draft');
|
const draft = conversation.get('draft');
|
||||||
if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) {
|
if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) {
|
||||||
|
|
|
@ -427,11 +427,20 @@ type WhatIsThis = import('./window.d').WhatIsThis;
|
||||||
await window.Signal.Data.shutdown();
|
await window.Signal.Data.shutdown();
|
||||||
},
|
},
|
||||||
|
|
||||||
showStickerPack: async (packId: string, key: string) => {
|
showStickerPack: (packId: string, key: string) => {
|
||||||
// We can get these events even if the user has never linked this instance.
|
// We can get these events even if the user has never linked this instance.
|
||||||
if (!window.Signal.Util.Registration.everDone()) {
|
if (!window.Signal.Util.Registration.everDone()) {
|
||||||
|
window.log.warn('showStickerPack: Not registered, returning early');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (window.isShowingModal) {
|
||||||
|
window.log.warn(
|
||||||
|
'showStickerPack: Already showing modal, returning early'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.isShowingModal = true;
|
||||||
|
|
||||||
// Kick off the download
|
// Kick off the download
|
||||||
window.Signal.Stickers.downloadEphemeralPack(packId, key);
|
window.Signal.Stickers.downloadEphemeralPack(packId, key);
|
||||||
|
@ -439,6 +448,7 @@ type WhatIsThis = import('./window.d').WhatIsThis;
|
||||||
const props = {
|
const props = {
|
||||||
packId,
|
packId,
|
||||||
onClose: async () => {
|
onClose: async () => {
|
||||||
|
window.isShowingModal = false;
|
||||||
stickerPreviewModalView.remove();
|
stickerPreviewModalView.remove();
|
||||||
await window.Signal.Stickers.removeEphemeralPack(packId);
|
await window.Signal.Stickers.removeEphemeralPack(packId);
|
||||||
},
|
},
|
||||||
|
@ -451,6 +461,69 @@ type WhatIsThis = import('./window.d').WhatIsThis;
|
||||||
props
|
props
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
window.isShowingModal = false;
|
||||||
|
window.log.error(
|
||||||
|
'showStickerPack: Ran into an error!',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
const errorView = new window.Whisper.ReactWrapperView({
|
||||||
|
className: 'error-modal-wrapper',
|
||||||
|
Component: window.Signal.Components.ErrorModal,
|
||||||
|
props: {
|
||||||
|
onClose: () => {
|
||||||
|
errorView.remove();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showGroupViaLink: async (hash: string) => {
|
||||||
|
// We can get these events even if the user has never linked this instance.
|
||||||
|
if (!window.Signal.Util.Registration.everDone()) {
|
||||||
|
window.log.warn('showGroupViaLink: Not registered, returning early');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.isShowingModal) {
|
||||||
|
window.log.warn(
|
||||||
|
'showGroupViaLink: Already showing modal, returning early'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await window.Signal.Groups.joinViaLink(hash);
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
'showGroupViaLink: Ran into an error!',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
const errorView = new window.Whisper.ReactWrapperView({
|
||||||
|
className: 'error-modal-wrapper',
|
||||||
|
Component: window.Signal.Components.ErrorModal,
|
||||||
|
props: {
|
||||||
|
title: window.i18n('GroupV2--join--general-join-failure--title'),
|
||||||
|
description: window.i18n('GroupV2--join--general-join-failure'),
|
||||||
|
onClose: () => {
|
||||||
|
errorView.remove();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.isShowingModal = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
unknownSignalLink: () => {
|
||||||
|
window.log.warn('unknownSignalLink: Showing error dialog');
|
||||||
|
const errorView = new window.Whisper.ReactWrapperView({
|
||||||
|
className: 'error-modal-wrapper',
|
||||||
|
Component: window.Signal.Components.ErrorModal,
|
||||||
|
props: {
|
||||||
|
description: window.i18n('unknown-sgnl-link'),
|
||||||
|
onClose: () => {
|
||||||
|
errorView.remove();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
installStickerPack: async (packId: string, key: string) => {
|
installStickerPack: async (packId: string, key: string) => {
|
||||||
|
|
|
@ -38,6 +38,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
overrideProps.conversationType || 'direct'
|
overrideProps.conversationType || 'direct'
|
||||||
),
|
),
|
||||||
i18n,
|
i18n,
|
||||||
|
loading: boolean('loading', overrideProps.loading || false),
|
||||||
name: text('name', overrideProps.name || ''),
|
name: text('name', overrideProps.name || ''),
|
||||||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||||
onClick: action('onClick'),
|
onClick: action('onClick'),
|
||||||
|
@ -46,7 +47,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const sizes: Array<Props['size']> = [112, 80, 52, 32, 28];
|
const sizes: Array<Props['size']> = [112, 96, 80, 52, 32, 28];
|
||||||
|
|
||||||
story.add('Avatar', () => {
|
story.add('Avatar', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
|
@ -124,3 +125,11 @@ story.add('Broken Avatar for Group', () => {
|
||||||
|
|
||||||
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
|
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
story.add('Loading', () => {
|
||||||
|
const props = createProps({
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
|
||||||
|
});
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { Spinner } from './Spinner';
|
||||||
|
|
||||||
import { getInitials } from '../util/getInitials';
|
import { getInitials } from '../util/getInitials';
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
import { ColorType } from '../types/Colors';
|
import { ColorType } from '../types/Colors';
|
||||||
|
@ -20,6 +22,7 @@ export enum AvatarSize {
|
||||||
export type Props = {
|
export type Props = {
|
||||||
avatarPath?: string;
|
avatarPath?: string;
|
||||||
color?: ColorType;
|
color?: ColorType;
|
||||||
|
loading?: boolean;
|
||||||
|
|
||||||
conversationType: 'group' | 'direct';
|
conversationType: 'group' | 'direct';
|
||||||
noteToSelf?: boolean;
|
noteToSelf?: boolean;
|
||||||
|
@ -136,11 +139,27 @@ export class Avatar extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public renderLoading(): JSX.Element {
|
||||||
|
const { size } = this.props;
|
||||||
|
const svgSize = size < 40 ? 'small' : 'normal';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="module-avatar__spinner-container">
|
||||||
|
<Spinner
|
||||||
|
size={`${size - 8}px`}
|
||||||
|
svgSize={svgSize}
|
||||||
|
direction="on-avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public render(): JSX.Element {
|
public render(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
avatarPath,
|
avatarPath,
|
||||||
color,
|
color,
|
||||||
innerRef,
|
innerRef,
|
||||||
|
loading,
|
||||||
noteToSelf,
|
noteToSelf,
|
||||||
onClick,
|
onClick,
|
||||||
size,
|
size,
|
||||||
|
@ -156,7 +175,9 @@ export class Avatar extends React.Component<Props, State> {
|
||||||
|
|
||||||
let contents;
|
let contents;
|
||||||
|
|
||||||
if (onClick) {
|
if (loading) {
|
||||||
|
contents = this.renderLoading();
|
||||||
|
} else if (onClick) {
|
||||||
contents = (
|
contents = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -71,6 +71,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
title: '',
|
title: '',
|
||||||
// GroupV1 Disabled Actions
|
// GroupV1 Disabled Actions
|
||||||
onStartGroupMigration: action('onStartGroupMigration'),
|
onStartGroupMigration: action('onStartGroupMigration'),
|
||||||
|
// GroupV2 Pending Approval Actions
|
||||||
|
onCancelJoinRequest: action('onCancelJoinRequest'),
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Default', () => {
|
story.add('Default', () => {
|
||||||
|
|
|
@ -22,6 +22,10 @@ import {
|
||||||
GroupV1DisabledActions,
|
GroupV1DisabledActions,
|
||||||
PropsType as GroupV1DisabledActionsPropsType,
|
PropsType as GroupV1DisabledActionsPropsType,
|
||||||
} from './conversation/GroupV1DisabledActions';
|
} from './conversation/GroupV1DisabledActions';
|
||||||
|
import {
|
||||||
|
GroupV2PendingApprovalActions,
|
||||||
|
PropsType as GroupV2PendingApprovalActionsPropsType,
|
||||||
|
} from './conversation/GroupV2PendingApprovalActions';
|
||||||
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
|
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
|
||||||
import { countStickers } from './stickers/lib';
|
import { countStickers } from './stickers/lib';
|
||||||
import { LocalizerType } from '../types/Util';
|
import { LocalizerType } from '../types/Util';
|
||||||
|
@ -30,6 +34,7 @@ import { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
readonly i18n: LocalizerType;
|
readonly i18n: LocalizerType;
|
||||||
readonly areWePending?: boolean;
|
readonly areWePending?: boolean;
|
||||||
|
readonly areWePendingApproval?: boolean;
|
||||||
readonly groupVersion?: 1 | 2;
|
readonly groupVersion?: 1 | 2;
|
||||||
readonly isGroupV1AndDisabled?: boolean;
|
readonly isGroupV1AndDisabled?: boolean;
|
||||||
readonly isMissingMandatoryProfileSharing?: boolean;
|
readonly isMissingMandatoryProfileSharing?: boolean;
|
||||||
|
@ -84,6 +89,7 @@ export type Props = Pick<
|
||||||
> &
|
> &
|
||||||
MessageRequestActionsProps &
|
MessageRequestActionsProps &
|
||||||
Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
|
Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
|
||||||
|
Pick<GroupV2PendingApprovalActionsPropsType, 'onCancelJoinRequest'> &
|
||||||
OwnProps;
|
OwnProps;
|
||||||
|
|
||||||
const emptyElement = (el: HTMLElement) => {
|
const emptyElement = (el: HTMLElement) => {
|
||||||
|
@ -128,6 +134,7 @@ export const CompositionArea = ({
|
||||||
// Message Requests
|
// Message Requests
|
||||||
acceptedMessageRequest,
|
acceptedMessageRequest,
|
||||||
areWePending,
|
areWePending,
|
||||||
|
areWePendingApproval,
|
||||||
conversationType,
|
conversationType,
|
||||||
groupVersion,
|
groupVersion,
|
||||||
isBlocked,
|
isBlocked,
|
||||||
|
@ -146,6 +153,8 @@ export const CompositionArea = ({
|
||||||
// GroupV1 Disabled Actions
|
// GroupV1 Disabled Actions
|
||||||
isGroupV1AndDisabled,
|
isGroupV1AndDisabled,
|
||||||
onStartGroupMigration,
|
onStartGroupMigration,
|
||||||
|
// GroupV2 Pending Approval Actions
|
||||||
|
onCancelJoinRequest,
|
||||||
}: Props): JSX.Element => {
|
}: Props): JSX.Element => {
|
||||||
const [disabled, setDisabled] = React.useState(false);
|
const [disabled, setDisabled] = React.useState(false);
|
||||||
const [showMic, setShowMic] = React.useState(!draftText);
|
const [showMic, setShowMic] = React.useState(!draftText);
|
||||||
|
@ -403,6 +412,15 @@ export const CompositionArea = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (areWePendingApproval) {
|
||||||
|
return (
|
||||||
|
<GroupV2PendingApprovalActions
|
||||||
|
i18n={i18n}
|
||||||
|
onCancelJoinRequest={onCancelJoinRequest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="module-composition-area">
|
<div className="module-composition-area">
|
||||||
<div className="module-composition-area__toggle-large">
|
<div className="module-composition-area__toggle-large">
|
||||||
|
|
|
@ -7,9 +7,9 @@ import { LocalizerType } from '../types/Util';
|
||||||
import { ConfirmationModal } from './ConfirmationModal';
|
import { ConfirmationModal } from './ConfirmationModal';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
buttonText: string;
|
buttonText?: string;
|
||||||
description: string;
|
description?: string;
|
||||||
title: string;
|
title?: string;
|
||||||
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
|
|
@ -8,12 +8,6 @@ import { ConversationType } from '../state/ducks/conversations';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { sortByTitle } from '../util/sortByTitle';
|
import { sortByTitle } from '../util/sortByTitle';
|
||||||
|
|
||||||
export type ActionSpec = {
|
|
||||||
text: string;
|
|
||||||
action: () => unknown;
|
|
||||||
style?: 'affirmative' | 'negative';
|
|
||||||
};
|
|
||||||
|
|
||||||
type CallbackType = () => unknown;
|
type CallbackType = () => unknown;
|
||||||
|
|
||||||
export type DataPropsType = {
|
export type DataPropsType = {
|
||||||
|
|
80
ts/components/GroupV2JoinDialog.stories.tsx
Normal file
80
ts/components/GroupV2JoinDialog.stories.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
import { boolean, number, text } from '@storybook/addon-knobs';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
import { GroupV2JoinDialog, PropsType } from './GroupV2JoinDialog';
|
||||||
|
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||||
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
|
|
||||||
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
|
memberCount: number('memberCount', overrideProps.memberCount || 12),
|
||||||
|
avatar: overrideProps.avatar,
|
||||||
|
title: text('title', overrideProps.title || 'Random Group!'),
|
||||||
|
approvalRequired: boolean(
|
||||||
|
'approvalRequired',
|
||||||
|
overrideProps.approvalRequired || false
|
||||||
|
),
|
||||||
|
join: action('join'),
|
||||||
|
onClose: action('onClose'),
|
||||||
|
i18n,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stories = storiesOf('Components/GroupV2JoinDialog', module);
|
||||||
|
|
||||||
|
stories.add('Basic', () => {
|
||||||
|
return <GroupV2JoinDialog {...createProps()} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
stories.add('Approval required', () => {
|
||||||
|
return (
|
||||||
|
<GroupV2JoinDialog
|
||||||
|
{...createProps({
|
||||||
|
approvalRequired: true,
|
||||||
|
title: 'Approval required!',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
stories.add('With avatar', () => {
|
||||||
|
return (
|
||||||
|
<GroupV2JoinDialog
|
||||||
|
{...createProps({
|
||||||
|
avatar: {
|
||||||
|
url: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
|
||||||
|
},
|
||||||
|
title: 'Has an avatar!',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
stories.add('With one member', () => {
|
||||||
|
return (
|
||||||
|
<GroupV2JoinDialog
|
||||||
|
{...createProps({
|
||||||
|
memberCount: 1,
|
||||||
|
title: 'Just one member!',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
stories.add('Avatar loading state', () => {
|
||||||
|
return (
|
||||||
|
<GroupV2JoinDialog
|
||||||
|
{...createProps({
|
||||||
|
avatar: {
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
title: 'Avatar loading!',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
120
ts/components/GroupV2JoinDialog.tsx
Normal file
120
ts/components/GroupV2JoinDialog.tsx
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
// Copyright 2021 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 { Avatar } from './Avatar';
|
||||||
|
import { Spinner } from './Spinner';
|
||||||
|
|
||||||
|
import { PreJoinConversationType } from '../state/ducks/conversations';
|
||||||
|
|
||||||
|
type CallbackType = () => unknown;
|
||||||
|
|
||||||
|
export type DataPropsType = PreJoinConversationType & {
|
||||||
|
readonly join: 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 GroupV2JoinDialog = React.memo((props: PropsType) => {
|
||||||
|
const [isWorking, setIsWorking] = React.useState(false);
|
||||||
|
const [isJoining, setIsJoining] = React.useState(false);
|
||||||
|
const {
|
||||||
|
approvalRequired,
|
||||||
|
avatar,
|
||||||
|
i18n,
|
||||||
|
join,
|
||||||
|
memberCount,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const joinString = approvalRequired
|
||||||
|
? i18n('GroupV2--join--request-to-join-button')
|
||||||
|
: i18n('GroupV2--join--join-button');
|
||||||
|
const promptString = approvalRequired
|
||||||
|
? i18n('GroupV2--join--prompt-with-approval')
|
||||||
|
: i18n('GroupV2--join--prompt');
|
||||||
|
const memberString =
|
||||||
|
memberCount === 1
|
||||||
|
? i18n('GroupV2--join--member-count--single')
|
||||||
|
: i18n('GroupV2--join--member-count--multiple', {
|
||||||
|
count: memberCount.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrappedJoin = React.useCallback(() => {
|
||||||
|
setIsWorking(true);
|
||||||
|
setIsJoining(true);
|
||||||
|
join();
|
||||||
|
}, [join, setIsJoining, setIsWorking]);
|
||||||
|
|
||||||
|
const wrappedClose = React.useCallback(() => {
|
||||||
|
setIsWorking(true);
|
||||||
|
onClose();
|
||||||
|
}, [onClose, setIsWorking]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="module-group-v2-join-dialog">
|
||||||
|
<button
|
||||||
|
aria-label={i18n('close')}
|
||||||
|
type="button"
|
||||||
|
disabled={isWorking}
|
||||||
|
className="module-group-v2-join-dialog__close-button"
|
||||||
|
onClick={wrappedClose}
|
||||||
|
/>
|
||||||
|
<div className="module-group-v2-join-dialog__avatar">
|
||||||
|
<Avatar
|
||||||
|
avatarPath={avatar ? avatar.url : undefined}
|
||||||
|
loading={avatar && !avatar.url}
|
||||||
|
conversationType="group"
|
||||||
|
title={title}
|
||||||
|
size={80}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="module-group-v2-join-dialog__title">{title}</div>
|
||||||
|
<div className="module-group-v2-join-dialog__metadata">
|
||||||
|
{i18n('GroupV2--join--group-metadata', [memberString])}
|
||||||
|
</div>
|
||||||
|
<div className="module-group-v2-join-dialog__prompt">{promptString}</div>
|
||||||
|
<div className="module-group-v2-join-dialog__buttons">
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
'module-group-v2-join-dialog__button',
|
||||||
|
'module-group-v2-join-dialog__button--secondary'
|
||||||
|
)}
|
||||||
|
disabled={isWorking}
|
||||||
|
type="button"
|
||||||
|
onClick={wrappedClose}
|
||||||
|
>
|
||||||
|
{i18n('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="module-group-v2-join-dialog__button"
|
||||||
|
disabled={isWorking}
|
||||||
|
ref={focusRef}
|
||||||
|
type="button"
|
||||||
|
onClick={wrappedJoin}
|
||||||
|
>
|
||||||
|
{isJoining ? (
|
||||||
|
<Spinner size="20px" svgSize="small" direction="on-avatar" />
|
||||||
|
) : (
|
||||||
|
joinString
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
|
@ -12,6 +12,7 @@ export const SpinnerDirections = [
|
||||||
'incoming',
|
'incoming',
|
||||||
'on-background',
|
'on-background',
|
||||||
'on-progress-dialog',
|
'on-progress-dialog',
|
||||||
|
'on-avatar',
|
||||||
] as const;
|
] as const;
|
||||||
export type SpinnerDirection = typeof SpinnerDirections[number];
|
export type SpinnerDirection = typeof SpinnerDirections[number];
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GroupV2PendingApprovalActions,
|
||||||
|
PropsType as GroupV2PendingApprovalActionsPropsType,
|
||||||
|
} from './GroupV2PendingApprovalActions';
|
||||||
|
import { setup as setupI18n } from '../../../js/modules/i18n';
|
||||||
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
|
|
||||||
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
const createProps = (): GroupV2PendingApprovalActionsPropsType => ({
|
||||||
|
i18n,
|
||||||
|
onCancelJoinRequest: action('onCancelJoinRequest'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stories = storiesOf(
|
||||||
|
'Components/Conversation/GroupV2PendingApprovalActions',
|
||||||
|
module
|
||||||
|
);
|
||||||
|
|
||||||
|
stories.add('Default', () => {
|
||||||
|
return <GroupV2PendingApprovalActions {...createProps()} />;
|
||||||
|
});
|
33
ts/components/conversation/GroupV2PendingApprovalActions.tsx
Normal file
33
ts/components/conversation/GroupV2PendingApprovalActions.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
|
export type PropsType = {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
onCancelJoinRequest: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GroupV2PendingApprovalActions = ({
|
||||||
|
i18n,
|
||||||
|
onCancelJoinRequest,
|
||||||
|
}: PropsType): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className="module-group-v2-pending-approval-actions">
|
||||||
|
<p className="module-group-v2-pending-approval-actions__message">
|
||||||
|
{i18n('GroupV2--join--requested')}
|
||||||
|
</p>
|
||||||
|
<div className="module-group-v2-pending-approval-actions__buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancelJoinRequest}
|
||||||
|
tabIndex={0}
|
||||||
|
className="module-group-v2-pending-approval-actions__buttons__button"
|
||||||
|
>
|
||||||
|
{i18n('GroupV2--join--cancel-request-to-join')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
608
ts/groups.ts
608
ts/groups.ts
|
@ -19,6 +19,7 @@ import {
|
||||||
maybeFetchNewCredentials,
|
maybeFetchNewCredentials,
|
||||||
} from './services/groupCredentialFetcher';
|
} from './services/groupCredentialFetcher';
|
||||||
import dataInterface from './sql/Client';
|
import dataInterface from './sql/Client';
|
||||||
|
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
|
||||||
import {
|
import {
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
GroupV2MemberType,
|
GroupV2MemberType,
|
||||||
|
@ -57,6 +58,7 @@ import {
|
||||||
GroupChangeClass,
|
GroupChangeClass,
|
||||||
GroupChangesClass,
|
GroupChangesClass,
|
||||||
GroupClass,
|
GroupClass,
|
||||||
|
GroupJoinInfoClass,
|
||||||
MemberClass,
|
MemberClass,
|
||||||
MemberPendingAdminApprovalClass,
|
MemberPendingAdminApprovalClass,
|
||||||
MemberPendingProfileKeyClass,
|
MemberPendingProfileKeyClass,
|
||||||
|
@ -71,6 +73,8 @@ import MessageSender, { CallbackResultType } from './textsecure/SendMessage';
|
||||||
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
||||||
import { ConversationModel } from './models/conversations';
|
import { ConversationModel } from './models/conversations';
|
||||||
|
|
||||||
|
export { joinViaLink } from './groups/joinViaLink';
|
||||||
|
|
||||||
export type GroupV2AccessCreateChangeType = {
|
export type GroupV2AccessCreateChangeType = {
|
||||||
type: 'create';
|
type: 'create';
|
||||||
};
|
};
|
||||||
|
@ -227,6 +231,7 @@ const TEMPORAL_AUTH_REJECTED_CODE = 401;
|
||||||
const GROUP_ACCESS_DENIED_CODE = 403;
|
const GROUP_ACCESS_DENIED_CODE = 403;
|
||||||
const GROUP_NONEXISTENT_CODE = 404;
|
const GROUP_NONEXISTENT_CODE = 404;
|
||||||
const SUPPORTED_CHANGE_EPOCH = 1;
|
const SUPPORTED_CHANGE_EPOCH = 1;
|
||||||
|
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
|
||||||
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
|
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
|
||||||
|
|
||||||
// Group Links
|
// Group Links
|
||||||
|
@ -235,8 +240,23 @@ export function generateGroupInviteLinkPassword(): ArrayBuffer {
|
||||||
return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
|
return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toWebSafeBase64(base64: string): string {
|
// Group Links
|
||||||
return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '');
|
|
||||||
|
export async function getPreJoinGroupInfo(
|
||||||
|
inviteLinkPasswordBase64: string,
|
||||||
|
masterKeyBase64: string
|
||||||
|
): Promise<GroupJoinInfoClass> {
|
||||||
|
const data = window.Signal.Groups.deriveGroupFields(
|
||||||
|
base64ToArrayBuffer(masterKeyBase64)
|
||||||
|
);
|
||||||
|
|
||||||
|
return makeRequestWithTemporalRetry({
|
||||||
|
logId: `groupv2(${data.id})`,
|
||||||
|
publicParams: arrayBufferToBase64(data.publicParams),
|
||||||
|
secretParams: arrayBufferToBase64(data.secretParams),
|
||||||
|
request: (sender, options) =>
|
||||||
|
sender.getGroupFromLink(inviteLinkPasswordBase64, options),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGroupLink(conversation: ConversationModel): string {
|
export function buildGroupLink(conversation: ConversationModel): string {
|
||||||
|
@ -257,6 +277,51 @@ export function buildGroupLink(conversation: ConversationModel): string {
|
||||||
return `sgnl://signal.group/#${hash}`;
|
return `sgnl://signal.group/#${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseGroupLink(
|
||||||
|
hash: string
|
||||||
|
): { masterKey: string; inviteLinkPassword: string } {
|
||||||
|
const base64 = fromWebSafeBase64(hash);
|
||||||
|
const buffer = base64ToArrayBuffer(base64);
|
||||||
|
|
||||||
|
const inviteLinkProto = window.textsecure.protobuf.GroupInviteLink.decode(
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
inviteLinkProto.contents !== 'v1Contents' ||
|
||||||
|
!inviteLinkProto.v1Contents
|
||||||
|
) {
|
||||||
|
const error = new Error(
|
||||||
|
'parseGroupLink: Parsed proto is missing v1Contents'
|
||||||
|
);
|
||||||
|
error.name = LINK_VERSION_ERROR;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasData(inviteLinkProto.v1Contents.groupMasterKey)) {
|
||||||
|
throw new Error('v1Contents.groupMasterKey had no data!');
|
||||||
|
}
|
||||||
|
if (!hasData(inviteLinkProto.v1Contents.inviteLinkPassword)) {
|
||||||
|
throw new Error('v1Contents.inviteLinkPassword had no data!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey: string = inviteLinkProto.v1Contents.groupMasterKey.toString(
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
if (masterKey.length !== 44) {
|
||||||
|
throw new Error(`masterKey had unexpected length ${masterKey.length}`);
|
||||||
|
}
|
||||||
|
const inviteLinkPassword: string = inviteLinkProto.v1Contents.inviteLinkPassword.toString(
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
if (inviteLinkPassword.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`inviteLinkPassword had unexpected length ${inviteLinkPassword.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { masterKey, inviteLinkPassword };
|
||||||
|
}
|
||||||
|
|
||||||
// Group Modifications
|
// Group Modifications
|
||||||
|
|
||||||
async function uploadAvatar({
|
async function uploadAvatar({
|
||||||
|
@ -596,6 +661,84 @@ export function buildDeletePendingAdminApprovalMemberChange({
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAddPendingAdminApprovalMemberChange({
|
||||||
|
group,
|
||||||
|
profileKeyCredentialBase64,
|
||||||
|
serverPublicParamsBase64,
|
||||||
|
}: {
|
||||||
|
group: ConversationAttributesType;
|
||||||
|
profileKeyCredentialBase64: string;
|
||||||
|
serverPublicParamsBase64: string;
|
||||||
|
}): GroupChangeClass.Actions {
|
||||||
|
const actions = new window.textsecure.protobuf.GroupChange.Actions();
|
||||||
|
|
||||||
|
if (!group.secretParams) {
|
||||||
|
throw new Error(
|
||||||
|
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const clientZkProfileCipher = getClientZkProfileOperations(
|
||||||
|
serverPublicParamsBase64
|
||||||
|
);
|
||||||
|
|
||||||
|
const addMemberPendingAdminApproval = new window.textsecure.protobuf.GroupChange.Actions.AddMemberPendingAdminApprovalAction();
|
||||||
|
const presentation = createProfileKeyCredentialPresentation(
|
||||||
|
clientZkProfileCipher,
|
||||||
|
profileKeyCredentialBase64,
|
||||||
|
group.secretParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = new window.textsecure.protobuf.MemberPendingAdminApproval();
|
||||||
|
added.presentation = presentation;
|
||||||
|
|
||||||
|
addMemberPendingAdminApproval.added = added;
|
||||||
|
|
||||||
|
actions.version = (group.revision || 0) + 1;
|
||||||
|
actions.addMemberPendingAdminApprovals = [addMemberPendingAdminApproval];
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAddMember({
|
||||||
|
group,
|
||||||
|
profileKeyCredentialBase64,
|
||||||
|
serverPublicParamsBase64,
|
||||||
|
}: {
|
||||||
|
group: ConversationAttributesType;
|
||||||
|
profileKeyCredentialBase64: string;
|
||||||
|
serverPublicParamsBase64: string;
|
||||||
|
joinFromInviteLink?: boolean;
|
||||||
|
}): GroupChangeClass.Actions {
|
||||||
|
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||||
|
|
||||||
|
const actions = new window.textsecure.protobuf.GroupChange.Actions();
|
||||||
|
|
||||||
|
if (!group.secretParams) {
|
||||||
|
throw new Error('buildAddMember: group was missing secretParams!');
|
||||||
|
}
|
||||||
|
const clientZkProfileCipher = getClientZkProfileOperations(
|
||||||
|
serverPublicParamsBase64
|
||||||
|
);
|
||||||
|
|
||||||
|
const addMember = new window.textsecure.protobuf.GroupChange.Actions.AddMemberAction();
|
||||||
|
const presentation = createProfileKeyCredentialPresentation(
|
||||||
|
clientZkProfileCipher,
|
||||||
|
profileKeyCredentialBase64,
|
||||||
|
group.secretParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const added = new window.textsecure.protobuf.Member();
|
||||||
|
added.presentation = presentation;
|
||||||
|
added.role = MEMBER_ROLE_ENUM.DEFAULT;
|
||||||
|
|
||||||
|
addMember.added = added;
|
||||||
|
|
||||||
|
actions.version = (group.revision || 0) + 1;
|
||||||
|
actions.addMembers = [addMember];
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDeletePendingMemberChange({
|
export function buildDeletePendingMemberChange({
|
||||||
uuids,
|
uuids,
|
||||||
group,
|
group,
|
||||||
|
@ -744,11 +887,13 @@ export function buildPromoteMemberChange({
|
||||||
export async function uploadGroupChange({
|
export async function uploadGroupChange({
|
||||||
actions,
|
actions,
|
||||||
group,
|
group,
|
||||||
|
inviteLinkPassword,
|
||||||
}: {
|
}: {
|
||||||
actions: GroupChangeClass.Actions;
|
actions: GroupChangeClass.Actions;
|
||||||
group: ConversationAttributesType;
|
group: ConversationAttributesType;
|
||||||
|
inviteLinkPassword?: string;
|
||||||
}): Promise<GroupChangeClass> {
|
}): Promise<GroupChangeClass> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
|
|
||||||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||||
await maybeFetchNewCredentials();
|
await maybeFetchNewCredentials();
|
||||||
|
@ -764,14 +909,160 @@ export async function uploadGroupChange({
|
||||||
logId: `uploadGroupChange/${logId}`,
|
logId: `uploadGroupChange/${logId}`,
|
||||||
publicParams: group.publicParams,
|
publicParams: group.publicParams,
|
||||||
secretParams: group.secretParams,
|
secretParams: group.secretParams,
|
||||||
request: (sender, options) => sender.modifyGroup(actions, options),
|
request: (sender, options) =>
|
||||||
|
sender.modifyGroup(actions, options, inviteLinkPassword),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function modifyGroupV2({
|
||||||
|
conversation,
|
||||||
|
createGroupChange,
|
||||||
|
inviteLinkPassword,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
conversation: ConversationModel;
|
||||||
|
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
|
||||||
|
inviteLinkPassword?: string;
|
||||||
|
name: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const idLog = `${name}/${conversation.idForLogging()}`;
|
||||||
|
|
||||||
|
if (!conversation.isGroupV2()) {
|
||||||
|
throw new Error(
|
||||||
|
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONE_MINUTE = 1000 * 60;
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timeoutTime = startTime + ONE_MINUTE;
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await window.waitForEmptyEventQueue();
|
||||||
|
|
||||||
|
window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await conversation.queueJob(async () => {
|
||||||
|
window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
|
||||||
|
|
||||||
|
const actions = await createGroupChange();
|
||||||
|
if (!actions) {
|
||||||
|
window.log.warn(
|
||||||
|
`modifyGroupV2/${idLog}: No change actions. Returning early.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new revision has to be exactly one more than the current revision
|
||||||
|
// or it won't upload properly, and it won't apply in maybeUpdateGroup
|
||||||
|
const currentRevision = conversation.get('revision');
|
||||||
|
const newRevision = actions.version;
|
||||||
|
|
||||||
|
if ((currentRevision || 0) + 1 !== newRevision) {
|
||||||
|
throw new Error(
|
||||||
|
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload. If we don't have permission, the server will return an error here.
|
||||||
|
const groupChange = await window.Signal.Groups.uploadGroupChange({
|
||||||
|
actions,
|
||||||
|
inviteLinkPassword,
|
||||||
|
group: conversation.attributes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupChangeBuffer = groupChange.toArrayBuffer();
|
||||||
|
const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer);
|
||||||
|
|
||||||
|
// Apply change locally, just like we would with an incoming change. This will
|
||||||
|
// change conversation state and add change notifications to the timeline.
|
||||||
|
await window.Signal.Groups.maybeUpdateGroup({
|
||||||
|
conversation,
|
||||||
|
groupChangeBase64,
|
||||||
|
newRevision,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send message to notify group members (including pending members) of change
|
||||||
|
const profileKey = conversation.get('profileSharing')
|
||||||
|
? window.storage.get('profileKey')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const sendOptions = conversation.getSendOptions();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const promise = conversation.wrapSend(
|
||||||
|
window.textsecure.messaging.sendMessageToGroup(
|
||||||
|
{
|
||||||
|
groupV2: conversation.getGroupV2Info({
|
||||||
|
groupChange: groupChangeBuffer,
|
||||||
|
includePendingMembers: true,
|
||||||
|
}),
|
||||||
|
timestamp,
|
||||||
|
profileKey,
|
||||||
|
},
|
||||||
|
sendOptions
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// We don't save this message; we just use it to ensure that a sync message is
|
||||||
|
// sent to our linked devices.
|
||||||
|
const m = new window.Whisper.Message(({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
type: 'not-to-save',
|
||||||
|
sent_at: timestamp,
|
||||||
|
received_at: timestamp,
|
||||||
|
// TODO: DESKTOP-722
|
||||||
|
// this type does not fully implement the interface it is expected to
|
||||||
|
} as unknown) as MessageAttributesType);
|
||||||
|
|
||||||
|
// This is to ensure that the functions in send() and sendSyncMessage()
|
||||||
|
// don't save anything to the database.
|
||||||
|
m.doNotSave = true;
|
||||||
|
|
||||||
|
await m.send(promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we've gotten here with no error, we exit!
|
||||||
|
window.log.info(
|
||||||
|
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 409 && Date.now() <= timeoutTime) {
|
||||||
|
window.log.info(
|
||||||
|
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await conversation.fetchLatestGroupV2Data();
|
||||||
|
} else if (error.code === 409) {
|
||||||
|
window.log.error(
|
||||||
|
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
|
||||||
|
);
|
||||||
|
// We don't wait here because we're breaking out of the loop immediately.
|
||||||
|
conversation.fetchLatestGroupV2Data();
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
const errorString = error && error.stack ? error.stack : error;
|
||||||
|
window.log.error(
|
||||||
|
`modifyGroupV2/${idLog}: Error updating: ${errorString}`
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Utility
|
// Utility
|
||||||
|
|
||||||
function idForLogging(group: ConversationAttributesType) {
|
export function idForLogging(groupId: string | undefined): string {
|
||||||
return `groupv2(${group.groupId})`;
|
return `groupv2(${groupId})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveGroupFields(
|
export function deriveGroupFields(
|
||||||
|
@ -1242,6 +1533,7 @@ export async function initiateMigrationToGroupV2(
|
||||||
accessControl: {
|
accessControl: {
|
||||||
attributes: ACCESS_ENUM.MEMBER,
|
attributes: ACCESS_ENUM.MEMBER,
|
||||||
members: ACCESS_ENUM.MEMBER,
|
members: ACCESS_ENUM.MEMBER,
|
||||||
|
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
|
||||||
},
|
},
|
||||||
membersV2,
|
membersV2,
|
||||||
pendingMembersV2,
|
pendingMembersV2,
|
||||||
|
@ -1437,6 +1729,128 @@ export async function waitThenRespondToGroupV2Migration(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildMigrationBubble(
|
||||||
|
previousGroupV1MembersIds: Array<string>,
|
||||||
|
newAttributes: ConversationAttributesType
|
||||||
|
): MessageAttributesType {
|
||||||
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
|
||||||
|
// Assemble items to commemorate this event for the timeline..
|
||||||
|
const combinedConversationIds: Array<string> = [
|
||||||
|
...(newAttributes.membersV2 || []).map(item => item.conversationId),
|
||||||
|
...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId),
|
||||||
|
];
|
||||||
|
const droppedMemberIds: Array<string> = difference(
|
||||||
|
previousGroupV1MembersIds,
|
||||||
|
combinedConversationIds
|
||||||
|
).filter(id => id && id !== ourConversationId);
|
||||||
|
const invitedMembers = (newAttributes.pendingMembersV2 || []).filter(
|
||||||
|
item => item.conversationId !== ourConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
|
||||||
|
item => item.conversationId === ourConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...generateBasicMessage(),
|
||||||
|
type: 'group-v1-migration',
|
||||||
|
groupMigration: {
|
||||||
|
areWeInvited,
|
||||||
|
invitedMembers,
|
||||||
|
droppedMemberIds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinGroupV2ViaLinkAndMigrate({
|
||||||
|
approvalRequired,
|
||||||
|
conversation,
|
||||||
|
inviteLinkPassword,
|
||||||
|
revision,
|
||||||
|
}: {
|
||||||
|
approvalRequired: boolean;
|
||||||
|
conversation: ConversationModel;
|
||||||
|
inviteLinkPassword: string;
|
||||||
|
revision: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const isGroupV1 = conversation.isGroupV1();
|
||||||
|
const previousGroupV1Id = conversation.get('groupId');
|
||||||
|
|
||||||
|
if (!isGroupV1 || !previousGroupV1Id) {
|
||||||
|
throw new Error(
|
||||||
|
`joinGroupV2ViaLinkAndMigrate: Conversation is not GroupV1! ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive GroupV2 fields
|
||||||
|
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id);
|
||||||
|
const masterKeyBuffer = await deriveMasterKeyFromGroupV1(groupV1IdBuffer);
|
||||||
|
const fields = deriveGroupFields(masterKeyBuffer);
|
||||||
|
|
||||||
|
const groupId = arrayBufferToBase64(fields.id);
|
||||||
|
const logId = idForLogging(groupId);
|
||||||
|
window.log.info(
|
||||||
|
`joinGroupV2ViaLinkAndMigrate/${logId}: Migrating from ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const masterKey = arrayBufferToBase64(masterKeyBuffer);
|
||||||
|
const secretParams = arrayBufferToBase64(fields.secretParams);
|
||||||
|
const publicParams = arrayBufferToBase64(fields.publicParams);
|
||||||
|
|
||||||
|
// A mini-migration, which will not show dropped/invited members
|
||||||
|
const newAttributes = {
|
||||||
|
...conversation.attributes,
|
||||||
|
|
||||||
|
// Core GroupV2 info
|
||||||
|
revision,
|
||||||
|
groupId,
|
||||||
|
groupVersion: 2,
|
||||||
|
masterKey,
|
||||||
|
publicParams,
|
||||||
|
secretParams,
|
||||||
|
groupInviteLinkPassword: inviteLinkPassword,
|
||||||
|
|
||||||
|
left: true,
|
||||||
|
|
||||||
|
// Capture previous GroupV1 data for future use
|
||||||
|
previousGroupV1Id: conversation.get('groupId'),
|
||||||
|
previousGroupV1Members: conversation.get('members'),
|
||||||
|
|
||||||
|
// Clear storage ID, since we need to start over on the storage service
|
||||||
|
storageID: undefined,
|
||||||
|
|
||||||
|
// Clear obsolete data
|
||||||
|
derivedGroupV2Id: undefined,
|
||||||
|
members: undefined,
|
||||||
|
};
|
||||||
|
const groupChangeMessages = [
|
||||||
|
{
|
||||||
|
...generateBasicMessage(),
|
||||||
|
type: 'group-v1-migration',
|
||||||
|
groupMigration: {
|
||||||
|
areWeInvited: false,
|
||||||
|
invitedMembers: [],
|
||||||
|
droppedMemberIds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await updateGroup({
|
||||||
|
conversation,
|
||||||
|
updates: {
|
||||||
|
newAttributes,
|
||||||
|
groupChangeMessages,
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now things are set up, so we can go through normal channels
|
||||||
|
await conversation.joinGroupV2ViaLink({
|
||||||
|
inviteLinkPassword,
|
||||||
|
approvalRequired,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// This may be called from storage service, an out-of-band check, or an incoming message.
|
// This may be called from storage service, an out-of-band check, or an incoming message.
|
||||||
// If this is kicked off via an incoming message, we want to do the right thing and hit
|
// If this is kicked off via an incoming message, we want to do the right thing and hit
|
||||||
// the log endpoint - the parameters beyond conversation are needed in that scenario.
|
// the log endpoint - the parameters beyond conversation are needed in that scenario.
|
||||||
|
@ -1459,17 +1873,11 @@ export async function respondToGroupV2Migration({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we were not previously a member, we won't migrate
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
|
||||||
const wereWePreviouslyAMember =
|
const wereWePreviouslyAMember =
|
||||||
!conversation.get('left') &&
|
!conversation.get('left') &&
|
||||||
ourConversationId &&
|
ourConversationId &&
|
||||||
conversation.hasMember(ourConversationId);
|
conversation.hasMember(ourConversationId);
|
||||||
if (!ourConversationId) {
|
|
||||||
throw new Error(
|
|
||||||
`respondToGroupV2Migration: No conversationId when attempting to migrate ${conversation.idForLogging()}. Returning early.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive GroupV2 fields
|
// Derive GroupV2 fields
|
||||||
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id);
|
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id);
|
||||||
|
@ -1477,7 +1885,7 @@ export async function respondToGroupV2Migration({
|
||||||
const fields = deriveGroupFields(masterKeyBuffer);
|
const fields = deriveGroupFields(masterKeyBuffer);
|
||||||
|
|
||||||
const groupId = arrayBufferToBase64(fields.id);
|
const groupId = arrayBufferToBase64(fields.id);
|
||||||
const logId = `groupv2(${groupId})`;
|
const logId = idForLogging(groupId);
|
||||||
window.log.info(
|
window.log.info(
|
||||||
`respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}`
|
`respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}`
|
||||||
);
|
);
|
||||||
|
@ -1600,17 +2008,11 @@ export async function respondToGroupV2Migration({
|
||||||
groupState,
|
groupState,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assemble items to commemorate this event for the timeline..
|
// Generate notifications into the timeline
|
||||||
const combinedConversationIds: Array<string> = [
|
const groupChangeMessages: Array<MessageAttributesType> = [];
|
||||||
...(newAttributes.membersV2 || []).map(item => item.conversationId),
|
|
||||||
...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId),
|
groupChangeMessages.push(
|
||||||
];
|
buildMigrationBubble(previousGroupV1MembersIds, newAttributes)
|
||||||
const droppedMemberIds: Array<string> = difference(
|
|
||||||
previousGroupV1MembersIds,
|
|
||||||
combinedConversationIds
|
|
||||||
).filter(id => id && id !== ourConversationId);
|
|
||||||
const invitedMembers = (newAttributes.pendingMembersV2 || []).filter(
|
|
||||||
item => item.conversationId !== ourConversationId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
|
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
|
||||||
|
@ -1619,19 +2021,6 @@ export async function respondToGroupV2Migration({
|
||||||
const areWeMember = (newAttributes.membersV2 || []).some(
|
const areWeMember = (newAttributes.membersV2 || []).some(
|
||||||
item => item.conversationId === ourConversationId
|
item => item.conversationId === ourConversationId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate notifications into the timeline
|
|
||||||
const groupChangeMessages: Array<MessageAttributesType> = [];
|
|
||||||
groupChangeMessages.push({
|
|
||||||
...generateBasicMessage(),
|
|
||||||
type: 'group-v1-migration',
|
|
||||||
groupMigration: {
|
|
||||||
areWeInvited,
|
|
||||||
invitedMembers,
|
|
||||||
droppedMemberIds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!areWeInvited && !areWeMember) {
|
if (!areWeInvited && !areWeMember) {
|
||||||
// Add a message to the timeline saying the user was removed. This shouldn't happen.
|
// Add a message to the timeline saying the user was removed. This shouldn't happen.
|
||||||
groupChangeMessages.push({
|
groupChangeMessages.push({
|
||||||
|
@ -1764,6 +2153,8 @@ async function updateGroup({
|
||||||
|
|
||||||
const isInitialDataFetch =
|
const isInitialDataFetch =
|
||||||
!isNumber(startingRevision) && isNumber(endingRevision);
|
!isNumber(startingRevision) && isNumber(endingRevision);
|
||||||
|
const isInGroup = !updates.newAttributes.left;
|
||||||
|
const justJoinedGroup = conversation.get('left') && isInGroup;
|
||||||
|
|
||||||
// Ensure that all generated messages are ordered properly.
|
// Ensure that all generated messages are ordered properly.
|
||||||
// Before the provided timestamp so update messages appear before the
|
// Before the provided timestamp so update messages appear before the
|
||||||
|
@ -1782,9 +2173,12 @@ async function updateGroup({
|
||||||
// fetched data about it, and we were able to fetch its name. Nobody likes to see
|
// fetched data about it, and we were able to fetch its name. Nobody likes to see
|
||||||
// Unknown Group in the left pane.
|
// Unknown Group in the left pane.
|
||||||
active_at:
|
active_at:
|
||||||
isInitialDataFetch && newAttributes.name
|
(isInitialDataFetch || justJoinedGroup) && newAttributes.name
|
||||||
? finalReceivedAt
|
? finalReceivedAt
|
||||||
: newAttributes.active_at,
|
: newAttributes.active_at,
|
||||||
|
temporaryMemberCount: isInGroup
|
||||||
|
? undefined
|
||||||
|
: newAttributes.temporaryMemberCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (idChanged) {
|
if (idChanged) {
|
||||||
|
@ -1843,14 +2237,18 @@ async function getGroupUpdates({
|
||||||
newRevision?: number;
|
newRevision?: number;
|
||||||
serverPublicParamsBase64: string;
|
serverPublicParamsBase64: string;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
|
|
||||||
window.log.info(`getGroupUpdates/${logId}: Starting...`);
|
window.log.info(`getGroupUpdates/${logId}: Starting...`);
|
||||||
|
|
||||||
const currentRevision = group.revision;
|
const currentRevision = group.revision;
|
||||||
const isFirstFetch = !isNumber(group.revision);
|
const isFirstFetch = !isNumber(group.revision);
|
||||||
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
||||||
|
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
|
||||||
|
item => item.conversationId === ourConversationId
|
||||||
|
);
|
||||||
const isOneVersionUp =
|
const isOneVersionUp =
|
||||||
isNumber(currentRevision) &&
|
isNumber(currentRevision) &&
|
||||||
isNumber(newRevision) &&
|
isNumber(newRevision) &&
|
||||||
|
@ -1860,7 +2258,7 @@ async function getGroupUpdates({
|
||||||
window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
|
window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
|
||||||
groupChangeBase64 &&
|
groupChangeBase64 &&
|
||||||
isNumber(newRevision) &&
|
isNumber(newRevision) &&
|
||||||
(isInitialCreationMessage || isOneVersionUp)
|
(isInitialCreationMessage || weAreAwaitingApproval || isOneVersionUp)
|
||||||
) {
|
) {
|
||||||
window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
|
window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
|
||||||
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
|
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
|
||||||
|
@ -1872,7 +2270,12 @@ async function getGroupUpdates({
|
||||||
groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;
|
groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;
|
||||||
|
|
||||||
if (isChangeSupported) {
|
if (isChangeSupported) {
|
||||||
return integrateGroupChange({ group, newRevision, groupChange });
|
return updateGroupViaSingleChange({
|
||||||
|
group,
|
||||||
|
newRevision,
|
||||||
|
groupChange,
|
||||||
|
serverPublicParamsBase64,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.log.info(
|
window.log.info(
|
||||||
|
@ -1933,7 +2336,7 @@ async function updateGroupViaState({
|
||||||
group: ConversationAttributesType;
|
group: ConversationAttributesType;
|
||||||
serverPublicParamsBase64: string;
|
serverPublicParamsBase64: string;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
throw new Error('updateGroupViaState: No group credentials!');
|
throw new Error('updateGroupViaState: No group credentials!');
|
||||||
|
@ -1980,6 +2383,46 @@ async function updateGroupViaState({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGroupViaSingleChange({
|
||||||
|
group,
|
||||||
|
groupChange,
|
||||||
|
newRevision,
|
||||||
|
serverPublicParamsBase64,
|
||||||
|
}: {
|
||||||
|
group: ConversationAttributesType;
|
||||||
|
groupChange: GroupChangeClass;
|
||||||
|
newRevision: number;
|
||||||
|
serverPublicParamsBase64: string;
|
||||||
|
}): Promise<UpdatesResultType> {
|
||||||
|
const wasInGroup = !group.left;
|
||||||
|
const result: UpdatesResultType = await integrateGroupChange({
|
||||||
|
group,
|
||||||
|
groupChange,
|
||||||
|
newRevision,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowInGroup = !result.newAttributes.left;
|
||||||
|
|
||||||
|
// If we were just added to the group (for example, via a join link), we go fetch the
|
||||||
|
// entire group state to make sure we're up to date.
|
||||||
|
if (!wasInGroup && nowInGroup) {
|
||||||
|
const { newAttributes, members } = await updateGroupViaState({
|
||||||
|
group: result.newAttributes,
|
||||||
|
serverPublicParamsBase64,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We discard any change events that come out of this full group fetch, but we do
|
||||||
|
// keep the final group attributes generated, as well as any new members.
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
members: [...result.members, ...members],
|
||||||
|
newAttributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async function updateGroupViaLogs({
|
async function updateGroupViaLogs({
|
||||||
group,
|
group,
|
||||||
serverPublicParamsBase64,
|
serverPublicParamsBase64,
|
||||||
|
@ -1989,7 +2432,7 @@ async function updateGroupViaLogs({
|
||||||
newRevision: number;
|
newRevision: number;
|
||||||
serverPublicParamsBase64: string;
|
serverPublicParamsBase64: string;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
throw new Error('getGroupUpdates: No group credentials!');
|
throw new Error('getGroupUpdates: No group credentials!');
|
||||||
|
@ -2032,10 +2475,10 @@ function generateBasicMessage() {
|
||||||
} as MessageAttributesType;
|
} as MessageAttributesType;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateLeftGroupChanges(
|
async function generateLeftGroupChanges(
|
||||||
group: ConversationAttributesType
|
group: ConversationAttributesType
|
||||||
): UpdatesResultType {
|
): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
window.log.info(`generateLeftGroupChanges/${logId}: Starting...`);
|
window.log.info(`generateLeftGroupChanges/${logId}: Starting...`);
|
||||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||||
if (!ourConversationId) {
|
if (!ourConversationId) {
|
||||||
|
@ -2043,6 +2486,29 @@ function generateLeftGroupChanges(
|
||||||
'generateLeftGroupChanges: We do not have a conversationId!'
|
'generateLeftGroupChanges: We do not have a conversationId!'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { masterKey, groupInviteLinkPassword } = group;
|
||||||
|
let { revision } = group;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (masterKey && groupInviteLinkPassword) {
|
||||||
|
window.log.info(
|
||||||
|
`generateLeftGroupChanges/${logId}: Have invite link. Attempting to fetch latest revision with it.`
|
||||||
|
);
|
||||||
|
const preJoinInfo = await getPreJoinGroupInfo(
|
||||||
|
groupInviteLinkPassword,
|
||||||
|
masterKey
|
||||||
|
);
|
||||||
|
|
||||||
|
revision = preJoinInfo.version;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
window.log.warn(
|
||||||
|
'generateLeftGroupChanges: Failed to fetch latest revision via group link. Code:',
|
||||||
|
error.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existingMembers = group.membersV2 || [];
|
const existingMembers = group.membersV2 || [];
|
||||||
const newAttributes: ConversationAttributesType = {
|
const newAttributes: ConversationAttributesType = {
|
||||||
...group,
|
...group,
|
||||||
|
@ -2050,6 +2516,7 @@ function generateLeftGroupChanges(
|
||||||
member => member.conversationId !== ourConversationId
|
member => member.conversationId !== ourConversationId
|
||||||
),
|
),
|
||||||
left: true,
|
left: true,
|
||||||
|
revision,
|
||||||
};
|
};
|
||||||
const isNewlyRemoved =
|
const isNewlyRemoved =
|
||||||
existingMembers.length > (newAttributes.membersV2 || []).length;
|
existingMembers.length > (newAttributes.membersV2 || []).length;
|
||||||
|
@ -2162,7 +2629,7 @@ async function integrateGroupChanges({
|
||||||
newRevision: number;
|
newRevision: number;
|
||||||
changes: Array<GroupChangesClass>;
|
changes: Array<GroupChangesClass>;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
let attributes = group;
|
let attributes = group;
|
||||||
const finalMessages: Array<Array<MessageAttributesType>> = [];
|
const finalMessages: Array<Array<MessageAttributesType>> = [];
|
||||||
const finalMembers: Array<Array<MemberType>> = [];
|
const finalMembers: Array<Array<MemberType>> = [];
|
||||||
|
@ -2258,7 +2725,7 @@ async function integrateGroupChange({
|
||||||
groupState?: GroupClass;
|
groupState?: GroupClass;
|
||||||
newRevision: number;
|
newRevision: number;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
if (!group.secretParams) {
|
if (!group.secretParams) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`integrateGroupChange/${logId}: Group was missing secretParams!`
|
`integrateGroupChange/${logId}: Group was missing secretParams!`
|
||||||
|
@ -2299,7 +2766,16 @@ async function integrateGroupChange({
|
||||||
isNumber(group.revision) &&
|
isNumber(group.revision) &&
|
||||||
groupChangeActions.version > group.revision + 1;
|
groupChangeActions.version > group.revision + 1;
|
||||||
|
|
||||||
if (!isChangeSupported || isFirstFetch || isMoreThanOneVersionUp) {
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
|
||||||
|
item => item.conversationId === ourConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isChangeSupported ||
|
||||||
|
isFirstFetch ||
|
||||||
|
(isMoreThanOneVersionUp && !weAreAwaitingApproval)
|
||||||
|
) {
|
||||||
if (!groupState) {
|
if (!groupState) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`integrateGroupChange/${logId}: No group state, but we can't apply changes!`
|
`integrateGroupChange/${logId}: No group state, but we can't apply changes!`
|
||||||
|
@ -2372,7 +2848,7 @@ async function getCurrentGroupState({
|
||||||
group: ConversationAttributesType;
|
group: ConversationAttributesType;
|
||||||
serverPublicParamsBase64: string;
|
serverPublicParamsBase64: string;
|
||||||
}): Promise<UpdatesResultType> {
|
}): Promise<UpdatesResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
const sender = window.textsecure.messaging;
|
const sender = window.textsecure.messaging;
|
||||||
if (!sender) {
|
if (!sender) {
|
||||||
throw new Error('textsecure.messaging is not available!');
|
throw new Error('textsecure.messaging is not available!');
|
||||||
|
@ -2425,7 +2901,7 @@ function extractDiffs({
|
||||||
old: ConversationAttributesType;
|
old: ConversationAttributesType;
|
||||||
sourceConversationId?: string;
|
sourceConversationId?: string;
|
||||||
}): Array<MessageAttributesType> {
|
}): Array<MessageAttributesType> {
|
||||||
const logId = idForLogging(old);
|
const logId = idForLogging(old.groupId);
|
||||||
const details: Array<GroupV2ChangeDetailType> = [];
|
const details: Array<GroupV2ChangeDetailType> = [];
|
||||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||||
|
@ -2847,7 +3323,7 @@ async function applyGroupChange({
|
||||||
group: ConversationAttributesType;
|
group: ConversationAttributesType;
|
||||||
sourceConversationId: string;
|
sourceConversationId: string;
|
||||||
}): Promise<GroupChangeResultType> {
|
}): Promise<GroupChangeResultType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||||
|
@ -3335,11 +3811,11 @@ async function applyGroupChange({
|
||||||
|
|
||||||
// Ovewriting result.avatar as part of functionality
|
// Ovewriting result.avatar as part of functionality
|
||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
async function applyNewAvatar(
|
export async function applyNewAvatar(
|
||||||
newAvatar: string | undefined,
|
newAvatar: string | undefined,
|
||||||
result: ConversationAttributesType,
|
result: Pick<ConversationAttributesType, 'avatar' | 'secretParams'>,
|
||||||
logId: string
|
logId: string
|
||||||
) {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Avatar has been dropped
|
// Avatar has been dropped
|
||||||
if (!newAvatar && result.avatar) {
|
if (!newAvatar && result.avatar) {
|
||||||
|
@ -3413,7 +3889,7 @@ async function applyGroupState({
|
||||||
groupState: GroupClass;
|
groupState: GroupClass;
|
||||||
sourceConversationId?: string;
|
sourceConversationId?: string;
|
||||||
}): Promise<ConversationAttributesType> {
|
}): Promise<ConversationAttributesType> {
|
||||||
const logId = idForLogging(group);
|
const logId = idForLogging(group.groupId);
|
||||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||||
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||||
const version = groupState.version || 0;
|
const version = groupState.version || 0;
|
||||||
|
@ -4144,6 +4620,24 @@ function decryptGroupChange(
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decryptGroupTitle(
|
||||||
|
title: ProtoBinaryType,
|
||||||
|
secretParams: string
|
||||||
|
): string | undefined {
|
||||||
|
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
|
||||||
|
if (hasData(title)) {
|
||||||
|
const blob = window.textsecure.protobuf.GroupAttributeBlob.decode(
|
||||||
|
decryptGroupBlob(clientZkGroupCipher, title.toArrayBuffer())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blob && blob.content === 'title') {
|
||||||
|
return blob.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function decryptGroupState(
|
function decryptGroupState(
|
||||||
groupState: GroupClass,
|
groupState: GroupClass,
|
||||||
groupSecretParams: string,
|
groupSecretParams: string,
|
||||||
|
|
423
ts/groups/joinViaLink.ts
Normal file
423
ts/groups/joinViaLink.ts
Normal file
|
@ -0,0 +1,423 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyNewAvatar,
|
||||||
|
decryptGroupTitle,
|
||||||
|
deriveGroupFields,
|
||||||
|
getPreJoinGroupInfo,
|
||||||
|
idForLogging,
|
||||||
|
LINK_VERSION_ERROR,
|
||||||
|
parseGroupLink,
|
||||||
|
} from '../groups';
|
||||||
|
import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto';
|
||||||
|
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
|
||||||
|
|
||||||
|
import type { GroupJoinInfoClass } from '../textsecure.d';
|
||||||
|
import type { ConversationAttributesType } from '../model-types.d';
|
||||||
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
import type { PreJoinConversationType } from '../state/ducks/conversations';
|
||||||
|
|
||||||
|
export async function joinViaLink(hash: string): Promise<void> {
|
||||||
|
let inviteLinkPassword: string;
|
||||||
|
let masterKey: string;
|
||||||
|
try {
|
||||||
|
({ inviteLinkPassword, masterKey } = parseGroupLink(hash));
|
||||||
|
} catch (error) {
|
||||||
|
const errorString = error && error.stack ? error.stack : error;
|
||||||
|
window.log.error(`joinViaLink: Failed to parse group link ${errorString}`);
|
||||||
|
if (error && error.name === LINK_VERSION_ERROR) {
|
||||||
|
showErrorDialog(
|
||||||
|
window.i18n('GroupV2--join--unknown-link-version'),
|
||||||
|
window.i18n('GroupV2--join--unknown-link-version--title')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showErrorDialog(
|
||||||
|
window.i18n('GroupV2--join--invalid-link'),
|
||||||
|
window.i18n('GroupV2--join--invalid-link--title')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = deriveGroupFields(base64ToArrayBuffer(masterKey));
|
||||||
|
const id = arrayBufferToBase64(data.id);
|
||||||
|
const logId = `groupv2(${id})`;
|
||||||
|
const secretParams = arrayBufferToBase64(data.secretParams);
|
||||||
|
const publicParams = arrayBufferToBase64(data.publicParams);
|
||||||
|
|
||||||
|
const existingConversation =
|
||||||
|
window.ConversationController.get(id) ||
|
||||||
|
window.ConversationController.getByDerivedGroupV2Id(id);
|
||||||
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingConversation &&
|
||||||
|
existingConversation.hasMember(ourConversationId)
|
||||||
|
) {
|
||||||
|
window.log.warn(
|
||||||
|
`joinViaLink/${logId}: Already a member of group, opening conversation`
|
||||||
|
);
|
||||||
|
window.reduxActions.conversations.openConversationInternal(
|
||||||
|
existingConversation.id
|
||||||
|
);
|
||||||
|
window.window.Whisper.ToastView.show(
|
||||||
|
window.Whisper.AlreadyGroupMemberToast,
|
||||||
|
document.getElementsByClassName('conversation-stack')[0]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: GroupJoinInfoClass;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await longRunningTaskWrapper({
|
||||||
|
name: 'getPreJoinGroupInfo',
|
||||||
|
idForLogging: idForLogging(id),
|
||||||
|
// If an error happens here, we won't show a dialog. We'll rely on the catch a few
|
||||||
|
// lines below.
|
||||||
|
suppressErrorDialog: true,
|
||||||
|
task: () => getPreJoinGroupInfo(inviteLinkPassword, masterKey),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorString = error && error.stack ? error.stack : error;
|
||||||
|
window.log.error(
|
||||||
|
`joinViaLink/${logId}: Failed to fetch group info - ${errorString}`
|
||||||
|
);
|
||||||
|
|
||||||
|
showErrorDialog(
|
||||||
|
error.code && error.code === 403
|
||||||
|
? window.i18n('GroupV2--join--link-revoked')
|
||||||
|
: window.i18n('GroupV2--join--general-join-failure'),
|
||||||
|
error.code && error.code === 403
|
||||||
|
? window.i18n('GroupV2--join--link-revoked--title')
|
||||||
|
: window.i18n('GroupV2--join--general-join-failure--title')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||||
|
if (
|
||||||
|
result.addFromInviteLink !== ACCESS_ENUM.ADMINISTRATOR &&
|
||||||
|
result.addFromInviteLink !== ACCESS_ENUM.ANY
|
||||||
|
) {
|
||||||
|
window.log.error(
|
||||||
|
`joinViaLink/${logId}: addFromInviteLink value of ${result.addFromInviteLink} is invalid`
|
||||||
|
);
|
||||||
|
showErrorDialog(
|
||||||
|
window.i18n('GroupV2--join--link-revoked'),
|
||||||
|
window.i18n('GroupV2--join--link-revoked--title')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let localAvatar:
|
||||||
|
| {
|
||||||
|
loading?: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
| undefined = result.avatar ? { loading: true } : undefined;
|
||||||
|
const memberCount = result.memberCount || 1;
|
||||||
|
const approvalRequired =
|
||||||
|
result.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR;
|
||||||
|
const title =
|
||||||
|
decryptGroupTitle(result.title, secretParams) ||
|
||||||
|
window.i18n('unknownGroup');
|
||||||
|
|
||||||
|
if (
|
||||||
|
approvalRequired &&
|
||||||
|
existingConversation &&
|
||||||
|
existingConversation.isMemberAwaitingApproval(ourConversationId)
|
||||||
|
) {
|
||||||
|
window.log.warn(
|
||||||
|
`joinViaLink/${logId}: Already awaiting approval, opening conversation`
|
||||||
|
);
|
||||||
|
window.reduxActions.conversations.openConversationInternal(
|
||||||
|
existingConversation.id
|
||||||
|
);
|
||||||
|
|
||||||
|
window.Whisper.ToastView.show(
|
||||||
|
window.Whisper.AlreadyRequestedToJoinToast,
|
||||||
|
document.getElementsByClassName('conversation-stack')[0]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPreJoinConversation = (): PreJoinConversationType => {
|
||||||
|
let avatar;
|
||||||
|
if (!localAvatar) {
|
||||||
|
avatar = undefined;
|
||||||
|
} else if (localAvatar && localAvatar.loading) {
|
||||||
|
avatar = {
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
} else if (localAvatar && localAvatar.path) {
|
||||||
|
avatar = {
|
||||||
|
url: window.Signal.Migrations.getAbsoluteAttachmentPath(
|
||||||
|
localAvatar.path
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalRequired,
|
||||||
|
avatar,
|
||||||
|
memberCount,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explode a promise so we know when this whole join process is complete
|
||||||
|
const { promise, resolve, reject } = explodePromise();
|
||||||
|
|
||||||
|
const closeDialog = async () => {
|
||||||
|
try {
|
||||||
|
if (groupV2InfoDialog) {
|
||||||
|
groupV2InfoDialog.remove();
|
||||||
|
groupV2InfoDialog = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.reduxActions.conversations.setPreJoinConversation(undefined);
|
||||||
|
|
||||||
|
if (localAvatar && localAvatar.path) {
|
||||||
|
await window.Signal.Migrations.deleteAttachmentData(localAvatar.path);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const join = async () => {
|
||||||
|
try {
|
||||||
|
if (groupV2InfoDialog) {
|
||||||
|
groupV2InfoDialog.remove();
|
||||||
|
groupV2InfoDialog = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.reduxActions.conversations.setPreJoinConversation(undefined);
|
||||||
|
|
||||||
|
await longRunningTaskWrapper({
|
||||||
|
name: 'joinViaLink',
|
||||||
|
idForLogging: idForLogging(id),
|
||||||
|
// If an error happens here, we won't show a dialog. We'll rely on a top-level
|
||||||
|
// error dialog provided by the caller of this function.
|
||||||
|
suppressErrorDialog: true,
|
||||||
|
task: async () => {
|
||||||
|
let targetConversation =
|
||||||
|
existingConversation ||
|
||||||
|
window.ConversationController.get(id) ||
|
||||||
|
window.ConversationController.getByDerivedGroupV2Id(id);
|
||||||
|
let tempConversation: ConversationModel | undefined;
|
||||||
|
|
||||||
|
// Check again to ensure that we haven't already joined or requested to join
|
||||||
|
// via some other process. If so, just open that conversation.
|
||||||
|
if (
|
||||||
|
targetConversation &&
|
||||||
|
(targetConversation.hasMember(ourConversationId) ||
|
||||||
|
(approvalRequired &&
|
||||||
|
targetConversation.isMemberAwaitingApproval(ourConversationId)))
|
||||||
|
) {
|
||||||
|
window.log.warn(
|
||||||
|
`joinViaLink/${logId}: User is part of group on second check, opening conversation`
|
||||||
|
);
|
||||||
|
window.reduxActions.conversations.openConversationInternal(
|
||||||
|
targetConversation.id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!targetConversation) {
|
||||||
|
// Note: we save this temp conversation in the database, so we'll need to
|
||||||
|
// clean it up if something goes wrong
|
||||||
|
tempConversation = window.ConversationController.getOrCreate(
|
||||||
|
id,
|
||||||
|
'group',
|
||||||
|
{
|
||||||
|
// This will cause this conversation to be deleted at next startup
|
||||||
|
isTemporary: true,
|
||||||
|
|
||||||
|
groupVersion: 2,
|
||||||
|
masterKey,
|
||||||
|
secretParams,
|
||||||
|
publicParams,
|
||||||
|
|
||||||
|
left: true,
|
||||||
|
revision: result.version,
|
||||||
|
|
||||||
|
avatar:
|
||||||
|
localAvatar && localAvatar.path && result.avatar
|
||||||
|
? {
|
||||||
|
url: result.avatar,
|
||||||
|
path: localAvatar.path,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
groupInviteLinkPassword: inviteLinkPassword,
|
||||||
|
name: title,
|
||||||
|
temporaryMemberCount: memberCount,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
targetConversation = tempConversation;
|
||||||
|
} else {
|
||||||
|
// Ensure the group maintains the title and avatar you saw when attempting
|
||||||
|
// to join it.
|
||||||
|
targetConversation.set({
|
||||||
|
avatar:
|
||||||
|
localAvatar && localAvatar.path && result.avatar
|
||||||
|
? {
|
||||||
|
url: result.avatar,
|
||||||
|
path: localAvatar.path,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
groupInviteLinkPassword: inviteLinkPassword,
|
||||||
|
name: title,
|
||||||
|
temporaryMemberCount: memberCount,
|
||||||
|
});
|
||||||
|
window.Signal.Data.updateConversation(
|
||||||
|
targetConversation.attributes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetConversation.isGroupV1()) {
|
||||||
|
await targetConversation.joinGroupV2ViaLinkAndMigrate({
|
||||||
|
approvalRequired,
|
||||||
|
inviteLinkPassword,
|
||||||
|
revision: result.version || 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await targetConversation.joinGroupV2ViaLink({
|
||||||
|
inviteLinkPassword,
|
||||||
|
approvalRequired,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempConversation) {
|
||||||
|
tempConversation.set({
|
||||||
|
// We want to keep this conversation around, since the join succeeded
|
||||||
|
isTemporary: undefined,
|
||||||
|
});
|
||||||
|
window.Signal.Data.updateConversation(
|
||||||
|
tempConversation.attributes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.reduxActions.conversations.openConversationInternal(
|
||||||
|
targetConversation.id
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Delete newly-created conversation if we encountered any errors
|
||||||
|
if (tempConversation) {
|
||||||
|
window.ConversationController.dangerouslyRemoveById(
|
||||||
|
tempConversation.id
|
||||||
|
);
|
||||||
|
await window.Signal.Data.removeConversation(tempConversation.id, {
|
||||||
|
Conversation: window.Whisper.Conversation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial add to redux, with basic group information
|
||||||
|
window.reduxActions.conversations.setPreJoinConversation(
|
||||||
|
getPreJoinConversation()
|
||||||
|
);
|
||||||
|
|
||||||
|
window.log.info(`joinViaLink/${logId}: Showing modal`);
|
||||||
|
|
||||||
|
let groupV2InfoDialog = new Whisper.ReactWrapperView({
|
||||||
|
className: 'group-v2-join-dialog-wrapper',
|
||||||
|
JSX: window.Signal.State.Roots.createGroupV2JoinModal(window.reduxStore, {
|
||||||
|
join,
|
||||||
|
onClose: closeDialog,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// We declare a new function here so we can await but not block
|
||||||
|
const fetchAvatar = async () => {
|
||||||
|
if (result.avatar) {
|
||||||
|
localAvatar = {
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributes: Pick<
|
||||||
|
ConversationAttributesType,
|
||||||
|
'avatar' | 'secretParams'
|
||||||
|
> = {
|
||||||
|
avatar: null,
|
||||||
|
secretParams,
|
||||||
|
};
|
||||||
|
await applyNewAvatar(result.avatar, attributes, logId);
|
||||||
|
|
||||||
|
if (attributes.avatar && attributes.avatar.path) {
|
||||||
|
localAvatar = {
|
||||||
|
path: attributes.avatar.path,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dialog has been dismissed; we'll delete the unneeeded avatar
|
||||||
|
if (!groupV2InfoDialog) {
|
||||||
|
await window.Signal.Migrations.deleteAttachmentData(
|
||||||
|
attributes.avatar.path
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localAvatar = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update join dialog with newly-downloaded avatar
|
||||||
|
window.reduxActions.conversations.setPreJoinConversation(
|
||||||
|
getPreJoinConversation()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAvatar();
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorDialog(description: string, title: string) {
|
||||||
|
const errorView = new window.Whisper.ReactWrapperView({
|
||||||
|
className: 'error-modal-wrapper',
|
||||||
|
Component: window.Signal.Components.ErrorModal,
|
||||||
|
props: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClose: () => {
|
||||||
|
errorView.remove();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function explodePromise(): {
|
||||||
|
promise: Promise<void>;
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
} {
|
||||||
|
let resolve: () => void;
|
||||||
|
let reject: (error: Error) => void;
|
||||||
|
|
||||||
|
const promise = new Promise<void>((innerResolve, innerReject) => {
|
||||||
|
resolve = innerResolve;
|
||||||
|
reject = innerReject;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
// Typescript thinks that resolve and reject can be undefined here.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
resolve: resolve!,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
reject: reject!,
|
||||||
|
};
|
||||||
|
}
|
9
ts/model-types.d.ts
vendored
9
ts/model-types.d.ts
vendored
|
@ -211,6 +211,9 @@ export type ConversationAttributesType = {
|
||||||
|
|
||||||
// Group-only
|
// Group-only
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
// A shorthand, representing whether the user is part of the group. Not strictly for
|
||||||
|
// when the user manually left the group. But historically, that was the only way
|
||||||
|
// to leave a group.
|
||||||
left: boolean;
|
left: boolean;
|
||||||
groupVersion?: number;
|
groupVersion?: number;
|
||||||
|
|
||||||
|
@ -233,7 +236,7 @@ export type ConversationAttributesType = {
|
||||||
avatar?: {
|
avatar?: {
|
||||||
url: string;
|
url: string;
|
||||||
path: string;
|
path: string;
|
||||||
hash: string;
|
hash?: string;
|
||||||
} | null;
|
} | null;
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
membersV2?: Array<GroupV2MemberType>;
|
membersV2?: Array<GroupV2MemberType>;
|
||||||
|
@ -242,6 +245,10 @@ export type ConversationAttributesType = {
|
||||||
groupInviteLinkPassword?: string;
|
groupInviteLinkPassword?: string;
|
||||||
previousGroupV1Id?: string;
|
previousGroupV1Id?: string;
|
||||||
previousGroupV1Members?: Array<string>;
|
previousGroupV1Members?: Array<string>;
|
||||||
|
|
||||||
|
// Used only when user is waiting for approval to join via link
|
||||||
|
isTemporary?: boolean;
|
||||||
|
temporaryMemberCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GroupV2MemberType = {
|
export type GroupV2MemberType = {
|
||||||
|
|
|
@ -331,6 +331,22 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isMemberAwaitingApproval(conversationId: string): boolean {
|
||||||
|
if (!this.isGroupV2()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
|
||||||
|
|
||||||
|
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window._.any(
|
||||||
|
pendingAdminApprovalV2,
|
||||||
|
item => item.conversationId === conversationId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
isMember(conversationId: string): boolean {
|
isMember(conversationId: string): boolean {
|
||||||
if (!this.isGroupV2()) {
|
if (!this.isGroupV2()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -483,6 +499,97 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addPendingApprovalRequest(): Promise<
|
||||||
|
GroupChangeClass.Actions | undefined
|
||||||
|
> {
|
||||||
|
const idLog = this.idForLogging();
|
||||||
|
|
||||||
|
// Hard-coded to our own ID, because you don't add other users for admin approval
|
||||||
|
const conversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
|
||||||
|
const toRequest = window.ConversationController.get(conversationId);
|
||||||
|
if (!toRequest) {
|
||||||
|
throw new Error(
|
||||||
|
`addPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need the user's profileKeyCredential, which requires a roundtrip with the
|
||||||
|
// server, and most definitely their profileKey. A getProfiles() call will
|
||||||
|
// ensure that we have as much as we can get with the data we have.
|
||||||
|
let profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
|
||||||
|
if (!profileKeyCredentialBase64) {
|
||||||
|
await toRequest.getProfiles();
|
||||||
|
|
||||||
|
profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
|
||||||
|
if (!profileKeyCredentialBase64) {
|
||||||
|
throw new Error(
|
||||||
|
`promotePendingMember/${idLog}: No profileKeyCredential for conversation ${toRequest.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This user's pending state may have changed in the time between the user's
|
||||||
|
// button press and when we get here. It's especially important to check here
|
||||||
|
// in conflict/retry cases.
|
||||||
|
if (this.isMemberAwaitingApproval(conversationId)) {
|
||||||
|
window.log.warn(
|
||||||
|
`addPendingApprovalRequest/${idLog}: ${conversationId} already in pending approval.`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.Signal.Groups.buildAddPendingAdminApprovalMemberChange({
|
||||||
|
group: this.attributes,
|
||||||
|
profileKeyCredentialBase64,
|
||||||
|
serverPublicParamsBase64: window.getServerPublicParams(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(
|
||||||
|
conversationId: string
|
||||||
|
): Promise<GroupChangeClass.Actions | undefined> {
|
||||||
|
const idLog = this.idForLogging();
|
||||||
|
|
||||||
|
const toRequest = window.ConversationController.get(conversationId);
|
||||||
|
if (!toRequest) {
|
||||||
|
throw new Error(
|
||||||
|
`addMember/${idLog}: No conversation found for conversation ${conversationId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need the user's profileKeyCredential, which requires a roundtrip with the
|
||||||
|
// server, and most definitely their profileKey. A getProfiles() call will
|
||||||
|
// ensure that we have as much as we can get with the data we have.
|
||||||
|
let profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
|
||||||
|
if (!profileKeyCredentialBase64) {
|
||||||
|
await toRequest.getProfiles();
|
||||||
|
|
||||||
|
profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
|
||||||
|
if (!profileKeyCredentialBase64) {
|
||||||
|
throw new Error(
|
||||||
|
`addMember/${idLog}: No profileKeyCredential for conversation ${toRequest.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This user's pending state may have changed in the time between the user's
|
||||||
|
// button press and when we get here. It's especially important to check here
|
||||||
|
// in conflict/retry cases.
|
||||||
|
if (this.isMember(conversationId)) {
|
||||||
|
window.log.warn(
|
||||||
|
`addMember/${idLog}: ${conversationId} already a member.`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.Signal.Groups.buildAddMember({
|
||||||
|
group: this.attributes,
|
||||||
|
profileKeyCredentialBase64,
|
||||||
|
serverPublicParamsBase64: window.getServerPublicParams(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async removePendingMember(
|
async removePendingMember(
|
||||||
conversationIds: Array<string>
|
conversationIds: Array<string>
|
||||||
): Promise<GroupChangeClass.Actions | undefined> {
|
): Promise<GroupChangeClass.Actions | undefined> {
|
||||||
|
@ -609,142 +716,19 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
|
|
||||||
async modifyGroupV2({
|
async modifyGroupV2({
|
||||||
name,
|
name,
|
||||||
|
inviteLinkPassword,
|
||||||
createGroupChange,
|
createGroupChange,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
|
inviteLinkPassword?: string;
|
||||||
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
|
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const idLog = `${name}/${this.idForLogging()}`;
|
await window.Signal.Groups.modifyGroupV2({
|
||||||
|
createGroupChange,
|
||||||
if (!this.isGroupV2()) {
|
|
||||||
throw new Error(
|
|
||||||
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ONE_MINUTE = 1000 * 60;
|
|
||||||
const startTime = Date.now();
|
|
||||||
const timeoutTime = startTime + ONE_MINUTE;
|
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 5;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
|
||||||
window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await window.waitForEmptyEventQueue();
|
|
||||||
|
|
||||||
window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await this.queueJob(async () => {
|
|
||||||
window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
|
|
||||||
|
|
||||||
const actions = await createGroupChange();
|
|
||||||
if (!actions) {
|
|
||||||
window.log.warn(
|
|
||||||
`modifyGroupV2/${idLog}: No change actions. Returning early.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The new revision has to be exactly one more than the current revision
|
|
||||||
// or it won't upload properly, and it won't apply in maybeUpdateGroup
|
|
||||||
const currentRevision = this.get('revision');
|
|
||||||
const newRevision = actions.version;
|
|
||||||
|
|
||||||
if ((currentRevision || 0) + 1 !== newRevision) {
|
|
||||||
throw new Error(
|
|
||||||
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload. If we don't have permission, the server will return an error here.
|
|
||||||
const groupChange = await window.Signal.Groups.uploadGroupChange({
|
|
||||||
actions,
|
|
||||||
group: this.attributes,
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupChangeBuffer = groupChange.toArrayBuffer();
|
|
||||||
const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer);
|
|
||||||
|
|
||||||
// Apply change locally, just like we would with an incoming change. This will
|
|
||||||
// change conversation state and add change notifications to the timeline.
|
|
||||||
await window.Signal.Groups.maybeUpdateGroup({
|
|
||||||
conversation: this,
|
conversation: this,
|
||||||
groupChangeBase64,
|
inviteLinkPassword,
|
||||||
newRevision,
|
name,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send message to notify group members (including pending members) of change
|
|
||||||
const profileKey = this.get('profileSharing')
|
|
||||||
? window.storage.get('profileKey')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const sendOptions = this.getSendOptions();
|
|
||||||
const timestamp = Date.now();
|
|
||||||
|
|
||||||
const promise = this.wrapSend(
|
|
||||||
window.textsecure.messaging.sendMessageToGroup(
|
|
||||||
{
|
|
||||||
groupV2: this.getGroupV2Info({
|
|
||||||
groupChange: groupChangeBuffer,
|
|
||||||
includePendingMembers: true,
|
|
||||||
}),
|
|
||||||
timestamp,
|
|
||||||
profileKey,
|
|
||||||
},
|
|
||||||
sendOptions
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// We don't save this message; we just use it to ensure that a sync message is
|
|
||||||
// sent to our linked devices.
|
|
||||||
const m = new window.Whisper.Message(({
|
|
||||||
conversationId: this.id,
|
|
||||||
type: 'not-to-save',
|
|
||||||
sent_at: timestamp,
|
|
||||||
received_at: timestamp,
|
|
||||||
// TODO: DESKTOP-722
|
|
||||||
// this type does not fully implement the interface it is expected to
|
|
||||||
} as unknown) as MessageAttributesType);
|
|
||||||
|
|
||||||
// This is to ensure that the functions in send() and sendSyncMessage()
|
|
||||||
// don't save anything to the database.
|
|
||||||
m.doNotSave = true;
|
|
||||||
|
|
||||||
await m.send(promise);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we've gotten here with no error, we exit!
|
|
||||||
window.log.info(
|
|
||||||
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 409 && Date.now() <= timeoutTime) {
|
|
||||||
window.log.info(
|
|
||||||
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await this.fetchLatestGroupV2Data();
|
|
||||||
} else if (error.code === 409) {
|
|
||||||
window.log.error(
|
|
||||||
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
|
|
||||||
);
|
|
||||||
// We don't wait here because we're breaking out of the loop immediately.
|
|
||||||
this.fetchLatestGroupV2Data();
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
const errorString = error && error.stack ? error.stack : error;
|
|
||||||
window.log.error(
|
|
||||||
`modifyGroupV2/${idLog}: Error updating: ${errorString}`
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isEverUnregistered(): boolean {
|
isEverUnregistered(): boolean {
|
||||||
|
@ -1324,6 +1308,9 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
areWePending: Boolean(
|
areWePending: Boolean(
|
||||||
ourConversationId && this.isMemberPending(ourConversationId)
|
ourConversationId && this.isMemberPending(ourConversationId)
|
||||||
),
|
),
|
||||||
|
areWePendingApproval: Boolean(
|
||||||
|
ourConversationId && this.isMemberAwaitingApproval(ourConversationId)
|
||||||
|
),
|
||||||
areWeAdmin: this.areWeAdmin(),
|
areWeAdmin: this.areWeAdmin(),
|
||||||
canChangeTimer: this.canChangeTimer(),
|
canChangeTimer: this.canChangeTimer(),
|
||||||
canEditGroupInfo: this.canEditGroupInfo(),
|
canEditGroupInfo: this.canEditGroupInfo(),
|
||||||
|
@ -1353,9 +1340,7 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
lastUpdated: this.get('timestamp')!,
|
lastUpdated: this.get('timestamp')!,
|
||||||
left: Boolean(this.get('left')),
|
left: Boolean(this.get('left')),
|
||||||
markedUnread: this.get('markedUnread')!,
|
markedUnread: this.get('markedUnread')!,
|
||||||
membersCount: this.isPrivate()
|
membersCount: this.getMembersCount(),
|
||||||
? undefined
|
|
||||||
: (this.get('membersV2')! || this.get('members')! || []).length,
|
|
||||||
memberships: this.getMemberships(),
|
memberships: this.getMemberships(),
|
||||||
pendingMemberships: this.getPendingMemberships(),
|
pendingMemberships: this.getPendingMemberships(),
|
||||||
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
||||||
|
@ -1427,6 +1412,26 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMembersCount(): number {
|
||||||
|
if (this.isPrivate()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberList = this.get('membersV2') || this.get('members');
|
||||||
|
|
||||||
|
// We'll fail over if the member list is empty
|
||||||
|
if (memberList && memberList.length) {
|
||||||
|
return memberList.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryMemberCount = this.get('temporaryMemberCount');
|
||||||
|
if (window._.isNumber(temporaryMemberCount)) {
|
||||||
|
return temporaryMemberCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
decrementMessageCount(): void {
|
decrementMessageCount(): void {
|
||||||
this.set({
|
this.set({
|
||||||
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
|
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
|
||||||
|
@ -1623,6 +1628,98 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async joinGroupV2ViaLinkAndMigrate({
|
||||||
|
approvalRequired,
|
||||||
|
inviteLinkPassword,
|
||||||
|
revision,
|
||||||
|
}: {
|
||||||
|
approvalRequired: boolean;
|
||||||
|
inviteLinkPassword: string;
|
||||||
|
revision: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
await window.Signal.Groups.joinGroupV2ViaLinkAndMigrate({
|
||||||
|
approvalRequired,
|
||||||
|
conversation: this,
|
||||||
|
inviteLinkPassword,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinGroupV2ViaLink({
|
||||||
|
inviteLinkPassword,
|
||||||
|
approvalRequired,
|
||||||
|
}: {
|
||||||
|
inviteLinkPassword: string;
|
||||||
|
approvalRequired: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
try {
|
||||||
|
if (approvalRequired) {
|
||||||
|
await this.modifyGroupV2({
|
||||||
|
name: 'requestToJoin',
|
||||||
|
inviteLinkPassword,
|
||||||
|
createGroupChange: () => this.addPendingApprovalRequest(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.modifyGroupV2({
|
||||||
|
name: 'joinGroup',
|
||||||
|
inviteLinkPassword,
|
||||||
|
createGroupChange: () => this.addMember(ourConversationId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const ALREADY_REQUESTED_TO_JOIN =
|
||||||
|
'{"code":400,"message":"cannot ask to join via invite link if already asked to join"}';
|
||||||
|
if (!error.response) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
const errorDetails = stringFromBytes(error.response);
|
||||||
|
if (errorDetails !== ALREADY_REQUESTED_TO_JOIN) {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
window.log.info(
|
||||||
|
'joinGroupV2ViaLink: Got 400, but server is telling us we have already requested to join. Forcing that local state'
|
||||||
|
);
|
||||||
|
this.set({
|
||||||
|
pendingAdminApprovalV2: [
|
||||||
|
{
|
||||||
|
conversationId: ourConversationId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageRequestEnum =
|
||||||
|
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
||||||
|
|
||||||
|
// Ensure active_at is set, because this is an event that justifies putting the group
|
||||||
|
// in the left pane.
|
||||||
|
this.set({
|
||||||
|
messageRequestResponseType: messageRequestEnum.ACCEPT,
|
||||||
|
active_at: this.get('active_at') || Date.now(),
|
||||||
|
});
|
||||||
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelJoinRequest(): Promise<void> {
|
||||||
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
|
||||||
|
const inviteLinkPassword = this.get('groupInviteLinkPassword');
|
||||||
|
if (!inviteLinkPassword) {
|
||||||
|
throw new Error('Missing groupInviteLinkPassword!');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.modifyGroupV2({
|
||||||
|
name: 'cancelJoinRequest',
|
||||||
|
inviteLinkPassword,
|
||||||
|
createGroupChange: () =>
|
||||||
|
this.denyPendingApprovalRequest(ourConversationId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async leaveGroupV2(): Promise<void> {
|
async leaveGroupV2(): Promise<void> {
|
||||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
|
|
|
@ -61,6 +61,7 @@ export type ConversationType = {
|
||||||
avatarPath?: string;
|
avatarPath?: string;
|
||||||
areWeAdmin?: boolean;
|
areWeAdmin?: boolean;
|
||||||
areWePending?: boolean;
|
areWePending?: boolean;
|
||||||
|
areWePendingApproval?: boolean;
|
||||||
canChangeTimer?: boolean;
|
canChangeTimer?: boolean;
|
||||||
canEditGroupInfo?: boolean;
|
canEditGroupInfo?: boolean;
|
||||||
color?: ColorType;
|
color?: ColorType;
|
||||||
|
@ -208,7 +209,18 @@ export type MessagesByConversationType = {
|
||||||
[key: string]: ConversationMessageType | undefined;
|
[key: string]: ConversationMessageType | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PreJoinConversationType = {
|
||||||
|
avatar?: {
|
||||||
|
loading?: boolean;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
memberCount: number;
|
||||||
|
title: string;
|
||||||
|
approvalRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConversationsStateType = {
|
export type ConversationsStateType = {
|
||||||
|
preJoinConversation?: PreJoinConversationType;
|
||||||
conversationLookup: ConversationLookupType;
|
conversationLookup: ConversationLookupType;
|
||||||
conversationsByE164: ConversationLookupType;
|
conversationsByE164: ConversationLookupType;
|
||||||
conversationsByUuid: ConversationLookupType;
|
conversationsByUuid: ConversationLookupType;
|
||||||
|
@ -252,6 +264,13 @@ export const getConversationCallMode = (
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
|
type SetPreJoinConversationActionType = {
|
||||||
|
type: 'SET_PRE_JOIN_CONVERSATION';
|
||||||
|
payload: {
|
||||||
|
data: PreJoinConversationType | undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ConversationAddedActionType = {
|
type ConversationAddedActionType = {
|
||||||
type: 'CONVERSATION_ADDED';
|
type: 'CONVERSATION_ADDED';
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -421,34 +440,33 @@ type SetRecentMediaItemsActionType = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationActionType =
|
export type ConversationActionType =
|
||||||
|
| ClearChangedMessagesActionType
|
||||||
|
| ClearSelectedMessageActionType
|
||||||
|
| ClearUnreadMetricsActionType
|
||||||
| ConversationAddedActionType
|
| ConversationAddedActionType
|
||||||
| ConversationChangedActionType
|
| ConversationChangedActionType
|
||||||
| ConversationRemovedActionType
|
| ConversationRemovedActionType
|
||||||
| ConversationUnloadedActionType
|
| ConversationUnloadedActionType
|
||||||
| RemoveAllConversationsActionType
|
|
||||||
| MessageSelectedActionType
|
|
||||||
| MessageSizeChangedActionType
|
|
||||||
| MessageChangedActionType
|
| MessageChangedActionType
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| MessagesAddedActionType
|
| MessagesAddedActionType
|
||||||
|
| MessageSelectedActionType
|
||||||
|
| MessageSizeChangedActionType
|
||||||
|
| MessagesResetActionType
|
||||||
|
| RemoveAllConversationsActionType
|
||||||
| RepairNewestMessageActionType
|
| RepairNewestMessageActionType
|
||||||
| RepairOldestMessageActionType
|
| RepairOldestMessageActionType
|
||||||
| MessagesResetActionType
|
| ScrollToMessageActionType
|
||||||
| SetMessagesLoadingActionType
|
| SelectedConversationChangedActionType
|
||||||
|
| SetConversationHeaderTitleActionType
|
||||||
| SetIsNearBottomActionType
|
| SetIsNearBottomActionType
|
||||||
| SetLoadCountdownStartActionType
|
| SetLoadCountdownStartActionType
|
||||||
| ClearChangedMessagesActionType
|
| SetMessagesLoadingActionType
|
||||||
| ClearSelectedMessageActionType
|
| SetPreJoinConversationActionType
|
||||||
| ClearUnreadMetricsActionType
|
|
||||||
| ScrollToMessageActionType
|
|
||||||
| SetConversationHeaderTitleActionType
|
|
||||||
| SetSelectedConversationPanelDepthActionType
|
|
||||||
| SelectedConversationChangedActionType
|
|
||||||
| MessageDeletedActionType
|
|
||||||
| SelectedConversationChangedActionType
|
|
||||||
| SetRecentMediaItemsActionType
|
| SetRecentMediaItemsActionType
|
||||||
| ShowInboxActionType
|
| SetSelectedConversationPanelDepthActionType
|
||||||
| ShowArchivedConversationsActionType;
|
| ShowArchivedConversationsActionType
|
||||||
|
| ShowInboxActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
|
@ -462,8 +480,8 @@ export const actions = {
|
||||||
conversationUnloaded,
|
conversationUnloaded,
|
||||||
messageChanged,
|
messageChanged,
|
||||||
messageDeleted,
|
messageDeleted,
|
||||||
messageSizeChanged,
|
|
||||||
messagesAdded,
|
messagesAdded,
|
||||||
|
messageSizeChanged,
|
||||||
messagesReset,
|
messagesReset,
|
||||||
openConversationExternal,
|
openConversationExternal,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
|
@ -475,6 +493,7 @@ export const actions = {
|
||||||
setIsNearBottom,
|
setIsNearBottom,
|
||||||
setLoadCountdownStart,
|
setLoadCountdownStart,
|
||||||
setMessagesLoading,
|
setMessagesLoading,
|
||||||
|
setPreJoinConversation,
|
||||||
setRecentMediaItems,
|
setRecentMediaItems,
|
||||||
setSelectedConversationHeaderTitle,
|
setSelectedConversationHeaderTitle,
|
||||||
setSelectedConversationPanelDepth,
|
setSelectedConversationPanelDepth,
|
||||||
|
@ -482,6 +501,16 @@ export const actions = {
|
||||||
showInbox,
|
showInbox,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function setPreJoinConversation(
|
||||||
|
data: PreJoinConversationType | undefined
|
||||||
|
): SetPreJoinConversationActionType {
|
||||||
|
return {
|
||||||
|
type: 'SET_PRE_JOIN_CONVERSATION',
|
||||||
|
payload: {
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
function conversationAdded(
|
function conversationAdded(
|
||||||
id: string,
|
id: string,
|
||||||
data: ConversationType
|
data: ConversationType
|
||||||
|
@ -924,6 +953,15 @@ export function reducer(
|
||||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||||
action: Readonly<ConversationActionType>
|
action: Readonly<ConversationActionType>
|
||||||
): ConversationsStateType {
|
): ConversationsStateType {
|
||||||
|
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
|
||||||
|
const { payload } = action;
|
||||||
|
const { data } = payload;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
preJoinConversation: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (action.type === 'CONVERSATION_ADDED') {
|
if (action.type === 'CONVERSATION_ADDED') {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const { id, data } = payload;
|
const { id, data } = payload;
|
||||||
|
|
25
ts/state/roots/createGroupV2JoinModal.tsx
Normal file
25
ts/state/roots/createGroupV2JoinModal.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
import { Store } from 'redux';
|
||||||
|
|
||||||
|
import { ModalHost } from '../../components/ModalHost';
|
||||||
|
import { SmartGroupV2JoinDialog, PropsType } from '../smart/GroupV2JoinDialog';
|
||||||
|
|
||||||
|
export const createGroupV2JoinModal = (
|
||||||
|
store: Store,
|
||||||
|
props: PropsType
|
||||||
|
): React.ReactElement => {
|
||||||
|
const { onClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<ModalHost onClose={onClose}>
|
||||||
|
<SmartGroupV2JoinDialog {...props} />
|
||||||
|
</ModalHost>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -14,6 +14,7 @@ import {
|
||||||
MessageLookupType,
|
MessageLookupType,
|
||||||
MessagesByConversationType,
|
MessagesByConversationType,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import type { CallsByConversationType } from '../ducks/calling';
|
import type { CallsByConversationType } from '../ducks/calling';
|
||||||
|
@ -48,6 +49,12 @@ export const getPlaceholderContact = (): ConversationType => {
|
||||||
export const getConversations = (state: StateType): ConversationsStateType =>
|
export const getConversations = (state: StateType): ConversationsStateType =>
|
||||||
state.conversations;
|
state.conversations;
|
||||||
|
|
||||||
|
export const getPreJoinConversation = createSelector(
|
||||||
|
getConversations,
|
||||||
|
(state: ConversationsStateType): PreJoinConversationType | undefined => {
|
||||||
|
return state.preJoinConversation;
|
||||||
|
}
|
||||||
|
);
|
||||||
export const getConversationLookup = createSelector(
|
export const getConversationLookup = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
(state: ConversationsStateType): ConversationLookupType => {
|
(state: ConversationsStateType): ConversationLookupType => {
|
||||||
|
|
36
ts/state/smart/GroupV2JoinDialog.tsx
Normal file
36
ts/state/smart/GroupV2JoinDialog.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
import {
|
||||||
|
GroupV2JoinDialog,
|
||||||
|
PropsType as GroupV2JoinDialogPropsType,
|
||||||
|
} from '../../components/GroupV2JoinDialog';
|
||||||
|
import { StateType } from '../reducer';
|
||||||
|
|
||||||
|
import { getIntl } from '../selectors/user';
|
||||||
|
import { getPreJoinConversation } from '../selectors/conversations';
|
||||||
|
|
||||||
|
export type PropsType = Pick<GroupV2JoinDialogPropsType, 'join' | 'onClose'>;
|
||||||
|
|
||||||
|
const mapStateToProps = (
|
||||||
|
state: StateType,
|
||||||
|
props: PropsType
|
||||||
|
): GroupV2JoinDialogPropsType => {
|
||||||
|
const preJoinConversation = getPreJoinConversation(state);
|
||||||
|
|
||||||
|
if (!preJoinConversation) {
|
||||||
|
throw new Error('smart/GroupV2JoinDialog: No pre-join conversation!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
...preJoinConversation,
|
||||||
|
i18n: getIntl(state),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const smart = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
|
export const SmartGroupV2JoinDialog = smart(GroupV2JoinDialog);
|
85
ts/test-both/util/webSafeBase64_test.ts
Normal file
85
ts/test-both/util/webSafeBase64_test.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { toWebSafeBase64, fromWebSafeBase64 } from '../../util/webSafeBase64';
|
||||||
|
|
||||||
|
describe('both/util/webSafeBase64', () => {
|
||||||
|
it('roundtrips with all elements', () => {
|
||||||
|
const base64 = 'X0KjoAj3h7Tu9YjJ++PamFc4kAg//D4FKommANpP41I=';
|
||||||
|
|
||||||
|
const webSafe = toWebSafeBase64(base64);
|
||||||
|
const actual = fromWebSafeBase64(webSafe);
|
||||||
|
|
||||||
|
assert.strictEqual(base64, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#toWebSafeBase64', () => {
|
||||||
|
it('replaces +', () => {
|
||||||
|
const base64 = 'X++y';
|
||||||
|
const expected = 'X--y';
|
||||||
|
const actual = toWebSafeBase64(base64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces /', () => {
|
||||||
|
const base64 = 'X//y';
|
||||||
|
const expected = 'X__y';
|
||||||
|
const actual = toWebSafeBase64(base64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes =', () => {
|
||||||
|
const base64 = 'X===';
|
||||||
|
const expected = 'X';
|
||||||
|
const actual = toWebSafeBase64(base64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#fromWebSafeBase64', () => {
|
||||||
|
it('replaces -', () => {
|
||||||
|
const webSafeBase64 = 'X--y';
|
||||||
|
const expected = 'X++y';
|
||||||
|
const actual = fromWebSafeBase64(webSafeBase64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces _', () => {
|
||||||
|
const webSafeBase64 = 'X__y';
|
||||||
|
const expected = 'X//y';
|
||||||
|
const actual = fromWebSafeBase64(webSafeBase64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds ===', () => {
|
||||||
|
const webSafeBase64 = 'X';
|
||||||
|
const expected = 'X===';
|
||||||
|
const actual = fromWebSafeBase64(webSafeBase64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds ==', () => {
|
||||||
|
const webSafeBase64 = 'Xy';
|
||||||
|
const expected = 'Xy==';
|
||||||
|
const actual = fromWebSafeBase64(webSafeBase64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds =', () => {
|
||||||
|
const webSafeBase64 = 'XyZ';
|
||||||
|
const expected = 'XyZ=';
|
||||||
|
const actual = fromWebSafeBase64(webSafeBase64);
|
||||||
|
|
||||||
|
assert.strictEqual(expected, actual);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -20,6 +20,7 @@ const {
|
||||||
messageSizeChanged,
|
messageSizeChanged,
|
||||||
repairNewestMessage,
|
repairNewestMessage,
|
||||||
repairOldestMessage,
|
repairOldestMessage,
|
||||||
|
setPreJoinConversation,
|
||||||
} = actions;
|
} = actions;
|
||||||
|
|
||||||
describe('both/state/ducks/conversations', () => {
|
describe('both/state/ducks/conversations', () => {
|
||||||
|
@ -577,5 +578,39 @@ describe('both/state/ducks/conversations', () => {
|
||||||
assert.equal(actual, state);
|
assert.equal(actual, state);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SET_PRE_JOIN_CONVERSATION', () => {
|
||||||
|
const startState = {
|
||||||
|
...getEmptyState(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('starts with empty value', () => {
|
||||||
|
assert.isUndefined(startState.preJoinConversation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets value as provided', () => {
|
||||||
|
const preJoinConversation = {
|
||||||
|
title: 'Pre-join group!',
|
||||||
|
memberCount: 4,
|
||||||
|
approvalRequired: false,
|
||||||
|
};
|
||||||
|
const stateWithData = reducer(
|
||||||
|
startState,
|
||||||
|
setPreJoinConversation(preJoinConversation)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
stateWithData.preJoinConversation,
|
||||||
|
preJoinConversation
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetState = reducer(
|
||||||
|
stateWithData,
|
||||||
|
setPreJoinConversation(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.isUndefined(resetState.preJoinConversation);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -100,11 +100,12 @@ describe('sgnlHref', () => {
|
||||||
'sgnl://foo?',
|
'sgnl://foo?',
|
||||||
'SGNL://foo?',
|
'SGNL://foo?',
|
||||||
'sgnl://user:pass@foo',
|
'sgnl://user:pass@foo',
|
||||||
'sgnl://foo/path/data#hash-data',
|
'sgnl://foo/path/data',
|
||||||
].forEach(href => {
|
].forEach(href => {
|
||||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
||||||
command: 'foo',
|
command: 'foo',
|
||||||
args: new Map<string, string>(),
|
args: new Map<string, string>(),
|
||||||
|
hash: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -124,6 +125,7 @@ describe('sgnlHref', () => {
|
||||||
['empty', ''],
|
['empty', ''],
|
||||||
['encoded', 'hello world'],
|
['encoded', 'hello world'],
|
||||||
]),
|
]),
|
||||||
|
hash: undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -144,17 +146,30 @@ describe('sgnlHref', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes hash', () => {
|
||||||
|
[
|
||||||
|
'sgnl://foo?bar=baz#somehash',
|
||||||
|
'sgnl://user:pass@foo?bar=baz#somehash',
|
||||||
|
].forEach(href => {
|
||||||
|
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
||||||
|
command: 'foo',
|
||||||
|
args: new Map([['bar', 'baz']]),
|
||||||
|
hash: 'somehash',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores other parts of the URL', () => {
|
it('ignores other parts of the URL', () => {
|
||||||
[
|
[
|
||||||
'sgnl://foo?bar=baz',
|
'sgnl://foo?bar=baz',
|
||||||
'sgnl://foo/?bar=baz',
|
'sgnl://foo/?bar=baz',
|
||||||
'sgnl://foo/lots/of/path?bar=baz',
|
'sgnl://foo/lots/of/path?bar=baz',
|
||||||
'sgnl://foo?bar=baz#hash',
|
|
||||||
'sgnl://user:pass@foo?bar=baz',
|
'sgnl://user:pass@foo?bar=baz',
|
||||||
].forEach(href => {
|
].forEach(href => {
|
||||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
||||||
command: 'foo',
|
command: 'foo',
|
||||||
args: new Map([['bar', 'baz']]),
|
args: new Map([['bar', 'baz']]),
|
||||||
|
hash: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
36
ts/textsecure.d.ts
vendored
36
ts/textsecure.d.ts
vendored
|
@ -176,6 +176,7 @@ type GroupsProtobufTypes = {
|
||||||
GroupAttributeBlob: typeof GroupAttributeBlobClass;
|
GroupAttributeBlob: typeof GroupAttributeBlobClass;
|
||||||
GroupExternalCredential: typeof GroupExternalCredentialClass;
|
GroupExternalCredential: typeof GroupExternalCredentialClass;
|
||||||
GroupInviteLink: typeof GroupInviteLinkClass;
|
GroupInviteLink: typeof GroupInviteLinkClass;
|
||||||
|
GroupJoinInfo: typeof GroupJoinInfoClass;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SignalServiceProtobufTypes = {
|
type SignalServiceProtobufTypes = {
|
||||||
|
@ -494,6 +495,22 @@ export declare namespace GroupChangesClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export declare class GroupAttributeBlobClass {
|
||||||
|
static decode: (
|
||||||
|
data: ArrayBuffer | ByteBufferClass,
|
||||||
|
encoding?: string
|
||||||
|
) => GroupAttributeBlobClass;
|
||||||
|
toArrayBuffer(): ArrayBuffer;
|
||||||
|
|
||||||
|
title?: string;
|
||||||
|
avatar?: ProtoBinaryType;
|
||||||
|
disappearingMessagesDuration?: number;
|
||||||
|
|
||||||
|
// Note: this isn't part of the proto, but our protobuf library tells us which
|
||||||
|
// field has been set with this prop.
|
||||||
|
content: 'title' | 'avatar' | 'disappearingMessagesDuration';
|
||||||
|
}
|
||||||
|
|
||||||
export declare class GroupExternalCredentialClass {
|
export declare class GroupExternalCredentialClass {
|
||||||
static decode: (
|
static decode: (
|
||||||
data: ArrayBuffer | ByteBufferClass,
|
data: ArrayBuffer | ByteBufferClass,
|
||||||
|
@ -524,20 +541,19 @@ export declare namespace GroupInviteLinkClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare class GroupAttributeBlobClass {
|
export declare class GroupJoinInfoClass {
|
||||||
static decode: (
|
static decode: (
|
||||||
data: ArrayBuffer | ByteBufferClass,
|
data: ArrayBuffer | ByteBufferClass,
|
||||||
encoding?: string
|
encoding?: string
|
||||||
) => GroupAttributeBlobClass;
|
) => GroupJoinInfoClass;
|
||||||
toArrayBuffer(): ArrayBuffer;
|
|
||||||
|
|
||||||
title?: string;
|
publicKey?: ProtoBinaryType;
|
||||||
avatar?: ProtoBinaryType;
|
title?: ProtoBinaryType;
|
||||||
disappearingMessagesDuration?: number;
|
avatar?: string;
|
||||||
|
memberCount?: number;
|
||||||
// Note: this isn't part of the proto, but our protobuf library tells us which
|
addFromInviteLink?: AccessControlClass.AccessRequired;
|
||||||
// field has been set with this prop.
|
version?: number;
|
||||||
content: 'title' | 'avatar' | 'disappearingMessagesDuration';
|
pendingAdminApproval?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previous protos
|
// Previous protos
|
||||||
|
|
|
@ -476,7 +476,7 @@ export default class OutgoingMessage {
|
||||||
if (error.code === 409) {
|
if (error.code === 409) {
|
||||||
p = this.removeDeviceIdsForIdentifier(
|
p = this.removeDeviceIdsForIdentifier(
|
||||||
identifier,
|
identifier,
|
||||||
error.response.extraDevices
|
error.response.extraDevices || []
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
p = Promise.all(
|
p = Promise.all(
|
||||||
|
|
|
@ -35,6 +35,7 @@ import {
|
||||||
GroupChangeClass,
|
GroupChangeClass,
|
||||||
GroupClass,
|
GroupClass,
|
||||||
GroupExternalCredentialClass,
|
GroupExternalCredentialClass,
|
||||||
|
GroupJoinInfoClass,
|
||||||
StorageServiceCallOptionsType,
|
StorageServiceCallOptionsType,
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
SyncMessageClass,
|
SyncMessageClass,
|
||||||
|
@ -1769,6 +1770,13 @@ export default class MessageSender {
|
||||||
return this.server.getGroup(options);
|
return this.server.getGroup(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupFromLink(
|
||||||
|
groupInviteLink: string,
|
||||||
|
auth: GroupCredentialsType
|
||||||
|
): Promise<GroupJoinInfoClass> {
|
||||||
|
return this.server.getGroupFromLink(groupInviteLink, auth);
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupLog(
|
async getGroupLog(
|
||||||
startVersion: number,
|
startVersion: number,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType
|
||||||
|
@ -1782,9 +1790,10 @@ export default class MessageSender {
|
||||||
|
|
||||||
async modifyGroup(
|
async modifyGroup(
|
||||||
changes: GroupChangeClass.Actions,
|
changes: GroupChangeClass.Actions,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType,
|
||||||
|
inviteLinkBase64?: string
|
||||||
): Promise<GroupChangeClass> {
|
): Promise<GroupChangeClass> {
|
||||||
return this.server.modifyGroup(changes, options);
|
return this.server.modifyGroup(changes, options, inviteLinkBase64);
|
||||||
}
|
}
|
||||||
|
|
||||||
async leaveGroup(
|
async leaveGroup(
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { v4 as getGuid } from 'uuid';
|
||||||
|
|
||||||
import { Long } from '../window.d';
|
import { Long } from '../window.d';
|
||||||
import { getUserAgent } from '../util/getUserAgent';
|
import { getUserAgent } from '../util/getUserAgent';
|
||||||
|
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
||||||
import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
|
import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
|
||||||
import {
|
import {
|
||||||
arrayBufferToBase64,
|
arrayBufferToBase64,
|
||||||
|
@ -50,6 +51,7 @@ import {
|
||||||
GroupChangeClass,
|
GroupChangeClass,
|
||||||
GroupChangesClass,
|
GroupChangesClass,
|
||||||
GroupClass,
|
GroupClass,
|
||||||
|
GroupJoinInfoClass,
|
||||||
GroupExternalCredentialClass,
|
GroupExternalCredentialClass,
|
||||||
StorageServiceCallOptionsType,
|
StorageServiceCallOptionsType,
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
|
@ -58,6 +60,11 @@ import {
|
||||||
import { WebSocket } from './WebSocket';
|
import { WebSocket } from './WebSocket';
|
||||||
import MessageSender from './SendMessage';
|
import MessageSender from './SendMessage';
|
||||||
|
|
||||||
|
// Note: this will break some code that expects to be able to use err.response when a
|
||||||
|
// web request fails, because it will force it to text. But it is very useful for
|
||||||
|
// debugging failed requests.
|
||||||
|
const DEBUG = false;
|
||||||
|
|
||||||
type SgxConstantsType = {
|
type SgxConstantsType = {
|
||||||
SGX_FLAGS_INITTED: Long;
|
SGX_FLAGS_INITTED: Long;
|
||||||
SGX_FLAGS_DEBUG: Long;
|
SGX_FLAGS_DEBUG: Long;
|
||||||
|
@ -340,6 +347,10 @@ type ArrayBufferWithDetailsType = {
|
||||||
response: Response;
|
response: Response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isSuccess(status: number): boolean {
|
||||||
|
return status >= 0 && status < 400;
|
||||||
|
}
|
||||||
|
|
||||||
async function _promiseAjax(
|
async function _promiseAjax(
|
||||||
providedUrl: string | null,
|
providedUrl: string | null,
|
||||||
options: PromiseAjaxOptionsType
|
options: PromiseAjaxOptionsType
|
||||||
|
@ -432,7 +443,9 @@ async function _promiseAjax(
|
||||||
}
|
}
|
||||||
|
|
||||||
let resultPromise;
|
let resultPromise;
|
||||||
if (
|
if (DEBUG && !isSuccess(response.status)) {
|
||||||
|
resultPromise = response.text();
|
||||||
|
} else if (
|
||||||
(options.responseType === 'json' ||
|
(options.responseType === 'json' ||
|
||||||
options.responseType === 'jsonwithdetails') &&
|
options.responseType === 'jsonwithdetails') &&
|
||||||
response.headers.get('Content-Type') === 'application/json'
|
response.headers.get('Content-Type') === 'application/json'
|
||||||
|
@ -448,6 +461,7 @@ async function _promiseAjax(
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultPromise.then(result => {
|
return resultPromise.then(result => {
|
||||||
|
if (isSuccess(response.status)) {
|
||||||
if (
|
if (
|
||||||
options.responseType === 'arraybuffer' ||
|
options.responseType === 'arraybuffer' ||
|
||||||
options.responseType === 'arraybufferwithdetails'
|
options.responseType === 'arraybufferwithdetails'
|
||||||
|
@ -471,7 +485,12 @@ async function _promiseAjax(
|
||||||
'Error'
|
'Error'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
window.log.error(options.type, url, response.status, 'Error');
|
window.log.error(
|
||||||
|
options.type,
|
||||||
|
url,
|
||||||
|
response.status,
|
||||||
|
'Error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
reject(
|
reject(
|
||||||
makeHTTPError(
|
makeHTTPError(
|
||||||
|
@ -486,7 +505,7 @@ async function _promiseAjax(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.status >= 0 && response.status < 400) {
|
|
||||||
if (options.redactUrl) {
|
if (options.redactUrl) {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
options.type,
|
options.type,
|
||||||
|
@ -605,6 +624,10 @@ function makeHTTPError(
|
||||||
const e = new Error(`${message}; code: ${code}`);
|
const e = new Error(`${message}; code: ${code}`);
|
||||||
e.name = 'HTTPError';
|
e.name = 'HTTPError';
|
||||||
e.code = code;
|
e.code = code;
|
||||||
|
if (DEBUG && response) {
|
||||||
|
e.stack += `\nresponse: ${response}`;
|
||||||
|
}
|
||||||
|
|
||||||
e.stack += `\nOriginal stack:\n${stack}`;
|
e.stack += `\nOriginal stack:\n${stack}`;
|
||||||
if (response) {
|
if (response) {
|
||||||
e.response = response;
|
e.response = response;
|
||||||
|
@ -628,6 +651,7 @@ const URL_CALLS = {
|
||||||
getStickerPackUpload: 'v1/sticker/pack/form',
|
getStickerPackUpload: 'v1/sticker/pack/form',
|
||||||
groupLog: 'v1/groups/logs',
|
groupLog: 'v1/groups/logs',
|
||||||
groups: 'v1/groups',
|
groups: 'v1/groups',
|
||||||
|
groupsViaLink: 'v1/groups/join',
|
||||||
groupToken: 'v1/groups/token',
|
groupToken: 'v1/groups/token',
|
||||||
keys: 'v2/keys',
|
keys: 'v2/keys',
|
||||||
messages: 'v1/messages',
|
messages: 'v1/messages',
|
||||||
|
@ -734,6 +758,10 @@ export type WebAPIType = {
|
||||||
getAvatar: (path: string) => Promise<any>;
|
getAvatar: (path: string) => Promise<any>;
|
||||||
getDevices: () => Promise<any>;
|
getDevices: () => Promise<any>;
|
||||||
getGroup: (options: GroupCredentialsType) => Promise<GroupClass>;
|
getGroup: (options: GroupCredentialsType) => Promise<GroupClass>;
|
||||||
|
getGroupFromLink: (
|
||||||
|
inviteLinkPassword: string,
|
||||||
|
auth: GroupCredentialsType
|
||||||
|
) => Promise<GroupJoinInfoClass>;
|
||||||
getGroupAvatar: (key: string) => Promise<ArrayBuffer>;
|
getGroupAvatar: (key: string) => Promise<ArrayBuffer>;
|
||||||
getGroupCredentials: (
|
getGroupCredentials: (
|
||||||
startDay: number,
|
startDay: number,
|
||||||
|
@ -803,7 +831,8 @@ export type WebAPIType = {
|
||||||
) => Promise<ArrayBufferWithDetailsType>;
|
) => Promise<ArrayBufferWithDetailsType>;
|
||||||
modifyGroup: (
|
modifyGroup: (
|
||||||
changes: GroupChangeClass.Actions,
|
changes: GroupChangeClass.Actions,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType,
|
||||||
|
inviteLinkBase64?: string
|
||||||
) => Promise<GroupChangeClass>;
|
) => Promise<GroupChangeClass>;
|
||||||
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
||||||
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
||||||
|
@ -955,6 +984,8 @@ export function initialize({
|
||||||
return {
|
return {
|
||||||
confirmCode,
|
confirmCode,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
fetchLinkPreviewImage,
|
||||||
|
fetchLinkPreviewMetadata,
|
||||||
getAttachment,
|
getAttachment,
|
||||||
getAvatar,
|
getAvatar,
|
||||||
getConfig,
|
getConfig,
|
||||||
|
@ -963,6 +994,7 @@ export function initialize({
|
||||||
getGroupAvatar,
|
getGroupAvatar,
|
||||||
getGroupCredentials,
|
getGroupCredentials,
|
||||||
getGroupExternalCredential,
|
getGroupExternalCredential,
|
||||||
|
getGroupFromLink,
|
||||||
getGroupLog,
|
getGroupLog,
|
||||||
getIceServers,
|
getIceServers,
|
||||||
getKeysForIdentifier,
|
getKeysForIdentifier,
|
||||||
|
@ -979,8 +1011,6 @@ export function initialize({
|
||||||
getStorageManifest,
|
getStorageManifest,
|
||||||
getStorageRecords,
|
getStorageRecords,
|
||||||
getUuidsForE164s,
|
getUuidsForE164s,
|
||||||
fetchLinkPreviewMetadata,
|
|
||||||
fetchLinkPreviewImage,
|
|
||||||
makeProxiedRequest,
|
makeProxiedRequest,
|
||||||
makeSfuRequest,
|
makeSfuRequest,
|
||||||
modifyGroup,
|
modifyGroup,
|
||||||
|
@ -2052,9 +2082,32 @@ export function initialize({
|
||||||
return window.textsecure.protobuf.Group.decode(response);
|
return window.textsecure.protobuf.Group.decode(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getGroupFromLink(
|
||||||
|
inviteLinkPassword: string,
|
||||||
|
auth: GroupCredentialsType
|
||||||
|
): Promise<GroupJoinInfoClass> {
|
||||||
|
const basicAuth = generateGroupAuth(
|
||||||
|
auth.groupPublicParamsHex,
|
||||||
|
auth.authCredentialPresentationHex
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ArrayBuffer = await _ajax({
|
||||||
|
basicAuth,
|
||||||
|
call: 'groupsViaLink',
|
||||||
|
contentType: 'application/x-protobuf',
|
||||||
|
host: storageUrl,
|
||||||
|
httpType: 'GET',
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
urlParameters: `/${toWebSafeBase64(inviteLinkPassword)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return window.textsecure.protobuf.GroupJoinInfo.decode(response);
|
||||||
|
}
|
||||||
|
|
||||||
async function modifyGroup(
|
async function modifyGroup(
|
||||||
changes: GroupChangeClass.Actions,
|
changes: GroupChangeClass.Actions,
|
||||||
options: GroupCredentialsType
|
options: GroupCredentialsType,
|
||||||
|
inviteLinkBase64?: string
|
||||||
): Promise<GroupChangeClass> {
|
): Promise<GroupChangeClass> {
|
||||||
const basicAuth = generateGroupAuth(
|
const basicAuth = generateGroupAuth(
|
||||||
options.groupPublicParamsHex,
|
options.groupPublicParamsHex,
|
||||||
|
@ -2070,6 +2123,9 @@ export function initialize({
|
||||||
host: storageUrl,
|
host: storageUrl,
|
||||||
httpType: 'PATCH',
|
httpType: 'PATCH',
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
|
urlParameters: inviteLinkBase64
|
||||||
|
? `?inviteLinkPassword=${toWebSafeBase64(inviteLinkBase64)}`
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return window.textsecure.protobuf.GroupChange.decode(response);
|
return window.textsecure.protobuf.GroupChange.decode(response);
|
||||||
|
|
|
@ -22,6 +22,8 @@ import { makeLookup } from './makeLookup';
|
||||||
import { missingCaseError } from './missingCaseError';
|
import { missingCaseError } from './missingCaseError';
|
||||||
import { parseRemoteClientExpiration } from './parseRemoteClientExpiration';
|
import { parseRemoteClientExpiration } from './parseRemoteClientExpiration';
|
||||||
import { sleep } from './sleep';
|
import { sleep } from './sleep';
|
||||||
|
import { longRunningTaskWrapper } from './longRunningTaskWrapper';
|
||||||
|
import { toWebSafeBase64, fromWebSafeBase64 } from './webSafeBase64';
|
||||||
import * as zkgroup from './zkgroup';
|
import * as zkgroup from './zkgroup';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -31,6 +33,7 @@ export {
|
||||||
createWaitBatcher,
|
createWaitBatcher,
|
||||||
deleteForEveryone,
|
deleteForEveryone,
|
||||||
downloadAttachment,
|
downloadAttachment,
|
||||||
|
fromWebSafeBase64,
|
||||||
generateSecurityNumber,
|
generateSecurityNumber,
|
||||||
getSafetyNumberPlaceholder,
|
getSafetyNumberPlaceholder,
|
||||||
getStringForProfileChange,
|
getStringForProfileChange,
|
||||||
|
@ -39,10 +42,12 @@ export {
|
||||||
GoogleChrome,
|
GoogleChrome,
|
||||||
hasExpired,
|
hasExpired,
|
||||||
isFileDangerous,
|
isFileDangerous,
|
||||||
|
longRunningTaskWrapper,
|
||||||
makeLookup,
|
makeLookup,
|
||||||
missingCaseError,
|
missingCaseError,
|
||||||
parseRemoteClientExpiration,
|
parseRemoteClientExpiration,
|
||||||
Registration,
|
Registration,
|
||||||
sleep,
|
sleep,
|
||||||
|
toWebSafeBase64,
|
||||||
zkgroup,
|
zkgroup,
|
||||||
};
|
};
|
||||||
|
|
|
@ -14478,7 +14478,7 @@
|
||||||
"rule": "DOM-innerHTML",
|
"rule": "DOM-innerHTML",
|
||||||
"path": "ts/components/CompositionArea.js",
|
"path": "ts/components/CompositionArea.js",
|
||||||
"line": " el.innerHTML = '';",
|
"line": " el.innerHTML = '';",
|
||||||
"lineNumber": 41,
|
"lineNumber": 42,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-05-20T20:10:43.540Z",
|
"updated": "2020-05-20T20:10:43.540Z",
|
||||||
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
||||||
|
@ -14487,7 +14487,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionArea.js",
|
"path": "ts/components/CompositionArea.js",
|
||||||
"line": " const inputApiRef = React.useRef();",
|
"line": " const inputApiRef = React.useRef();",
|
||||||
"lineNumber": 59,
|
"lineNumber": 62,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14496,7 +14496,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionArea.js",
|
"path": "ts/components/CompositionArea.js",
|
||||||
"line": " const attSlotRef = React.useRef(null);",
|
"line": " const attSlotRef = React.useRef(null);",
|
||||||
"lineNumber": 82,
|
"lineNumber": 85,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Needed for the composition area."
|
"reasonDetail": "Needed for the composition area."
|
||||||
|
@ -14505,7 +14505,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionArea.js",
|
"path": "ts/components/CompositionArea.js",
|
||||||
"line": " const micCellRef = React.useRef(null);",
|
"line": " const micCellRef = React.useRef(null);",
|
||||||
"lineNumber": 116,
|
"lineNumber": 119,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Needed for the composition area."
|
"reasonDetail": "Needed for the composition area."
|
||||||
|
@ -14514,7 +14514,7 @@
|
||||||
"rule": "DOM-innerHTML",
|
"rule": "DOM-innerHTML",
|
||||||
"path": "ts/components/CompositionArea.tsx",
|
"path": "ts/components/CompositionArea.tsx",
|
||||||
"line": " el.innerHTML = '';",
|
"line": " el.innerHTML = '';",
|
||||||
"lineNumber": 92,
|
"lineNumber": 98,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-06-03T19:23:21.195Z",
|
"updated": "2020-06-03T19:23:21.195Z",
|
||||||
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
||||||
|
@ -15279,7 +15279,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": 1270,
|
"lineNumber": 1302,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"updated": "2020-09-08T23:07:22.682Z"
|
||||||
},
|
},
|
||||||
|
@ -15287,7 +15287,7 @@
|
||||||
"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": 2174,
|
"lineNumber": 2230,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"updated": "2020-09-08T23:07:22.682Z"
|
||||||
},
|
},
|
||||||
|
|
90
ts/util/longRunningTaskWrapper.ts
Normal file
90
ts/util/longRunningTaskWrapper.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export async function longRunningTaskWrapper<T>({
|
||||||
|
name,
|
||||||
|
idForLogging,
|
||||||
|
task,
|
||||||
|
suppressErrorDialog,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
idForLogging: string;
|
||||||
|
task: () => Promise<T>;
|
||||||
|
suppressErrorDialog?: boolean;
|
||||||
|
}): Promise<T> {
|
||||||
|
const idLog = `${name}/${idForLogging}`;
|
||||||
|
const ONE_SECOND = 1000;
|
||||||
|
const TWO_SECONDS = 2000;
|
||||||
|
|
||||||
|
let progressView: typeof Whisper.ReactWrapperView | undefined;
|
||||||
|
let spinnerStart;
|
||||||
|
let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => {
|
||||||
|
window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`);
|
||||||
|
|
||||||
|
// Note: this component uses a portal to render itself into the top-level DOM. No
|
||||||
|
// need to attach it to the DOM here.
|
||||||
|
progressView = new Whisper.ReactWrapperView({
|
||||||
|
className: 'progress-modal-wrapper',
|
||||||
|
Component: window.Signal.Components.ProgressModal,
|
||||||
|
});
|
||||||
|
spinnerStart = Date.now();
|
||||||
|
}, TWO_SECONDS);
|
||||||
|
|
||||||
|
// Note: any task we put here needs to have its own safety valve; this function will
|
||||||
|
// show a spinner until it's done
|
||||||
|
try {
|
||||||
|
window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`);
|
||||||
|
const result = await task();
|
||||||
|
window.log.info(
|
||||||
|
`longRunningTaskWrapper/${idLog}: Task completed successfully`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progressTimeout) {
|
||||||
|
clearTimeout(progressTimeout);
|
||||||
|
progressTimeout = undefined;
|
||||||
|
}
|
||||||
|
if (progressView) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (spinnerStart && now - spinnerStart < ONE_SECOND) {
|
||||||
|
window.log.info(
|
||||||
|
`longRunningTaskWrapper/${idLog}: Spinner shown for less than second, showing for another second`
|
||||||
|
);
|
||||||
|
await window.Signal.Util.sleep(ONE_SECOND);
|
||||||
|
}
|
||||||
|
progressView.remove();
|
||||||
|
progressView = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
`longRunningTaskWrapper/${idLog}: Error!`,
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progressTimeout) {
|
||||||
|
clearTimeout(progressTimeout);
|
||||||
|
progressTimeout = undefined;
|
||||||
|
}
|
||||||
|
if (progressView) {
|
||||||
|
progressView.remove();
|
||||||
|
progressView = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suppressErrorDialog) {
|
||||||
|
window.log.info(`longRunningTaskWrapper/${idLog}: Showing error dialog`);
|
||||||
|
|
||||||
|
// Note: this component uses a portal to render itself into the top-level DOM. No
|
||||||
|
// need to attach it to the DOM here.
|
||||||
|
const errorView = new Whisper.ReactWrapperView({
|
||||||
|
className: 'error-modal-wrapper',
|
||||||
|
Component: window.Signal.Components.ErrorModal,
|
||||||
|
props: {
|
||||||
|
onClose: () => errorView.remove(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
|
||||||
|
|
||||||
type ParsedSgnlHref =
|
type ParsedSgnlHref =
|
||||||
| { command: null; args: Map<never, never> }
|
| { command: null; args: Map<never, never> }
|
||||||
| { command: string; args: Map<string, string> };
|
| { command: string; args: Map<string, string>; hash: string | undefined };
|
||||||
export function parseSgnlHref(
|
export function parseSgnlHref(
|
||||||
href: string,
|
href: string,
|
||||||
logger: LoggerType
|
logger: LoggerType
|
||||||
|
@ -42,5 +42,9 @@ export function parseSgnlHref(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { command: url.host, args };
|
return {
|
||||||
|
command: url.host,
|
||||||
|
args,
|
||||||
|
hash: url.hash ? url.hash.slice(1) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
25
ts/util/webSafeBase64.ts
Normal file
25
ts/util/webSafeBase64.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export function toWebSafeBase64(base64: string): string {
|
||||||
|
return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromWebSafeBase64(webSafeBase64: string): string {
|
||||||
|
const base64 = webSafeBase64.replace(/_/g, '/').replace(/-/g, '+');
|
||||||
|
|
||||||
|
// Ensure that the character count is a multiple of four, filling in the extra
|
||||||
|
// space needed with '='
|
||||||
|
const remainder = base64.length % 4;
|
||||||
|
if (remainder === 3) {
|
||||||
|
return `${base64}=`;
|
||||||
|
}
|
||||||
|
if (remainder === 2) {
|
||||||
|
return `${base64}==`;
|
||||||
|
}
|
||||||
|
if (remainder === 1) {
|
||||||
|
return `${base64}===`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64;
|
||||||
|
}
|
|
@ -280,6 +280,14 @@ Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
|
||||||
template: window.i18n('maximumAttachments'),
|
template: window.i18n('maximumAttachments'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Whisper.AlreadyGroupMemberToast = Whisper.ToastView.extend({
|
||||||
|
template: window.i18n('GroupV2--join--already-in-group'),
|
||||||
|
});
|
||||||
|
|
||||||
|
Whisper.AlreadyRequestedToJoinToast = Whisper.ToastView.extend({
|
||||||
|
template: window.i18n('GroupV2--join--already-awaiting-approval'),
|
||||||
|
});
|
||||||
|
|
||||||
Whisper.ConversationLoadingScreen = Whisper.View.extend({
|
Whisper.ConversationLoadingScreen = Whisper.View.extend({
|
||||||
templateName: 'conversation-loading-screen',
|
templateName: 'conversation-loading-screen',
|
||||||
className: 'conversation-loading-screen',
|
className: 'conversation-loading-screen',
|
||||||
|
@ -660,6 +668,21 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onStartGroupMigration: () => this.startMigrationToGV2(),
|
onStartGroupMigration: () => this.startMigrationToGV2(),
|
||||||
|
onCancelJoinRequest: async () => {
|
||||||
|
await window.showConfirmationDialog({
|
||||||
|
message: window.i18n(
|
||||||
|
'GroupV2--join--cancel-request-to-join--confirmation'
|
||||||
|
),
|
||||||
|
okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'),
|
||||||
|
cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'),
|
||||||
|
resolve: () => {
|
||||||
|
this.longRunningTaskWrapper({
|
||||||
|
name: 'onCancelJoinRequest',
|
||||||
|
task: async () => this.model.cancelJoinRequest(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
this.compositionAreaView = new Whisper.ReactWrapperView({
|
this.compositionAreaView = new Whisper.ReactWrapperView({
|
||||||
|
@ -681,79 +704,12 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
name: string;
|
name: string;
|
||||||
task: () => Promise<T>;
|
task: () => Promise<T>;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
const idLog = `${name}/${this.model.idForLogging()}`;
|
const idForLogging = this.model.idForLogging();
|
||||||
const ONE_SECOND = 1000;
|
return window.Signal.Util.longRunningTaskWrapper({
|
||||||
const TWO_SECONDS = 2000;
|
name,
|
||||||
|
idForLogging,
|
||||||
let progressView: any | undefined;
|
task,
|
||||||
let spinnerStart;
|
|
||||||
let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => {
|
|
||||||
window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`);
|
|
||||||
|
|
||||||
// Note: this component uses a portal to render itself into the top-level DOM. No
|
|
||||||
// need to attach it to the DOM here.
|
|
||||||
progressView = new Whisper.ReactWrapperView({
|
|
||||||
className: 'progress-modal-wrapper',
|
|
||||||
Component: window.Signal.Components.ProgressModal,
|
|
||||||
});
|
});
|
||||||
spinnerStart = Date.now();
|
|
||||||
}, TWO_SECONDS);
|
|
||||||
|
|
||||||
// Note: any task we put here needs to have its own safety valve; this function will
|
|
||||||
// show a spinner until it's done
|
|
||||||
try {
|
|
||||||
window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`);
|
|
||||||
const result = await task();
|
|
||||||
window.log.info(
|
|
||||||
`longRunningTaskWrapper/${idLog}: Task completed successfully`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (progressTimeout) {
|
|
||||||
clearTimeout(progressTimeout);
|
|
||||||
progressTimeout = undefined;
|
|
||||||
}
|
|
||||||
if (progressView) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (spinnerStart && now - spinnerStart < ONE_SECOND) {
|
|
||||||
window.log.info(
|
|
||||||
`longRunningTaskWrapper/${idLog}: Spinner shown for less than second, showing for another second`
|
|
||||||
);
|
|
||||||
await window.Signal.Util.sleep(ONE_SECOND);
|
|
||||||
}
|
|
||||||
progressView.remove();
|
|
||||||
progressView = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
window.log.error(
|
|
||||||
`longRunningTaskWrapper/${idLog}: Error!`,
|
|
||||||
error && error.stack ? error.stack : error
|
|
||||||
);
|
|
||||||
|
|
||||||
if (progressTimeout) {
|
|
||||||
clearTimeout(progressTimeout);
|
|
||||||
progressTimeout = undefined;
|
|
||||||
}
|
|
||||||
if (progressView) {
|
|
||||||
progressView.remove();
|
|
||||||
progressView = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.log.info(`longRunningTaskWrapper/${idLog}: Showing error dialog`);
|
|
||||||
|
|
||||||
// Note: this component uses a portal to render itself into the top-level DOM. No
|
|
||||||
// need to attach it to the DOM here.
|
|
||||||
const errorView = new Whisper.ReactWrapperView({
|
|
||||||
className: 'error-modal-wrapper',
|
|
||||||
Component: window.Signal.Components.ErrorModal,
|
|
||||||
props: {
|
|
||||||
onClose: () => errorView.remove(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setupTimeline() {
|
setupTimeline() {
|
||||||
|
|
11
ts/window.d.ts
vendored
11
ts/window.d.ts
vendored
|
@ -47,6 +47,7 @@ import { createConversationDetails } from './state/roots/createConversationDetai
|
||||||
import { createConversationHeader } from './state/roots/createConversationHeader';
|
import { createConversationHeader } from './state/roots/createConversationHeader';
|
||||||
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
||||||
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
||||||
|
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
||||||
import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions';
|
import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions';
|
||||||
import { createLeftPane } from './state/roots/createLeftPane';
|
import { createLeftPane } from './state/roots/createLeftPane';
|
||||||
import { createPendingInvites } from './state/roots/createPendingInvites';
|
import { createPendingInvites } from './state/roots/createPendingInvites';
|
||||||
|
@ -458,6 +459,7 @@ declare global {
|
||||||
createConversationHeader: typeof createConversationHeader;
|
createConversationHeader: typeof createConversationHeader;
|
||||||
createGroupLinkManagement: typeof createGroupLinkManagement;
|
createGroupLinkManagement: typeof createGroupLinkManagement;
|
||||||
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
||||||
|
createGroupV2JoinModal: typeof createGroupV2JoinModal;
|
||||||
createGroupV2Permissions: typeof createGroupV2Permissions;
|
createGroupV2Permissions: typeof createGroupV2Permissions;
|
||||||
createLeftPane: typeof createLeftPane;
|
createLeftPane: typeof createLeftPane;
|
||||||
createPendingInvites: typeof createPendingInvites;
|
createPendingInvites: typeof createPendingInvites;
|
||||||
|
@ -510,7 +512,10 @@ declare global {
|
||||||
readyForUpdates: () => void;
|
readyForUpdates: () => void;
|
||||||
logAppLoadedEvent: () => void;
|
logAppLoadedEvent: () => void;
|
||||||
|
|
||||||
// Flags
|
// Runtime Flags
|
||||||
|
isShowingModal?: boolean;
|
||||||
|
|
||||||
|
// Feature Flags
|
||||||
isGroupCallingEnabled: () => boolean;
|
isGroupCallingEnabled: () => boolean;
|
||||||
GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean;
|
GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean;
|
||||||
GV2_ENABLE_CHANGE_PROCESSING: boolean;
|
GV2_ENABLE_CHANGE_PROCESSING: boolean;
|
||||||
|
@ -640,7 +645,7 @@ export type WhisperType = {
|
||||||
ReactWrapperView: WhatIsThis;
|
ReactWrapperView: WhatIsThis;
|
||||||
activeConfirmationView: WhatIsThis;
|
activeConfirmationView: WhatIsThis;
|
||||||
ToastView: typeof Whisper.View & {
|
ToastView: typeof Whisper.View & {
|
||||||
show: (view: Backbone.View, el: Element) => void;
|
show: (view: typeof Backbone.View, el: Element) => void;
|
||||||
};
|
};
|
||||||
ConversationArchivedToast: WhatIsThis;
|
ConversationArchivedToast: WhatIsThis;
|
||||||
ConversationUnarchivedToast: WhatIsThis;
|
ConversationUnarchivedToast: WhatIsThis;
|
||||||
|
@ -715,6 +720,8 @@ export type WhisperType = {
|
||||||
deliveryReceiptBatcher: BatcherType<WhatIsThis>;
|
deliveryReceiptBatcher: BatcherType<WhatIsThis>;
|
||||||
RotateSignedPreKeyListener: WhatIsThis;
|
RotateSignedPreKeyListener: WhatIsThis;
|
||||||
|
|
||||||
|
AlreadyGroupMemberToast: typeof Whisper.ToastView;
|
||||||
|
AlreadyRequestedToJoinToast: typeof Whisper.ToastView;
|
||||||
BlockedGroupToast: typeof Whisper.ToastView;
|
BlockedGroupToast: typeof Whisper.ToastView;
|
||||||
BlockedToast: typeof Whisper.ToastView;
|
BlockedToast: typeof Whisper.ToastView;
|
||||||
CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView;
|
CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView;
|
||||||
|
|
Loading…
Reference in a new issue