diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c8e943234..5f091a5ea 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/js/modules/signal.js b/js/modules/signal.js index 7353e7f7a..1daad5214 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -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, diff --git a/main.js b/main.js index 4b2f66582..a14f1bb6f 100644 --- a/main.js +++ b/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'); } diff --git a/preload.js b/preload.js index 56549355b..bbc45422b 100644 --- a/preload.js +++ b/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; diff --git a/protos/Groups.proto b/protos/Groups.proto index 3a42b6b3d..7823ec8b1 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -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; +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 20f18711d..ec517224e 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 8f99e8da1..57cb84728 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -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) { diff --git a/ts/background.ts b/ts/background.ts index a81ad678f..00dd60861 100644 --- a/ts/background.ts +++ b/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 - ), }); }, diff --git a/ts/components/Avatar.stories.tsx b/ts/components/Avatar.stories.tsx index 8e9bc6123..65ce07ece 100644 --- a/ts/components/Avatar.stories.tsx +++ b/ts/components/Avatar.stories.tsx @@ -38,6 +38,7 @@ const createProps = (overrideProps: Partial = {}): 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 => ({ title: '', }); -const sizes: Array = [112, 80, 52, 32, 28]; +const sizes: Array = [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 => ); }); + +story.add('Loading', () => { + const props = createProps({ + loading: true, + }); + + return sizes.map(size => ); +}); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index c652b24fc..c78fefe07 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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 { ); } + public renderLoading(): JSX.Element { + const { size } = this.props; + const svgSize = size < 40 ? 'small' : 'normal'; + + return ( +
+ +
+ ); + } + public render(): JSX.Element { const { avatarPath, color, innerRef, + loading, noteToSelf, onClick, size, @@ -156,7 +175,9 @@ export class Avatar extends React.Component { let contents; - if (onClick) { + if (loading) { + contents = this.renderLoading(); + } else if (onClick) { contents = ( + + + + ); +}); diff --git a/ts/components/Spinner.tsx b/ts/components/Spinner.tsx index d176cd62a..2e63c1dc2 100644 --- a/ts/components/Spinner.tsx +++ b/ts/components/Spinner.tsx @@ -12,6 +12,7 @@ export const SpinnerDirections = [ 'incoming', 'on-background', 'on-progress-dialog', + 'on-avatar', ] as const; export type SpinnerDirection = typeof SpinnerDirections[number]; diff --git a/ts/components/conversation/GroupV2PendingApprovalActions.stories.tsx b/ts/components/conversation/GroupV2PendingApprovalActions.stories.tsx new file mode 100644 index 000000000..377c88313 --- /dev/null +++ b/ts/components/conversation/GroupV2PendingApprovalActions.stories.tsx @@ -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 ; +}); diff --git a/ts/components/conversation/GroupV2PendingApprovalActions.tsx b/ts/components/conversation/GroupV2PendingApprovalActions.tsx new file mode 100644 index 000000000..52611164f --- /dev/null +++ b/ts/components/conversation/GroupV2PendingApprovalActions.tsx @@ -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 ( +
+

+ {i18n('GroupV2--join--requested')} +

+
+ +
+
+ ); +}; diff --git a/ts/groups.ts b/ts/groups.ts index 658d80a36..ad3ee6dc9 100644 --- a/ts/groups.ts +++ b/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 { + 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 { - 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; + inviteLinkPassword?: string; + name: string; +}): Promise { + 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, + newAttributes: ConversationAttributesType +): MessageAttributesType { + const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + + // Assemble items to commemorate this event for the timeline.. + const combinedConversationIds: Array = [ + ...(newAttributes.membersV2 || []).map(item => item.conversationId), + ...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId), + ]; + const droppedMemberIds: Array = 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 { + 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 = [ - ...(newAttributes.membersV2 || []).map(item => item.conversationId), - ...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId), - ]; - const droppedMemberIds: Array = 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 = []; + + 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 = []; - 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 { - 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 { - 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 { + 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 { - 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 { + 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; }): Promise { - const logId = idForLogging(group); + const logId = idForLogging(group.groupId); let attributes = group; const finalMessages: Array> = []; const finalMembers: Array> = []; @@ -2258,7 +2725,7 @@ async function integrateGroupChange({ groupState?: GroupClass; newRevision: number; }): Promise { - 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 { - 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 { - const logId = idForLogging(old); + const logId = idForLogging(old.groupId); const details: Array = []; 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 { - 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, logId: string -) { +): Promise { try { // Avatar has been dropped if (!newAvatar && result.avatar) { @@ -3413,7 +3889,7 @@ async function applyGroupState({ groupState: GroupClass; sourceConversationId?: string; }): Promise { - 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, diff --git a/ts/groups/joinViaLink.ts b/ts/groups/joinViaLink.ts new file mode 100644 index 000000000..f2bcb75e8 --- /dev/null +++ b/ts/groups/joinViaLink.ts @@ -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 { + 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; + resolve: () => void; + reject: (error: Error) => void; +} { + let resolve: () => void; + let reject: (error: Error) => void; + + const promise = new Promise((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!, + }; +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 5e845c53c..d8d3394af 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -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; @@ -242,6 +245,10 @@ export type ConversationAttributesType = { groupInviteLinkPassword?: string; previousGroupV1Id?: string; previousGroupV1Members?: Array; + + // Used only when user is waiting for approval to join via link + isTemporary?: boolean; + temporaryMemberCount?: number; }; export type GroupV2MemberType = { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index f7686d4ca..004f13bb5 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 { + 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 ): Promise { @@ -609,142 +716,19 @@ export class ConversationModel extends window.Backbone.Model< async modifyGroupV2({ name, + inviteLinkPassword, createGroupChange, }: { name: string; + inviteLinkPassword?: string; createGroupChange: () => Promise; }): Promise { - 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 { + await window.Signal.Groups.joinGroupV2ViaLinkAndMigrate({ + approvalRequired, + conversation: this, + inviteLinkPassword, + revision, + }); + } + + async joinGroupV2ViaLink({ + inviteLinkPassword, + approvalRequired, + }: { + inviteLinkPassword: string; + approvalRequired: boolean; + }): Promise { + 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 { + 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 { const ourConversationId = window.ConversationController.getOurConversationId(); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 613be5985..bfaf68ece 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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 = getEmptyState(), action: Readonly ): 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; diff --git a/ts/state/roots/createGroupV2JoinModal.tsx b/ts/state/roots/createGroupV2JoinModal.tsx new file mode 100644 index 000000000..43a64389a --- /dev/null +++ b/ts/state/roots/createGroupV2JoinModal.tsx @@ -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 ( + + + + + + ); +}; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 42cec012f..3aa4606d4 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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 => { diff --git a/ts/state/smart/GroupV2JoinDialog.tsx b/ts/state/smart/GroupV2JoinDialog.tsx new file mode 100644 index 000000000..1445732db --- /dev/null +++ b/ts/state/smart/GroupV2JoinDialog.tsx @@ -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; + +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); diff --git a/ts/test-both/util/webSafeBase64_test.ts b/ts/test-both/util/webSafeBase64_test.ts new file mode 100644 index 000000000..7e0fe2f5d --- /dev/null +++ b/ts/test-both/util/webSafeBase64_test.ts @@ -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); + }); + }); +}); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 9f01edea0..871c62a2f 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -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); + }); + }); }); }); diff --git a/ts/test-node/util/sgnlHref_test.ts b/ts/test-node/util/sgnlHref_test.ts index 85c4e16d2..b09c9ff78 100644 --- a/ts/test-node/util/sgnlHref_test.ts +++ b/ts/test-node/util/sgnlHref_test.ts @@ -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(), + 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, }); }); }); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 3e9a6de7a..4543432b5 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -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 diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 13c5ab60f..11f9813bc 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -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( diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 4feb7bad3..cb276ff45 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -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 { + 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 { - return this.server.modifyGroup(changes, options); + return this.server.modifyGroup(changes, options, inviteLinkBase64); } async leaveGroup( diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 72497a578..82ab00297 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -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; getDevices: () => Promise; getGroup: (options: GroupCredentialsType) => Promise; + getGroupFromLink: ( + inviteLinkPassword: string, + auth: GroupCredentialsType + ) => Promise; getGroupAvatar: (key: string) => Promise; getGroupCredentials: ( startDay: number, @@ -803,7 +831,8 @@ export type WebAPIType = { ) => Promise; modifyGroup: ( changes: GroupChangeClass.Actions, - options: GroupCredentialsType + options: GroupCredentialsType, + inviteLinkBase64?: string ) => Promise; modifyStorageRecords: MessageSender['modifyStorageRecords']; putAttachment: (encryptedBin: ArrayBuffer) => Promise; @@ -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 { + 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 { 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); diff --git a/ts/util/index.ts b/ts/util/index.ts index 90ccb1399..380327b74 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -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, }; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index a8cf5154e..0b1f99219 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" }, diff --git a/ts/util/longRunningTaskWrapper.ts b/ts/util/longRunningTaskWrapper.ts new file mode 100644 index 000000000..88d916174 --- /dev/null +++ b/ts/util/longRunningTaskWrapper.ts @@ -0,0 +1,90 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export async function longRunningTaskWrapper({ + name, + idForLogging, + task, + suppressErrorDialog, +}: { + name: string; + idForLogging: string; + task: () => Promise; + suppressErrorDialog?: boolean; +}): Promise { + 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; + } +} diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts index ff5ba25ad..0ae037daf 100644 --- a/ts/util/sgnlHref.ts +++ b/ts/util/sgnlHref.ts @@ -25,7 +25,7 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean { type ParsedSgnlHref = | { command: null; args: Map } - | { command: string; args: Map }; + | { command: string; args: Map; 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, + }; } diff --git a/ts/util/webSafeBase64.ts b/ts/util/webSafeBase64.ts new file mode 100644 index 000000000..01dffc3e8 --- /dev/null +++ b/ts/util/webSafeBase64.ts @@ -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; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 545d67f17..7f6b4c5b8 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -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; }): Promise { - 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() { diff --git a/ts/window.d.ts b/ts/window.d.ts index 8b76a0e21..ab8ff4b38 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -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; RotateSignedPreKeyListener: WhatIsThis; + AlreadyGroupMemberToast: typeof Whisper.ToastView; + AlreadyRequestedToJoinToast: typeof Whisper.ToastView; BlockedGroupToast: typeof Whisper.ToastView; BlockedToast: typeof Whisper.ToastView; CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView;