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"
|
||||
},
|
||||
|
||||
"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": {
|
||||
"message": "Admin",
|
||||
"description": "Label for a group administrator"
|
||||
|
|
|
@ -79,6 +79,9 @@ const {
|
|||
const {
|
||||
createGroupV1MigrationModal,
|
||||
} = require('../../ts/state/roots/createGroupV1MigrationModal');
|
||||
const {
|
||||
createGroupV2JoinModal,
|
||||
} = require('../../ts/state/roots/createGroupV2JoinModal');
|
||||
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
|
||||
const {
|
||||
createGroupV2Permissions,
|
||||
|
@ -340,6 +343,7 @@ exports.setup = (options = {}) => {
|
|||
createConversationHeader,
|
||||
createGroupLinkManagement,
|
||||
createGroupV1MigrationModal,
|
||||
createGroupV2JoinModal,
|
||||
createGroupV2Permissions,
|
||||
createLeftPane,
|
||||
createPendingInvites,
|
||||
|
|
13
main.js
13
main.js
|
@ -1428,7 +1428,7 @@ function getIncomingHref(argv) {
|
|||
}
|
||||
|
||||
function handleSgnlHref(incomingHref) {
|
||||
const { command, args } = parseSgnlHref(incomingHref, logger);
|
||||
const { command, args, hash } = parseSgnlHref(incomingHref, logger);
|
||||
if (command === 'addstickers' && mainWindow && mainWindow.webContents) {
|
||||
console.log('Opening sticker pack from sgnl protocol link');
|
||||
const packId = args.get('pack_id');
|
||||
|
@ -1437,6 +1437,17 @@ function handleSgnlHref(incomingHref) {
|
|||
? Buffer.from(packKeyHex, 'hex').toString('base64')
|
||||
: '';
|
||||
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 {
|
||||
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) => {
|
||||
const { packId, packKey } = info;
|
||||
const { installStickerPack } = window.Events;
|
||||
|
|
|
@ -206,3 +206,13 @@ message GroupInviteLink {
|
|||
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 {
|
||||
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 {
|
||||
@include light-theme {
|
||||
background-color: $color-gray-05;
|
||||
|
@ -5874,6 +5881,9 @@ button.module-image__border-overlay:focus {
|
|||
.module-spinner__arc--on-progress-dialog {
|
||||
background-color: $ultramarine-ui-light;
|
||||
}
|
||||
.module-spinner__arc--on-avatar {
|
||||
background-color: $color-white;
|
||||
}
|
||||
|
||||
// Module: Highlighted Message Body
|
||||
|
||||
|
@ -10576,6 +10586,60 @@ button.module-image__border-overlay:focus {
|
|||
@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__overlay {
|
||||
|
@ -10735,6 +10799,99 @@ button.module-image__border-overlay:focus {
|
|||
@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 {
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { debounce, reduce, uniq, without } from 'lodash';
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
import dataInterface from './sql/Client';
|
||||
import {
|
||||
ConversationModelCollectionType,
|
||||
|
@ -150,6 +152,11 @@ export class ConversationController {
|
|||
return this._conversations.add(attributes);
|
||||
}
|
||||
|
||||
dangerouslyRemoveById(id: string): void {
|
||||
this._conversations.remove(id);
|
||||
this._conversations.resetLookups();
|
||||
}
|
||||
|
||||
getOrCreate(
|
||||
identifier: string | null,
|
||||
type: ConversationAttributesTypeType,
|
||||
|
@ -283,6 +290,16 @@ export class ConversationController {
|
|||
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
|
||||
* 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,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
|
@ -725,10 +765,6 @@ export class ConversationController {
|
|||
updateConversation(conversation.attributes);
|
||||
}
|
||||
|
||||
if (!conversation.get('lastMessage')) {
|
||||
await conversation.updateLastMessage();
|
||||
}
|
||||
|
||||
// In case a too-large draft was saved to the database
|
||||
const draft = conversation.get('draft');
|
||||
if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) {
|
||||
|
|
105
ts/background.ts
105
ts/background.ts
|
@ -427,29 +427,102 @@ type WhatIsThis = import('./window.d').WhatIsThis;
|
|||
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.
|
||||
if (!window.Signal.Util.Registration.everDone()) {
|
||||
window.log.warn('showStickerPack: Not registered, returning early');
|
||||
return;
|
||||
}
|
||||
if (window.isShowingModal) {
|
||||
window.log.warn(
|
||||
'showStickerPack: Already showing modal, returning early'
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.isShowingModal = true;
|
||||
|
||||
// Kick off the download
|
||||
window.Signal.Stickers.downloadEphemeralPack(packId, key);
|
||||
// Kick off the download
|
||||
window.Signal.Stickers.downloadEphemeralPack(packId, key);
|
||||
|
||||
const props = {
|
||||
packId,
|
||||
onClose: async () => {
|
||||
stickerPreviewModalView.remove();
|
||||
await window.Signal.Stickers.removeEphemeralPack(packId);
|
||||
const props = {
|
||||
packId,
|
||||
onClose: async () => {
|
||||
window.isShowingModal = false;
|
||||
stickerPreviewModalView.remove();
|
||||
await window.Signal.Stickers.removeEphemeralPack(packId);
|
||||
},
|
||||
};
|
||||
|
||||
const stickerPreviewModalView = new window.Whisper.ReactWrapperView({
|
||||
className: 'sticker-preview-modal-wrapper',
|
||||
JSX: window.Signal.State.Roots.createStickerPreviewModal(
|
||||
window.reduxStore,
|
||||
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();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stickerPreviewModalView = new window.Whisper.ReactWrapperView({
|
||||
className: 'sticker-preview-modal-wrapper',
|
||||
JSX: window.Signal.State.Roots.createStickerPreviewModal(
|
||||
window.reduxStore,
|
||||
props
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
overrideProps.conversationType || 'direct'
|
||||
),
|
||||
i18n,
|
||||
loading: boolean('loading', overrideProps.loading || false),
|
||||
name: text('name', overrideProps.name || ''),
|
||||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||
onClick: action('onClick'),
|
||||
|
@ -46,7 +47,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
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', () => {
|
||||
const props = createProps({
|
||||
|
@ -124,3 +125,11 @@ story.add('Broken Avatar for Group', () => {
|
|||
|
||||
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 classNames from 'classnames';
|
||||
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
import { getInitials } from '../util/getInitials';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { ColorType } from '../types/Colors';
|
||||
|
@ -20,6 +22,7 @@ export enum AvatarSize {
|
|||
export type Props = {
|
||||
avatarPath?: string;
|
||||
color?: ColorType;
|
||||
loading?: boolean;
|
||||
|
||||
conversationType: 'group' | 'direct';
|
||||
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 {
|
||||
const {
|
||||
avatarPath,
|
||||
color,
|
||||
innerRef,
|
||||
loading,
|
||||
noteToSelf,
|
||||
onClick,
|
||||
size,
|
||||
|
@ -156,7 +175,9 @@ export class Avatar extends React.Component<Props, State> {
|
|||
|
||||
let contents;
|
||||
|
||||
if (onClick) {
|
||||
if (loading) {
|
||||
contents = this.renderLoading();
|
||||
} else if (onClick) {
|
||||
contents = (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -71,6 +71,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
title: '',
|
||||
// GroupV1 Disabled Actions
|
||||
onStartGroupMigration: action('onStartGroupMigration'),
|
||||
// GroupV2 Pending Approval Actions
|
||||
onCancelJoinRequest: action('onCancelJoinRequest'),
|
||||
});
|
||||
|
||||
story.add('Default', () => {
|
||||
|
|
|
@ -22,6 +22,10 @@ import {
|
|||
GroupV1DisabledActions,
|
||||
PropsType as GroupV1DisabledActionsPropsType,
|
||||
} from './conversation/GroupV1DisabledActions';
|
||||
import {
|
||||
GroupV2PendingApprovalActions,
|
||||
PropsType as GroupV2PendingApprovalActionsPropsType,
|
||||
} from './conversation/GroupV2PendingApprovalActions';
|
||||
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
|
||||
import { countStickers } from './stickers/lib';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
|
@ -30,6 +34,7 @@ import { EmojiPickDataType } from './emoji/EmojiPicker';
|
|||
export type OwnProps = {
|
||||
readonly i18n: LocalizerType;
|
||||
readonly areWePending?: boolean;
|
||||
readonly areWePendingApproval?: boolean;
|
||||
readonly groupVersion?: 1 | 2;
|
||||
readonly isGroupV1AndDisabled?: boolean;
|
||||
readonly isMissingMandatoryProfileSharing?: boolean;
|
||||
|
@ -84,6 +89,7 @@ export type Props = Pick<
|
|||
> &
|
||||
MessageRequestActionsProps &
|
||||
Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
|
||||
Pick<GroupV2PendingApprovalActionsPropsType, 'onCancelJoinRequest'> &
|
||||
OwnProps;
|
||||
|
||||
const emptyElement = (el: HTMLElement) => {
|
||||
|
@ -128,6 +134,7 @@ export const CompositionArea = ({
|
|||
// Message Requests
|
||||
acceptedMessageRequest,
|
||||
areWePending,
|
||||
areWePendingApproval,
|
||||
conversationType,
|
||||
groupVersion,
|
||||
isBlocked,
|
||||
|
@ -146,6 +153,8 @@ export const CompositionArea = ({
|
|||
// GroupV1 Disabled Actions
|
||||
isGroupV1AndDisabled,
|
||||
onStartGroupMigration,
|
||||
// GroupV2 Pending Approval Actions
|
||||
onCancelJoinRequest,
|
||||
}: Props): JSX.Element => {
|
||||
const [disabled, setDisabled] = React.useState(false);
|
||||
const [showMic, setShowMic] = React.useState(!draftText);
|
||||
|
@ -403,6 +412,15 @@ export const CompositionArea = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (areWePendingApproval) {
|
||||
return (
|
||||
<GroupV2PendingApprovalActions
|
||||
i18n={i18n}
|
||||
onCancelJoinRequest={onCancelJoinRequest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-composition-area">
|
||||
<div className="module-composition-area__toggle-large">
|
||||
|
|
|
@ -7,9 +7,9 @@ import { LocalizerType } from '../types/Util';
|
|||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
|
||||
export type PropsType = {
|
||||
buttonText: string;
|
||||
description: string;
|
||||
title: string;
|
||||
buttonText?: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
|
||||
onClose: () => void;
|
||||
i18n: LocalizerType;
|
||||
|
|
|
@ -8,12 +8,6 @@ import { ConversationType } from '../state/ducks/conversations';
|
|||
import { Avatar } from './Avatar';
|
||||
import { sortByTitle } from '../util/sortByTitle';
|
||||
|
||||
export type ActionSpec = {
|
||||
text: string;
|
||||
action: () => unknown;
|
||||
style?: 'affirmative' | 'negative';
|
||||
};
|
||||
|
||||
type CallbackType = () => unknown;
|
||||
|
||||
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',
|
||||
'on-background',
|
||||
'on-progress-dialog',
|
||||
'on-avatar',
|
||||
] as const;
|
||||
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,
|
||||
} from './services/groupCredentialFetcher';
|
||||
import dataInterface from './sql/Client';
|
||||
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
|
||||
import {
|
||||
ConversationAttributesType,
|
||||
GroupV2MemberType,
|
||||
|
@ -57,6 +58,7 @@ import {
|
|||
GroupChangeClass,
|
||||
GroupChangesClass,
|
||||
GroupClass,
|
||||
GroupJoinInfoClass,
|
||||
MemberClass,
|
||||
MemberPendingAdminApprovalClass,
|
||||
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 { ConversationModel } from './models/conversations';
|
||||
|
||||
export { joinViaLink } from './groups/joinViaLink';
|
||||
|
||||
export type GroupV2AccessCreateChangeType = {
|
||||
type: 'create';
|
||||
};
|
||||
|
@ -227,6 +231,7 @@ const TEMPORAL_AUTH_REJECTED_CODE = 401;
|
|||
const GROUP_ACCESS_DENIED_CODE = 403;
|
||||
const GROUP_NONEXISTENT_CODE = 404;
|
||||
const SUPPORTED_CHANGE_EPOCH = 1;
|
||||
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
|
||||
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
|
||||
|
||||
// Group Links
|
||||
|
@ -235,8 +240,23 @@ export function generateGroupInviteLinkPassword(): ArrayBuffer {
|
|||
return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
|
||||
}
|
||||
|
||||
export function toWebSafeBase64(base64: string): string {
|
||||
return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '');
|
||||
// Group Links
|
||||
|
||||
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 {
|
||||
|
@ -257,6 +277,51 @@ export function buildGroupLink(conversation: ConversationModel): string {
|
|||
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
|
||||
|
||||
async function uploadAvatar({
|
||||
|
@ -596,6 +661,84 @@ export function buildDeletePendingAdminApprovalMemberChange({
|
|||
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({
|
||||
uuids,
|
||||
group,
|
||||
|
@ -744,11 +887,13 @@ export function buildPromoteMemberChange({
|
|||
export async function uploadGroupChange({
|
||||
actions,
|
||||
group,
|
||||
inviteLinkPassword,
|
||||
}: {
|
||||
actions: GroupChangeClass.Actions;
|
||||
group: ConversationAttributesType;
|
||||
inviteLinkPassword?: string;
|
||||
}): Promise<GroupChangeClass> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
|
||||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
await maybeFetchNewCredentials();
|
||||
|
@ -764,14 +909,160 @@ export async function uploadGroupChange({
|
|||
logId: `uploadGroupChange/${logId}`,
|
||||
publicParams: group.publicParams,
|
||||
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
|
||||
|
||||
function idForLogging(group: ConversationAttributesType) {
|
||||
return `groupv2(${group.groupId})`;
|
||||
export function idForLogging(groupId: string | undefined): string {
|
||||
return `groupv2(${groupId})`;
|
||||
}
|
||||
|
||||
export function deriveGroupFields(
|
||||
|
@ -1242,6 +1533,7 @@ export async function initiateMigrationToGroupV2(
|
|||
accessControl: {
|
||||
attributes: ACCESS_ENUM.MEMBER,
|
||||
members: ACCESS_ENUM.MEMBER,
|
||||
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
|
||||
},
|
||||
membersV2,
|
||||
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.
|
||||
// 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.
|
||||
|
@ -1459,17 +1873,11 @@ export async function respondToGroupV2Migration({
|
|||
);
|
||||
}
|
||||
|
||||
// If we were not previously a member, we won't migrate
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
||||
const wereWePreviouslyAMember =
|
||||
!conversation.get('left') &&
|
||||
ourConversationId &&
|
||||
conversation.hasMember(ourConversationId);
|
||||
if (!ourConversationId) {
|
||||
throw new Error(
|
||||
`respondToGroupV2Migration: No conversationId when attempting to migrate ${conversation.idForLogging()}. Returning early.`
|
||||
);
|
||||
}
|
||||
|
||||
// Derive GroupV2 fields
|
||||
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id);
|
||||
|
@ -1477,7 +1885,7 @@ export async function respondToGroupV2Migration({
|
|||
const fields = deriveGroupFields(masterKeyBuffer);
|
||||
|
||||
const groupId = arrayBufferToBase64(fields.id);
|
||||
const logId = `groupv2(${groupId})`;
|
||||
const logId = idForLogging(groupId);
|
||||
window.log.info(
|
||||
`respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}`
|
||||
);
|
||||
|
@ -1600,17 +2008,11 @@ export async function respondToGroupV2Migration({
|
|||
groupState,
|
||||
});
|
||||
|
||||
// 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
|
||||
// Generate notifications into the timeline
|
||||
const groupChangeMessages: Array<MessageAttributesType> = [];
|
||||
|
||||
groupChangeMessages.push(
|
||||
buildMigrationBubble(previousGroupV1MembersIds, newAttributes)
|
||||
);
|
||||
|
||||
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
|
||||
|
@ -1619,19 +2021,6 @@ export async function respondToGroupV2Migration({
|
|||
const areWeMember = (newAttributes.membersV2 || []).some(
|
||||
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) {
|
||||
// Add a message to the timeline saying the user was removed. This shouldn't happen.
|
||||
groupChangeMessages.push({
|
||||
|
@ -1764,6 +2153,8 @@ async function updateGroup({
|
|||
|
||||
const isInitialDataFetch =
|
||||
!isNumber(startingRevision) && isNumber(endingRevision);
|
||||
const isInGroup = !updates.newAttributes.left;
|
||||
const justJoinedGroup = conversation.get('left') && isInGroup;
|
||||
|
||||
// Ensure that all generated messages are ordered properly.
|
||||
// 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
|
||||
// Unknown Group in the left pane.
|
||||
active_at:
|
||||
isInitialDataFetch && newAttributes.name
|
||||
(isInitialDataFetch || justJoinedGroup) && newAttributes.name
|
||||
? finalReceivedAt
|
||||
: newAttributes.active_at,
|
||||
temporaryMemberCount: isInGroup
|
||||
? undefined
|
||||
: newAttributes.temporaryMemberCount,
|
||||
});
|
||||
|
||||
if (idChanged) {
|
||||
|
@ -1843,14 +2237,18 @@ async function getGroupUpdates({
|
|||
newRevision?: number;
|
||||
serverPublicParamsBase64: string;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
|
||||
window.log.info(`getGroupUpdates/${logId}: Starting...`);
|
||||
|
||||
const currentRevision = group.revision;
|
||||
const isFirstFetch = !isNumber(group.revision);
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
||||
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
|
||||
item => item.conversationId === ourConversationId
|
||||
);
|
||||
const isOneVersionUp =
|
||||
isNumber(currentRevision) &&
|
||||
isNumber(newRevision) &&
|
||||
|
@ -1860,7 +2258,7 @@ async function getGroupUpdates({
|
|||
window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
|
||||
groupChangeBase64 &&
|
||||
isNumber(newRevision) &&
|
||||
(isInitialCreationMessage || isOneVersionUp)
|
||||
(isInitialCreationMessage || weAreAwaitingApproval || isOneVersionUp)
|
||||
) {
|
||||
window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
|
||||
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
|
||||
|
@ -1872,7 +2270,12 @@ async function getGroupUpdates({
|
|||
groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;
|
||||
|
||||
if (isChangeSupported) {
|
||||
return integrateGroupChange({ group, newRevision, groupChange });
|
||||
return updateGroupViaSingleChange({
|
||||
group,
|
||||
newRevision,
|
||||
groupChange,
|
||||
serverPublicParamsBase64,
|
||||
});
|
||||
}
|
||||
|
||||
window.log.info(
|
||||
|
@ -1933,7 +2336,7 @@ async function updateGroupViaState({
|
|||
group: ConversationAttributesType;
|
||||
serverPublicParamsBase64: string;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
||||
if (!data) {
|
||||
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({
|
||||
group,
|
||||
serverPublicParamsBase64,
|
||||
|
@ -1989,7 +2432,7 @@ async function updateGroupViaLogs({
|
|||
newRevision: number;
|
||||
serverPublicParamsBase64: string;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
|
||||
if (!data) {
|
||||
throw new Error('getGroupUpdates: No group credentials!');
|
||||
|
@ -2032,10 +2475,10 @@ function generateBasicMessage() {
|
|||
} as MessageAttributesType;
|
||||
}
|
||||
|
||||
function generateLeftGroupChanges(
|
||||
async function generateLeftGroupChanges(
|
||||
group: ConversationAttributesType
|
||||
): UpdatesResultType {
|
||||
const logId = idForLogging(group);
|
||||
): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group.groupId);
|
||||
window.log.info(`generateLeftGroupChanges/${logId}: Starting...`);
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
if (!ourConversationId) {
|
||||
|
@ -2043,6 +2486,29 @@ function generateLeftGroupChanges(
|
|||
'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 newAttributes: ConversationAttributesType = {
|
||||
...group,
|
||||
|
@ -2050,6 +2516,7 @@ function generateLeftGroupChanges(
|
|||
member => member.conversationId !== ourConversationId
|
||||
),
|
||||
left: true,
|
||||
revision,
|
||||
};
|
||||
const isNewlyRemoved =
|
||||
existingMembers.length > (newAttributes.membersV2 || []).length;
|
||||
|
@ -2162,7 +2629,7 @@ async function integrateGroupChanges({
|
|||
newRevision: number;
|
||||
changes: Array<GroupChangesClass>;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
let attributes = group;
|
||||
const finalMessages: Array<Array<MessageAttributesType>> = [];
|
||||
const finalMembers: Array<Array<MemberType>> = [];
|
||||
|
@ -2258,7 +2725,7 @@ async function integrateGroupChange({
|
|||
groupState?: GroupClass;
|
||||
newRevision: number;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
if (!group.secretParams) {
|
||||
throw new Error(
|
||||
`integrateGroupChange/${logId}: Group was missing secretParams!`
|
||||
|
@ -2299,7 +2766,16 @@ async function integrateGroupChange({
|
|||
isNumber(group.revision) &&
|
||||
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) {
|
||||
throw new Error(
|
||||
`integrateGroupChange/${logId}: No group state, but we can't apply changes!`
|
||||
|
@ -2372,7 +2848,7 @@ async function getCurrentGroupState({
|
|||
group: ConversationAttributesType;
|
||||
serverPublicParamsBase64: string;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
const sender = window.textsecure.messaging;
|
||||
if (!sender) {
|
||||
throw new Error('textsecure.messaging is not available!');
|
||||
|
@ -2425,7 +2901,7 @@ function extractDiffs({
|
|||
old: ConversationAttributesType;
|
||||
sourceConversationId?: string;
|
||||
}): Array<MessageAttributesType> {
|
||||
const logId = idForLogging(old);
|
||||
const logId = idForLogging(old.groupId);
|
||||
const details: Array<GroupV2ChangeDetailType> = [];
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
|
@ -2847,7 +3323,7 @@ async function applyGroupChange({
|
|||
group: ConversationAttributesType;
|
||||
sourceConversationId: string;
|
||||
}): Promise<GroupChangeResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
|
@ -3335,11 +3811,11 @@ async function applyGroupChange({
|
|||
|
||||
// Ovewriting result.avatar as part of functionality
|
||||
/* eslint-disable no-param-reassign */
|
||||
async function applyNewAvatar(
|
||||
export async function applyNewAvatar(
|
||||
newAvatar: string | undefined,
|
||||
result: ConversationAttributesType,
|
||||
result: Pick<ConversationAttributesType, 'avatar' | 'secretParams'>,
|
||||
logId: string
|
||||
) {
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Avatar has been dropped
|
||||
if (!newAvatar && result.avatar) {
|
||||
|
@ -3413,7 +3889,7 @@ async function applyGroupState({
|
|||
groupState: GroupClass;
|
||||
sourceConversationId?: string;
|
||||
}): Promise<ConversationAttributesType> {
|
||||
const logId = idForLogging(group);
|
||||
const logId = idForLogging(group.groupId);
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||
const version = groupState.version || 0;
|
||||
|
@ -4144,6 +4620,24 @@ function decryptGroupChange(
|
|||
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(
|
||||
groupState: GroupClass,
|
||||
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
|
||||
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;
|
||||
groupVersion?: number;
|
||||
|
||||
|
@ -233,7 +236,7 @@ export type ConversationAttributesType = {
|
|||
avatar?: {
|
||||
url: string;
|
||||
path: string;
|
||||
hash: string;
|
||||
hash?: string;
|
||||
} | null;
|
||||
expireTimer?: number;
|
||||
membersV2?: Array<GroupV2MemberType>;
|
||||
|
@ -242,6 +245,10 @@ export type ConversationAttributesType = {
|
|||
groupInviteLinkPassword?: string;
|
||||
previousGroupV1Id?: string;
|
||||
previousGroupV1Members?: Array<string>;
|
||||
|
||||
// Used only when user is waiting for approval to join via link
|
||||
isTemporary?: boolean;
|
||||
temporaryMemberCount?: number;
|
||||
};
|
||||
|
||||
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 {
|
||||
if (!this.isGroupV2()) {
|
||||
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(
|
||||
conversationIds: Array<string>
|
||||
): Promise<GroupChangeClass.Actions | undefined> {
|
||||
|
@ -609,142 +716,19 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
|
||||
async modifyGroupV2({
|
||||
name,
|
||||
inviteLinkPassword,
|
||||
createGroupChange,
|
||||
}: {
|
||||
name: string;
|
||||
inviteLinkPassword?: string;
|
||||
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
|
||||
}): Promise<void> {
|
||||
const idLog = `${name}/${this.idForLogging()}`;
|
||||
|
||||
if (!this.isGroupV2()) {
|
||||
throw new Error(
|
||||
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
|
||||
);
|
||||
}
|
||||
|
||||
const ONE_MINUTE = 1000 * 60;
|
||||
const startTime = Date.now();
|
||||
const timeoutTime = startTime + ONE_MINUTE;
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||
window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await window.waitForEmptyEventQueue();
|
||||
|
||||
window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.queueJob(async () => {
|
||||
window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
|
||||
|
||||
const actions = await createGroupChange();
|
||||
if (!actions) {
|
||||
window.log.warn(
|
||||
`modifyGroupV2/${idLog}: No change actions. Returning early.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The new revision has to be exactly one more than the current revision
|
||||
// or it won't upload properly, and it won't apply in maybeUpdateGroup
|
||||
const currentRevision = this.get('revision');
|
||||
const newRevision = actions.version;
|
||||
|
||||
if ((currentRevision || 0) + 1 !== newRevision) {
|
||||
throw new Error(
|
||||
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Upload. If we don't have permission, the server will return an error here.
|
||||
const groupChange = await window.Signal.Groups.uploadGroupChange({
|
||||
actions,
|
||||
group: this.attributes,
|
||||
});
|
||||
|
||||
const groupChangeBuffer = groupChange.toArrayBuffer();
|
||||
const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer);
|
||||
|
||||
// Apply change locally, just like we would with an incoming change. This will
|
||||
// change conversation state and add change notifications to the timeline.
|
||||
await window.Signal.Groups.maybeUpdateGroup({
|
||||
conversation: this,
|
||||
groupChangeBase64,
|
||||
newRevision,
|
||||
});
|
||||
|
||||
// Send message to notify group members (including pending members) of change
|
||||
const profileKey = this.get('profileSharing')
|
||||
? window.storage.get('profileKey')
|
||||
: undefined;
|
||||
|
||||
const sendOptions = this.getSendOptions();
|
||||
const timestamp = Date.now();
|
||||
|
||||
const promise = this.wrapSend(
|
||||
window.textsecure.messaging.sendMessageToGroup(
|
||||
{
|
||||
groupV2: this.getGroupV2Info({
|
||||
groupChange: groupChangeBuffer,
|
||||
includePendingMembers: true,
|
||||
}),
|
||||
timestamp,
|
||||
profileKey,
|
||||
},
|
||||
sendOptions
|
||||
)
|
||||
);
|
||||
|
||||
// We don't save this message; we just use it to ensure that a sync message is
|
||||
// sent to our linked devices.
|
||||
const m = new window.Whisper.Message(({
|
||||
conversationId: this.id,
|
||||
type: 'not-to-save',
|
||||
sent_at: timestamp,
|
||||
received_at: timestamp,
|
||||
// TODO: DESKTOP-722
|
||||
// this type does not fully implement the interface it is expected to
|
||||
} as unknown) as MessageAttributesType);
|
||||
|
||||
// This is to ensure that the functions in send() and sendSyncMessage()
|
||||
// don't save anything to the database.
|
||||
m.doNotSave = true;
|
||||
|
||||
await m.send(promise);
|
||||
});
|
||||
|
||||
// If we've gotten here with no error, we exit!
|
||||
window.log.info(
|
||||
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error.code === 409 && Date.now() <= timeoutTime) {
|
||||
window.log.info(
|
||||
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.fetchLatestGroupV2Data();
|
||||
} else if (error.code === 409) {
|
||||
window.log.error(
|
||||
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
|
||||
);
|
||||
// We don't wait here because we're breaking out of the loop immediately.
|
||||
this.fetchLatestGroupV2Data();
|
||||
throw error;
|
||||
} else {
|
||||
const errorString = error && error.stack ? error.stack : error;
|
||||
window.log.error(
|
||||
`modifyGroupV2/${idLog}: Error updating: ${errorString}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
await window.Signal.Groups.modifyGroupV2({
|
||||
createGroupChange,
|
||||
conversation: this,
|
||||
inviteLinkPassword,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
isEverUnregistered(): boolean {
|
||||
|
@ -1324,6 +1308,9 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
areWePending: Boolean(
|
||||
ourConversationId && this.isMemberPending(ourConversationId)
|
||||
),
|
||||
areWePendingApproval: Boolean(
|
||||
ourConversationId && this.isMemberAwaitingApproval(ourConversationId)
|
||||
),
|
||||
areWeAdmin: this.areWeAdmin(),
|
||||
canChangeTimer: this.canChangeTimer(),
|
||||
canEditGroupInfo: this.canEditGroupInfo(),
|
||||
|
@ -1353,9 +1340,7 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
lastUpdated: this.get('timestamp')!,
|
||||
left: Boolean(this.get('left')),
|
||||
markedUnread: this.get('markedUnread')!,
|
||||
membersCount: this.isPrivate()
|
||||
? undefined
|
||||
: (this.get('membersV2')! || this.get('members')! || []).length,
|
||||
membersCount: this.getMembersCount(),
|
||||
memberships: this.getMemberships(),
|
||||
pendingMemberships: this.getPendingMemberships(),
|
||||
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
||||
|
@ -1427,6 +1412,26 @@ export class ConversationModel extends window.Backbone.Model<
|
|||
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 {
|
||||
this.set({
|
||||
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> {
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ export type ConversationType = {
|
|||
avatarPath?: string;
|
||||
areWeAdmin?: boolean;
|
||||
areWePending?: boolean;
|
||||
areWePendingApproval?: boolean;
|
||||
canChangeTimer?: boolean;
|
||||
canEditGroupInfo?: boolean;
|
||||
color?: ColorType;
|
||||
|
@ -208,7 +209,18 @@ export type MessagesByConversationType = {
|
|||
[key: string]: ConversationMessageType | undefined;
|
||||
};
|
||||
|
||||
export type PreJoinConversationType = {
|
||||
avatar?: {
|
||||
loading?: boolean;
|
||||
url?: string;
|
||||
};
|
||||
memberCount: number;
|
||||
title: string;
|
||||
approvalRequired: boolean;
|
||||
};
|
||||
|
||||
export type ConversationsStateType = {
|
||||
preJoinConversation?: PreJoinConversationType;
|
||||
conversationLookup: ConversationLookupType;
|
||||
conversationsByE164: ConversationLookupType;
|
||||
conversationsByUuid: ConversationLookupType;
|
||||
|
@ -252,6 +264,13 @@ export const getConversationCallMode = (
|
|||
|
||||
// Actions
|
||||
|
||||
type SetPreJoinConversationActionType = {
|
||||
type: 'SET_PRE_JOIN_CONVERSATION';
|
||||
payload: {
|
||||
data: PreJoinConversationType | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
type ConversationAddedActionType = {
|
||||
type: 'CONVERSATION_ADDED';
|
||||
payload: {
|
||||
|
@ -421,34 +440,33 @@ type SetRecentMediaItemsActionType = {
|
|||
};
|
||||
|
||||
export type ConversationActionType =
|
||||
| ClearChangedMessagesActionType
|
||||
| ClearSelectedMessageActionType
|
||||
| ClearUnreadMetricsActionType
|
||||
| ConversationAddedActionType
|
||||
| ConversationChangedActionType
|
||||
| ConversationRemovedActionType
|
||||
| ConversationUnloadedActionType
|
||||
| RemoveAllConversationsActionType
|
||||
| MessageSelectedActionType
|
||||
| MessageSizeChangedActionType
|
||||
| MessageChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| MessagesAddedActionType
|
||||
| MessageSelectedActionType
|
||||
| MessageSizeChangedActionType
|
||||
| MessagesResetActionType
|
||||
| RemoveAllConversationsActionType
|
||||
| RepairNewestMessageActionType
|
||||
| RepairOldestMessageActionType
|
||||
| MessagesResetActionType
|
||||
| SetMessagesLoadingActionType
|
||||
| ScrollToMessageActionType
|
||||
| SelectedConversationChangedActionType
|
||||
| SetConversationHeaderTitleActionType
|
||||
| SetIsNearBottomActionType
|
||||
| SetLoadCountdownStartActionType
|
||||
| ClearChangedMessagesActionType
|
||||
| ClearSelectedMessageActionType
|
||||
| ClearUnreadMetricsActionType
|
||||
| ScrollToMessageActionType
|
||||
| SetConversationHeaderTitleActionType
|
||||
| SetSelectedConversationPanelDepthActionType
|
||||
| SelectedConversationChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| SelectedConversationChangedActionType
|
||||
| SetMessagesLoadingActionType
|
||||
| SetPreJoinConversationActionType
|
||||
| SetRecentMediaItemsActionType
|
||||
| ShowInboxActionType
|
||||
| ShowArchivedConversationsActionType;
|
||||
| SetSelectedConversationPanelDepthActionType
|
||||
| ShowArchivedConversationsActionType
|
||||
| ShowInboxActionType;
|
||||
|
||||
// Action Creators
|
||||
|
||||
|
@ -462,8 +480,8 @@ export const actions = {
|
|||
conversationUnloaded,
|
||||
messageChanged,
|
||||
messageDeleted,
|
||||
messageSizeChanged,
|
||||
messagesAdded,
|
||||
messageSizeChanged,
|
||||
messagesReset,
|
||||
openConversationExternal,
|
||||
openConversationInternal,
|
||||
|
@ -475,6 +493,7 @@ export const actions = {
|
|||
setIsNearBottom,
|
||||
setLoadCountdownStart,
|
||||
setMessagesLoading,
|
||||
setPreJoinConversation,
|
||||
setRecentMediaItems,
|
||||
setSelectedConversationHeaderTitle,
|
||||
setSelectedConversationPanelDepth,
|
||||
|
@ -482,6 +501,16 @@ export const actions = {
|
|||
showInbox,
|
||||
};
|
||||
|
||||
function setPreJoinConversation(
|
||||
data: PreJoinConversationType | undefined
|
||||
): SetPreJoinConversationActionType {
|
||||
return {
|
||||
type: 'SET_PRE_JOIN_CONVERSATION',
|
||||
payload: {
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
function conversationAdded(
|
||||
id: string,
|
||||
data: ConversationType
|
||||
|
@ -924,6 +953,15 @@ export function reducer(
|
|||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||
action: Readonly<ConversationActionType>
|
||||
): ConversationsStateType {
|
||||
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
|
||||
const { payload } = action;
|
||||
const { data } = payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
preJoinConversation: data,
|
||||
};
|
||||
}
|
||||
if (action.type === 'CONVERSATION_ADDED') {
|
||||
const { payload } = action;
|
||||
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,
|
||||
MessagesByConversationType,
|
||||
MessageType,
|
||||
PreJoinConversationType,
|
||||
} from '../ducks/conversations';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import type { CallsByConversationType } from '../ducks/calling';
|
||||
|
@ -48,6 +49,12 @@ export const getPlaceholderContact = (): ConversationType => {
|
|||
export const getConversations = (state: StateType): ConversationsStateType =>
|
||||
state.conversations;
|
||||
|
||||
export const getPreJoinConversation = createSelector(
|
||||
getConversations,
|
||||
(state: ConversationsStateType): PreJoinConversationType | undefined => {
|
||||
return state.preJoinConversation;
|
||||
}
|
||||
);
|
||||
export const getConversationLookup = createSelector(
|
||||
getConversations,
|
||||
(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,
|
||||
repairNewestMessage,
|
||||
repairOldestMessage,
|
||||
setPreJoinConversation,
|
||||
} = actions;
|
||||
|
||||
describe('both/state/ducks/conversations', () => {
|
||||
|
@ -577,5 +578,39 @@ describe('both/state/ducks/conversations', () => {
|
|||
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://user:pass@foo',
|
||||
'sgnl://foo/path/data#hash-data',
|
||||
'sgnl://foo/path/data',
|
||||
].forEach(href => {
|
||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
||||
command: 'foo',
|
||||
args: new Map<string, string>(),
|
||||
hash: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -124,6 +125,7 @@ describe('sgnlHref', () => {
|
|||
['empty', ''],
|
||||
['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', () => {
|
||||
[
|
||||
'sgnl://foo?bar=baz',
|
||||
'sgnl://foo/?bar=baz',
|
||||
'sgnl://foo/lots/of/path?bar=baz',
|
||||
'sgnl://foo?bar=baz#hash',
|
||||
'sgnl://user:pass@foo?bar=baz',
|
||||
].forEach(href => {
|
||||
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
|
||||
command: 'foo',
|
||||
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;
|
||||
GroupExternalCredential: typeof GroupExternalCredentialClass;
|
||||
GroupInviteLink: typeof GroupInviteLinkClass;
|
||||
GroupJoinInfo: typeof GroupJoinInfoClass;
|
||||
};
|
||||
|
||||
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 {
|
||||
static decode: (
|
||||
data: ArrayBuffer | ByteBufferClass,
|
||||
|
@ -524,20 +541,19 @@ export declare namespace GroupInviteLinkClass {
|
|||
}
|
||||
}
|
||||
|
||||
export declare class GroupAttributeBlobClass {
|
||||
export declare class GroupJoinInfoClass {
|
||||
static decode: (
|
||||
data: ArrayBuffer | ByteBufferClass,
|
||||
encoding?: string
|
||||
) => GroupAttributeBlobClass;
|
||||
toArrayBuffer(): ArrayBuffer;
|
||||
) => GroupJoinInfoClass;
|
||||
|
||||
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';
|
||||
publicKey?: ProtoBinaryType;
|
||||
title?: ProtoBinaryType;
|
||||
avatar?: string;
|
||||
memberCount?: number;
|
||||
addFromInviteLink?: AccessControlClass.AccessRequired;
|
||||
version?: number;
|
||||
pendingAdminApproval?: boolean;
|
||||
}
|
||||
|
||||
// Previous protos
|
||||
|
|
|
@ -476,7 +476,7 @@ export default class OutgoingMessage {
|
|||
if (error.code === 409) {
|
||||
p = this.removeDeviceIdsForIdentifier(
|
||||
identifier,
|
||||
error.response.extraDevices
|
||||
error.response.extraDevices || []
|
||||
);
|
||||
} else {
|
||||
p = Promise.all(
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
GroupChangeClass,
|
||||
GroupClass,
|
||||
GroupExternalCredentialClass,
|
||||
GroupJoinInfoClass,
|
||||
StorageServiceCallOptionsType,
|
||||
StorageServiceCredentials,
|
||||
SyncMessageClass,
|
||||
|
@ -1769,6 +1770,13 @@ export default class MessageSender {
|
|||
return this.server.getGroup(options);
|
||||
}
|
||||
|
||||
async getGroupFromLink(
|
||||
groupInviteLink: string,
|
||||
auth: GroupCredentialsType
|
||||
): Promise<GroupJoinInfoClass> {
|
||||
return this.server.getGroupFromLink(groupInviteLink, auth);
|
||||
}
|
||||
|
||||
async getGroupLog(
|
||||
startVersion: number,
|
||||
options: GroupCredentialsType
|
||||
|
@ -1782,9 +1790,10 @@ export default class MessageSender {
|
|||
|
||||
async modifyGroup(
|
||||
changes: GroupChangeClass.Actions,
|
||||
options: GroupCredentialsType
|
||||
options: GroupCredentialsType,
|
||||
inviteLinkBase64?: string
|
||||
): Promise<GroupChangeClass> {
|
||||
return this.server.modifyGroup(changes, options);
|
||||
return this.server.modifyGroup(changes, options, inviteLinkBase64);
|
||||
}
|
||||
|
||||
async leaveGroup(
|
||||
|
|
|
@ -29,6 +29,7 @@ import { v4 as getGuid } from 'uuid';
|
|||
|
||||
import { Long } from '../window.d';
|
||||
import { getUserAgent } from '../util/getUserAgent';
|
||||
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
||||
import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
|
@ -50,6 +51,7 @@ import {
|
|||
GroupChangeClass,
|
||||
GroupChangesClass,
|
||||
GroupClass,
|
||||
GroupJoinInfoClass,
|
||||
GroupExternalCredentialClass,
|
||||
StorageServiceCallOptionsType,
|
||||
StorageServiceCredentials,
|
||||
|
@ -58,6 +60,11 @@ import {
|
|||
import { WebSocket } from './WebSocket';
|
||||
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 = {
|
||||
SGX_FLAGS_INITTED: Long;
|
||||
SGX_FLAGS_DEBUG: Long;
|
||||
|
@ -340,6 +347,10 @@ type ArrayBufferWithDetailsType = {
|
|||
response: Response;
|
||||
};
|
||||
|
||||
function isSuccess(status: number): boolean {
|
||||
return status >= 0 && status < 400;
|
||||
}
|
||||
|
||||
async function _promiseAjax(
|
||||
providedUrl: string | null,
|
||||
options: PromiseAjaxOptionsType
|
||||
|
@ -432,7 +443,9 @@ async function _promiseAjax(
|
|||
}
|
||||
|
||||
let resultPromise;
|
||||
if (
|
||||
if (DEBUG && !isSuccess(response.status)) {
|
||||
resultPromise = response.text();
|
||||
} else if (
|
||||
(options.responseType === 'json' ||
|
||||
options.responseType === 'jsonwithdetails') &&
|
||||
response.headers.get('Content-Type') === 'application/json'
|
||||
|
@ -448,45 +461,51 @@ async function _promiseAjax(
|
|||
}
|
||||
|
||||
return resultPromise.then(result => {
|
||||
if (
|
||||
options.responseType === 'arraybuffer' ||
|
||||
options.responseType === 'arraybufferwithdetails'
|
||||
) {
|
||||
result = result.buffer.slice(
|
||||
result.byteOffset,
|
||||
result.byteOffset + result.byteLength
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.responseType === 'json' ||
|
||||
options.responseType === 'jsonwithdetails'
|
||||
) {
|
||||
if (options.validateResponse) {
|
||||
if (!_validateResponse(result, options.validateResponse)) {
|
||||
if (options.redactUrl) {
|
||||
window.log.info(
|
||||
options.type,
|
||||
options.redactUrl(url),
|
||||
response.status,
|
||||
'Error'
|
||||
if (isSuccess(response.status)) {
|
||||
if (
|
||||
options.responseType === 'arraybuffer' ||
|
||||
options.responseType === 'arraybufferwithdetails'
|
||||
) {
|
||||
result = result.buffer.slice(
|
||||
result.byteOffset,
|
||||
result.byteOffset + result.byteLength
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.responseType === 'json' ||
|
||||
options.responseType === 'jsonwithdetails'
|
||||
) {
|
||||
if (options.validateResponse) {
|
||||
if (!_validateResponse(result, options.validateResponse)) {
|
||||
if (options.redactUrl) {
|
||||
window.log.info(
|
||||
options.type,
|
||||
options.redactUrl(url),
|
||||
response.status,
|
||||
'Error'
|
||||
);
|
||||
} else {
|
||||
window.log.error(
|
||||
options.type,
|
||||
url,
|
||||
response.status,
|
||||
'Error'
|
||||
);
|
||||
}
|
||||
reject(
|
||||
makeHTTPError(
|
||||
'promiseAjax: invalid response',
|
||||
response.status,
|
||||
result,
|
||||
options.stack
|
||||
)
|
||||
);
|
||||
} else {
|
||||
window.log.error(options.type, url, response.status, 'Error');
|
||||
}
|
||||
reject(
|
||||
makeHTTPError(
|
||||
'promiseAjax: invalid response',
|
||||
response.status,
|
||||
result,
|
||||
options.stack
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.status >= 0 && response.status < 400) {
|
||||
|
||||
if (options.redactUrl) {
|
||||
window.log.info(
|
||||
options.type,
|
||||
|
@ -605,6 +624,10 @@ function makeHTTPError(
|
|||
const e = new Error(`${message}; code: ${code}`);
|
||||
e.name = 'HTTPError';
|
||||
e.code = code;
|
||||
if (DEBUG && response) {
|
||||
e.stack += `\nresponse: ${response}`;
|
||||
}
|
||||
|
||||
e.stack += `\nOriginal stack:\n${stack}`;
|
||||
if (response) {
|
||||
e.response = response;
|
||||
|
@ -628,6 +651,7 @@ const URL_CALLS = {
|
|||
getStickerPackUpload: 'v1/sticker/pack/form',
|
||||
groupLog: 'v1/groups/logs',
|
||||
groups: 'v1/groups',
|
||||
groupsViaLink: 'v1/groups/join',
|
||||
groupToken: 'v1/groups/token',
|
||||
keys: 'v2/keys',
|
||||
messages: 'v1/messages',
|
||||
|
@ -734,6 +758,10 @@ export type WebAPIType = {
|
|||
getAvatar: (path: string) => Promise<any>;
|
||||
getDevices: () => Promise<any>;
|
||||
getGroup: (options: GroupCredentialsType) => Promise<GroupClass>;
|
||||
getGroupFromLink: (
|
||||
inviteLinkPassword: string,
|
||||
auth: GroupCredentialsType
|
||||
) => Promise<GroupJoinInfoClass>;
|
||||
getGroupAvatar: (key: string) => Promise<ArrayBuffer>;
|
||||
getGroupCredentials: (
|
||||
startDay: number,
|
||||
|
@ -803,7 +831,8 @@ export type WebAPIType = {
|
|||
) => Promise<ArrayBufferWithDetailsType>;
|
||||
modifyGroup: (
|
||||
changes: GroupChangeClass.Actions,
|
||||
options: GroupCredentialsType
|
||||
options: GroupCredentialsType,
|
||||
inviteLinkBase64?: string
|
||||
) => Promise<GroupChangeClass>;
|
||||
modifyStorageRecords: MessageSender['modifyStorageRecords'];
|
||||
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
|
||||
|
@ -955,6 +984,8 @@ export function initialize({
|
|||
return {
|
||||
confirmCode,
|
||||
createGroup,
|
||||
fetchLinkPreviewImage,
|
||||
fetchLinkPreviewMetadata,
|
||||
getAttachment,
|
||||
getAvatar,
|
||||
getConfig,
|
||||
|
@ -963,6 +994,7 @@ export function initialize({
|
|||
getGroupAvatar,
|
||||
getGroupCredentials,
|
||||
getGroupExternalCredential,
|
||||
getGroupFromLink,
|
||||
getGroupLog,
|
||||
getIceServers,
|
||||
getKeysForIdentifier,
|
||||
|
@ -979,8 +1011,6 @@ export function initialize({
|
|||
getStorageManifest,
|
||||
getStorageRecords,
|
||||
getUuidsForE164s,
|
||||
fetchLinkPreviewMetadata,
|
||||
fetchLinkPreviewImage,
|
||||
makeProxiedRequest,
|
||||
makeSfuRequest,
|
||||
modifyGroup,
|
||||
|
@ -2052,9 +2082,32 @@ export function initialize({
|
|||
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(
|
||||
changes: GroupChangeClass.Actions,
|
||||
options: GroupCredentialsType
|
||||
options: GroupCredentialsType,
|
||||
inviteLinkBase64?: string
|
||||
): Promise<GroupChangeClass> {
|
||||
const basicAuth = generateGroupAuth(
|
||||
options.groupPublicParamsHex,
|
||||
|
@ -2070,6 +2123,9 @@ export function initialize({
|
|||
host: storageUrl,
|
||||
httpType: 'PATCH',
|
||||
responseType: 'arraybuffer',
|
||||
urlParameters: inviteLinkBase64
|
||||
? `?inviteLinkPassword=${toWebSafeBase64(inviteLinkBase64)}`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return window.textsecure.protobuf.GroupChange.decode(response);
|
||||
|
|
|
@ -22,6 +22,8 @@ import { makeLookup } from './makeLookup';
|
|||
import { missingCaseError } from './missingCaseError';
|
||||
import { parseRemoteClientExpiration } from './parseRemoteClientExpiration';
|
||||
import { sleep } from './sleep';
|
||||
import { longRunningTaskWrapper } from './longRunningTaskWrapper';
|
||||
import { toWebSafeBase64, fromWebSafeBase64 } from './webSafeBase64';
|
||||
import * as zkgroup from './zkgroup';
|
||||
|
||||
export {
|
||||
|
@ -31,6 +33,7 @@ export {
|
|||
createWaitBatcher,
|
||||
deleteForEveryone,
|
||||
downloadAttachment,
|
||||
fromWebSafeBase64,
|
||||
generateSecurityNumber,
|
||||
getSafetyNumberPlaceholder,
|
||||
getStringForProfileChange,
|
||||
|
@ -39,10 +42,12 @@ export {
|
|||
GoogleChrome,
|
||||
hasExpired,
|
||||
isFileDangerous,
|
||||
longRunningTaskWrapper,
|
||||
makeLookup,
|
||||
missingCaseError,
|
||||
parseRemoteClientExpiration,
|
||||
Registration,
|
||||
sleep,
|
||||
toWebSafeBase64,
|
||||
zkgroup,
|
||||
};
|
||||
|
|
|
@ -14478,7 +14478,7 @@
|
|||
"rule": "DOM-innerHTML",
|
||||
"path": "ts/components/CompositionArea.js",
|
||||
"line": " el.innerHTML = '';",
|
||||
"lineNumber": 41,
|
||||
"lineNumber": 42,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-05-20T20:10:43.540Z",
|
||||
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
||||
|
@ -14487,7 +14487,7 @@
|
|||
"rule": "React-useRef",
|
||||
"path": "ts/components/CompositionArea.js",
|
||||
"line": " const inputApiRef = React.useRef();",
|
||||
"lineNumber": 59,
|
||||
"lineNumber": 62,
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-10-26T19:12:24.410Z",
|
||||
"reasonDetail": "Doesn't refer to a DOM element."
|
||||
|
@ -14496,7 +14496,7 @@
|
|||
"rule": "React-useRef",
|
||||
"path": "ts/components/CompositionArea.js",
|
||||
"line": " const attSlotRef = React.useRef(null);",
|
||||
"lineNumber": 82,
|
||||
"lineNumber": 85,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-10-26T19:12:24.410Z",
|
||||
"reasonDetail": "Needed for the composition area."
|
||||
|
@ -14505,7 +14505,7 @@
|
|||
"rule": "React-useRef",
|
||||
"path": "ts/components/CompositionArea.js",
|
||||
"line": " const micCellRef = React.useRef(null);",
|
||||
"lineNumber": 116,
|
||||
"lineNumber": 119,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-10-26T19:12:24.410Z",
|
||||
"reasonDetail": "Needed for the composition area."
|
||||
|
@ -14514,7 +14514,7 @@
|
|||
"rule": "DOM-innerHTML",
|
||||
"path": "ts/components/CompositionArea.tsx",
|
||||
"line": " el.innerHTML = '';",
|
||||
"lineNumber": 92,
|
||||
"lineNumber": 98,
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-06-03T19:23:21.195Z",
|
||||
"reasonDetail": "Our code, no user input, only clearing out the dom"
|
||||
|
@ -15279,7 +15279,7 @@
|
|||
"rule": "jQuery-wrap(",
|
||||
"path": "ts/textsecure/WebAPI.js",
|
||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
|
||||
"lineNumber": 1270,
|
||||
"lineNumber": 1302,
|
||||
"reasonCategory": "falseMatch",
|
||||
"updated": "2020-09-08T23:07:22.682Z"
|
||||
},
|
||||
|
@ -15287,7 +15287,7 @@
|
|||
"rule": "jQuery-wrap(",
|
||||
"path": "ts/textsecure/WebAPI.ts",
|
||||
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
|
||||
"lineNumber": 2174,
|
||||
"lineNumber": 2230,
|
||||
"reasonCategory": "falseMatch",
|
||||
"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 =
|
||||
| { command: null; args: Map<never, never> }
|
||||
| { command: string; args: Map<string, string> };
|
||||
| { command: string; args: Map<string, string>; hash: string | undefined };
|
||||
export function parseSgnlHref(
|
||||
href: string,
|
||||
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'),
|
||||
});
|
||||
|
||||
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({
|
||||
templateName: 'conversation-loading-screen',
|
||||
className: 'conversation-loading-screen',
|
||||
|
@ -660,6 +668,21 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
},
|
||||
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({
|
||||
|
@ -681,79 +704,12 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
name: string;
|
||||
task: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const idLog = `${name}/${this.model.idForLogging()}`;
|
||||
const ONE_SECOND = 1000;
|
||||
const TWO_SECONDS = 2000;
|
||||
|
||||
let progressView: any | 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const idForLogging = this.model.idForLogging();
|
||||
return window.Signal.Util.longRunningTaskWrapper({
|
||||
name,
|
||||
idForLogging,
|
||||
task,
|
||||
});
|
||||
},
|
||||
|
||||
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 { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
||||
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
||||
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
||||
import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions';
|
||||
import { createLeftPane } from './state/roots/createLeftPane';
|
||||
import { createPendingInvites } from './state/roots/createPendingInvites';
|
||||
|
@ -458,6 +459,7 @@ declare global {
|
|||
createConversationHeader: typeof createConversationHeader;
|
||||
createGroupLinkManagement: typeof createGroupLinkManagement;
|
||||
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
||||
createGroupV2JoinModal: typeof createGroupV2JoinModal;
|
||||
createGroupV2Permissions: typeof createGroupV2Permissions;
|
||||
createLeftPane: typeof createLeftPane;
|
||||
createPendingInvites: typeof createPendingInvites;
|
||||
|
@ -510,7 +512,10 @@ declare global {
|
|||
readyForUpdates: () => void;
|
||||
logAppLoadedEvent: () => void;
|
||||
|
||||
// Flags
|
||||
// Runtime Flags
|
||||
isShowingModal?: boolean;
|
||||
|
||||
// Feature Flags
|
||||
isGroupCallingEnabled: () => boolean;
|
||||
GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean;
|
||||
GV2_ENABLE_CHANGE_PROCESSING: boolean;
|
||||
|
@ -640,7 +645,7 @@ export type WhisperType = {
|
|||
ReactWrapperView: WhatIsThis;
|
||||
activeConfirmationView: WhatIsThis;
|
||||
ToastView: typeof Whisper.View & {
|
||||
show: (view: Backbone.View, el: Element) => void;
|
||||
show: (view: typeof Backbone.View, el: Element) => void;
|
||||
};
|
||||
ConversationArchivedToast: WhatIsThis;
|
||||
ConversationUnarchivedToast: WhatIsThis;
|
||||
|
@ -715,6 +720,8 @@ export type WhisperType = {
|
|||
deliveryReceiptBatcher: BatcherType<WhatIsThis>;
|
||||
RotateSignedPreKeyListener: WhatIsThis;
|
||||
|
||||
AlreadyGroupMemberToast: typeof Whisper.ToastView;
|
||||
AlreadyRequestedToJoinToast: typeof Whisper.ToastView;
|
||||
BlockedGroupToast: typeof Whisper.ToastView;
|
||||
BlockedToast: typeof Whisper.ToastView;
|
||||
CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView;
|
||||
|
|
Loading…
Reference in a new issue