Support for joining New Groups via invite links

This commit is contained in:
Scott Nonnenberg 2021-01-29 14:16:48 -08:00 committed by GitHub
parent c0510b08a5
commit a48b3e381e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2532 additions and 381 deletions

View file

@ -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": "Cant 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. Youll 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"

View file

@ -79,6 +79,9 @@ const {
const {
createGroupV1MigrationModal,
} = require('../../ts/state/roots/createGroupV1MigrationModal');
const {
createGroupV2JoinModal,
} = require('../../ts/state/roots/createGroupV2JoinModal');
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
const {
createGroupV2Permissions,
@ -340,6 +343,7 @@ exports.setup = (options = {}) => {
createConversationHeader,
createGroupLinkManagement,
createGroupV1MigrationModal,
createGroupV2JoinModal,
createGroupV2Permissions,
createLeftPane,
createPendingInvites,

13
main.js
View file

@ -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');
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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
),
});
},

View file

@ -38,6 +38,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.conversationType || 'direct'
),
i18n,
loading: boolean('loading', overrideProps.loading || false),
name: text('name', overrideProps.name || ''),
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onClick: action('onClick'),
@ -46,7 +47,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
title: '',
});
const sizes: Array<Props['size']> = [112, 80, 52, 32, 28];
const sizes: Array<Props['size']> = [112, 96, 80, 52, 32, 28];
story.add('Avatar', () => {
const props = createProps({
@ -124,3 +125,11 @@ story.add('Broken Avatar for Group', () => {
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});
story.add('Loading', () => {
const props = createProps({
loading: true,
});
return sizes.map(size => <Avatar key={size} {...props} size={size} />);
});

View file

@ -4,6 +4,8 @@
import * as React from 'react';
import classNames from 'classnames';
import { Spinner } from './Spinner';
import { getInitials } from '../util/getInitials';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
@ -20,6 +22,7 @@ export enum AvatarSize {
export type Props = {
avatarPath?: string;
color?: ColorType;
loading?: boolean;
conversationType: 'group' | 'direct';
noteToSelf?: boolean;
@ -136,11 +139,27 @@ export class Avatar extends React.Component<Props, State> {
);
}
public renderLoading(): JSX.Element {
const { size } = this.props;
const svgSize = size < 40 ? 'small' : 'normal';
return (
<div className="module-avatar__spinner-container">
<Spinner
size={`${size - 8}px`}
svgSize={svgSize}
direction="on-avatar"
/>
</div>
);
}
public render(): JSX.Element {
const {
avatarPath,
color,
innerRef,
loading,
noteToSelf,
onClick,
size,
@ -156,7 +175,9 @@ export class Avatar extends React.Component<Props, State> {
let contents;
if (onClick) {
if (loading) {
contents = this.renderLoading();
} else if (onClick) {
contents = (
<button
type="button"

View file

@ -71,6 +71,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
title: '',
// GroupV1 Disabled Actions
onStartGroupMigration: action('onStartGroupMigration'),
// GroupV2 Pending Approval Actions
onCancelJoinRequest: action('onCancelJoinRequest'),
});
story.add('Default', () => {

View file

@ -22,6 +22,10 @@ import {
GroupV1DisabledActions,
PropsType as GroupV1DisabledActionsPropsType,
} from './conversation/GroupV1DisabledActions';
import {
GroupV2PendingApprovalActions,
PropsType as GroupV2PendingApprovalActionsPropsType,
} from './conversation/GroupV2PendingApprovalActions';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
@ -30,6 +34,7 @@ import { EmojiPickDataType } from './emoji/EmojiPicker';
export type OwnProps = {
readonly i18n: LocalizerType;
readonly areWePending?: boolean;
readonly areWePendingApproval?: boolean;
readonly groupVersion?: 1 | 2;
readonly isGroupV1AndDisabled?: boolean;
readonly isMissingMandatoryProfileSharing?: boolean;
@ -84,6 +89,7 @@ export type Props = Pick<
> &
MessageRequestActionsProps &
Pick<GroupV1DisabledActionsPropsType, 'onStartGroupMigration'> &
Pick<GroupV2PendingApprovalActionsPropsType, 'onCancelJoinRequest'> &
OwnProps;
const emptyElement = (el: HTMLElement) => {
@ -128,6 +134,7 @@ export const CompositionArea = ({
// Message Requests
acceptedMessageRequest,
areWePending,
areWePendingApproval,
conversationType,
groupVersion,
isBlocked,
@ -146,6 +153,8 @@ export const CompositionArea = ({
// GroupV1 Disabled Actions
isGroupV1AndDisabled,
onStartGroupMigration,
// GroupV2 Pending Approval Actions
onCancelJoinRequest,
}: Props): JSX.Element => {
const [disabled, setDisabled] = React.useState(false);
const [showMic, setShowMic] = React.useState(!draftText);
@ -403,6 +412,15 @@ export const CompositionArea = ({
);
}
if (areWePendingApproval) {
return (
<GroupV2PendingApprovalActions
i18n={i18n}
onCancelJoinRequest={onCancelJoinRequest}
/>
);
}
return (
<div className="module-composition-area">
<div className="module-composition-area__toggle-large">

View file

@ -7,9 +7,9 @@ import { LocalizerType } from '../types/Util';
import { ConfirmationModal } from './ConfirmationModal';
export type PropsType = {
buttonText: string;
description: string;
title: string;
buttonText?: string;
description?: string;
title?: string;
onClose: () => void;
i18n: LocalizerType;

View file

@ -8,12 +8,6 @@ import { ConversationType } from '../state/ducks/conversations';
import { Avatar } from './Avatar';
import { sortByTitle } from '../util/sortByTitle';
export type ActionSpec = {
text: string;
action: () => unknown;
style?: 'affirmative' | 'negative';
};
type CallbackType = () => unknown;
export type DataPropsType = {

View file

@ -0,0 +1,80 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean, number, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { GroupV2JoinDialog, PropsType } from './GroupV2JoinDialog';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
memberCount: number('memberCount', overrideProps.memberCount || 12),
avatar: overrideProps.avatar,
title: text('title', overrideProps.title || 'Random Group!'),
approvalRequired: boolean(
'approvalRequired',
overrideProps.approvalRequired || false
),
join: action('join'),
onClose: action('onClose'),
i18n,
});
const stories = storiesOf('Components/GroupV2JoinDialog', module);
stories.add('Basic', () => {
return <GroupV2JoinDialog {...createProps()} />;
});
stories.add('Approval required', () => {
return (
<GroupV2JoinDialog
{...createProps({
approvalRequired: true,
title: 'Approval required!',
})}
/>
);
});
stories.add('With avatar', () => {
return (
<GroupV2JoinDialog
{...createProps({
avatar: {
url: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
},
title: 'Has an avatar!',
})}
/>
);
});
stories.add('With one member', () => {
return (
<GroupV2JoinDialog
{...createProps({
memberCount: 1,
title: 'Just one member!',
})}
/>
);
});
stories.add('Avatar loading state', () => {
return (
<GroupV2JoinDialog
{...createProps({
avatar: {
loading: true,
},
title: 'Avatar loading!',
})}
/>
);
});

View file

@ -0,0 +1,120 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import { Avatar } from './Avatar';
import { Spinner } from './Spinner';
import { PreJoinConversationType } from '../state/ducks/conversations';
type CallbackType = () => unknown;
export type DataPropsType = PreJoinConversationType & {
readonly join: CallbackType;
readonly onClose: CallbackType;
};
export type HousekeepingPropsType = {
readonly i18n: LocalizerType;
};
export type PropsType = DataPropsType & HousekeepingPropsType;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const GroupV2JoinDialog = React.memo((props: PropsType) => {
const [isWorking, setIsWorking] = React.useState(false);
const [isJoining, setIsJoining] = React.useState(false);
const {
approvalRequired,
avatar,
i18n,
join,
memberCount,
onClose,
title,
} = props;
const joinString = approvalRequired
? i18n('GroupV2--join--request-to-join-button')
: i18n('GroupV2--join--join-button');
const promptString = approvalRequired
? i18n('GroupV2--join--prompt-with-approval')
: i18n('GroupV2--join--prompt');
const memberString =
memberCount === 1
? i18n('GroupV2--join--member-count--single')
: i18n('GroupV2--join--member-count--multiple', {
count: memberCount.toString(),
});
const wrappedJoin = React.useCallback(() => {
setIsWorking(true);
setIsJoining(true);
join();
}, [join, setIsJoining, setIsWorking]);
const wrappedClose = React.useCallback(() => {
setIsWorking(true);
onClose();
}, [onClose, setIsWorking]);
return (
<div className="module-group-v2-join-dialog">
<button
aria-label={i18n('close')}
type="button"
disabled={isWorking}
className="module-group-v2-join-dialog__close-button"
onClick={wrappedClose}
/>
<div className="module-group-v2-join-dialog__avatar">
<Avatar
avatarPath={avatar ? avatar.url : undefined}
loading={avatar && !avatar.url}
conversationType="group"
title={title}
size={80}
i18n={i18n}
/>
</div>
<div className="module-group-v2-join-dialog__title">{title}</div>
<div className="module-group-v2-join-dialog__metadata">
{i18n('GroupV2--join--group-metadata', [memberString])}
</div>
<div className="module-group-v2-join-dialog__prompt">{promptString}</div>
<div className="module-group-v2-join-dialog__buttons">
<button
className={classNames(
'module-group-v2-join-dialog__button',
'module-group-v2-join-dialog__button--secondary'
)}
disabled={isWorking}
type="button"
onClick={wrappedClose}
>
{i18n('cancel')}
</button>
<button
className="module-group-v2-join-dialog__button"
disabled={isWorking}
ref={focusRef}
type="button"
onClick={wrappedJoin}
>
{isJoining ? (
<Spinner size="20px" svgSize="small" direction="on-avatar" />
) : (
joinString
)}
</button>
</div>
</div>
);
});

View file

@ -12,6 +12,7 @@ export const SpinnerDirections = [
'incoming',
'on-background',
'on-progress-dialog',
'on-avatar',
] as const;
export type SpinnerDirection = typeof SpinnerDirections[number];

View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
GroupV2PendingApprovalActions,
PropsType as GroupV2PendingApprovalActionsPropsType,
} from './GroupV2PendingApprovalActions';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (): GroupV2PendingApprovalActionsPropsType => ({
i18n,
onCancelJoinRequest: action('onCancelJoinRequest'),
});
const stories = storiesOf(
'Components/Conversation/GroupV2PendingApprovalActions',
module
);
stories.add('Default', () => {
return <GroupV2PendingApprovalActions {...createProps()} />;
});

View file

@ -0,0 +1,33 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { LocalizerType } from '../../types/Util';
export type PropsType = {
i18n: LocalizerType;
onCancelJoinRequest: () => unknown;
};
export const GroupV2PendingApprovalActions = ({
i18n,
onCancelJoinRequest,
}: PropsType): JSX.Element => {
return (
<div className="module-group-v2-pending-approval-actions">
<p className="module-group-v2-pending-approval-actions__message">
{i18n('GroupV2--join--requested')}
</p>
<div className="module-group-v2-pending-approval-actions__buttons">
<button
type="button"
onClick={onCancelJoinRequest}
tabIndex={0}
className="module-group-v2-pending-approval-actions__buttons__button"
>
{i18n('GroupV2--join--cancel-request-to-join')}
</button>
</div>
</div>
);
};

View file

@ -19,6 +19,7 @@ import {
maybeFetchNewCredentials,
} from './services/groupCredentialFetcher';
import dataInterface from './sql/Client';
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
import {
ConversationAttributesType,
GroupV2MemberType,
@ -57,6 +58,7 @@ import {
GroupChangeClass,
GroupChangesClass,
GroupClass,
GroupJoinInfoClass,
MemberClass,
MemberPendingAdminApprovalClass,
MemberPendingProfileKeyClass,
@ -71,6 +73,8 @@ import MessageSender, { CallbackResultType } from './textsecure/SendMessage';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
import { ConversationModel } from './models/conversations';
export { joinViaLink } from './groups/joinViaLink';
export type GroupV2AccessCreateChangeType = {
type: 'create';
};
@ -227,6 +231,7 @@ const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404;
const SUPPORTED_CHANGE_EPOCH = 1;
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
// Group Links
@ -235,8 +240,23 @@ export function generateGroupInviteLinkPassword(): ArrayBuffer {
return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
}
export function toWebSafeBase64(base64: string): string {
return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '');
// Group Links
export async function getPreJoinGroupInfo(
inviteLinkPasswordBase64: string,
masterKeyBase64: string
): Promise<GroupJoinInfoClass> {
const data = window.Signal.Groups.deriveGroupFields(
base64ToArrayBuffer(masterKeyBase64)
);
return makeRequestWithTemporalRetry({
logId: `groupv2(${data.id})`,
publicParams: arrayBufferToBase64(data.publicParams),
secretParams: arrayBufferToBase64(data.secretParams),
request: (sender, options) =>
sender.getGroupFromLink(inviteLinkPasswordBase64, options),
});
}
export function buildGroupLink(conversation: ConversationModel): string {
@ -257,6 +277,51 @@ export function buildGroupLink(conversation: ConversationModel): string {
return `sgnl://signal.group/#${hash}`;
}
export function parseGroupLink(
hash: string
): { masterKey: string; inviteLinkPassword: string } {
const base64 = fromWebSafeBase64(hash);
const buffer = base64ToArrayBuffer(base64);
const inviteLinkProto = window.textsecure.protobuf.GroupInviteLink.decode(
buffer
);
if (
inviteLinkProto.contents !== 'v1Contents' ||
!inviteLinkProto.v1Contents
) {
const error = new Error(
'parseGroupLink: Parsed proto is missing v1Contents'
);
error.name = LINK_VERSION_ERROR;
throw error;
}
if (!hasData(inviteLinkProto.v1Contents.groupMasterKey)) {
throw new Error('v1Contents.groupMasterKey had no data!');
}
if (!hasData(inviteLinkProto.v1Contents.inviteLinkPassword)) {
throw new Error('v1Contents.inviteLinkPassword had no data!');
}
const masterKey: string = inviteLinkProto.v1Contents.groupMasterKey.toString(
'base64'
);
if (masterKey.length !== 44) {
throw new Error(`masterKey had unexpected length ${masterKey.length}`);
}
const inviteLinkPassword: string = inviteLinkProto.v1Contents.inviteLinkPassword.toString(
'base64'
);
if (inviteLinkPassword.length === 0) {
throw new Error(
`inviteLinkPassword had unexpected length ${inviteLinkPassword.length}`
);
}
return { masterKey, inviteLinkPassword };
}
// Group Modifications
async function uploadAvatar({
@ -596,6 +661,84 @@ export function buildDeletePendingAdminApprovalMemberChange({
return actions;
}
export function buildAddPendingAdminApprovalMemberChange({
group,
profileKeyCredentialBase64,
serverPublicParamsBase64,
}: {
group: ConversationAttributesType;
profileKeyCredentialBase64: string;
serverPublicParamsBase64: string;
}): GroupChangeClass.Actions {
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
);
}
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const addMemberPendingAdminApproval = new window.textsecure.protobuf.GroupChange.Actions.AddMemberPendingAdminApprovalAction();
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
group.secretParams
);
const added = new window.textsecure.protobuf.MemberPendingAdminApproval();
added.presentation = presentation;
addMemberPendingAdminApproval.added = added;
actions.version = (group.revision || 0) + 1;
actions.addMemberPendingAdminApprovals = [addMemberPendingAdminApproval];
return actions;
}
export function buildAddMember({
group,
profileKeyCredentialBase64,
serverPublicParamsBase64,
}: {
group: ConversationAttributesType;
profileKeyCredentialBase64: string;
serverPublicParamsBase64: string;
joinFromInviteLink?: boolean;
}): GroupChangeClass.Actions {
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const actions = new window.textsecure.protobuf.GroupChange.Actions();
if (!group.secretParams) {
throw new Error('buildAddMember: group was missing secretParams!');
}
const clientZkProfileCipher = getClientZkProfileOperations(
serverPublicParamsBase64
);
const addMember = new window.textsecure.protobuf.GroupChange.Actions.AddMemberAction();
const presentation = createProfileKeyCredentialPresentation(
clientZkProfileCipher,
profileKeyCredentialBase64,
group.secretParams
);
const added = new window.textsecure.protobuf.Member();
added.presentation = presentation;
added.role = MEMBER_ROLE_ENUM.DEFAULT;
addMember.added = added;
actions.version = (group.revision || 0) + 1;
actions.addMembers = [addMember];
return actions;
}
export function buildDeletePendingMemberChange({
uuids,
group,
@ -744,11 +887,13 @@ export function buildPromoteMemberChange({
export async function uploadGroupChange({
actions,
group,
inviteLinkPassword,
}: {
actions: GroupChangeClass.Actions;
group: ConversationAttributesType;
inviteLinkPassword?: string;
}): Promise<GroupChangeClass> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
@ -764,14 +909,160 @@ export async function uploadGroupChange({
logId: `uploadGroupChange/${logId}`,
publicParams: group.publicParams,
secretParams: group.secretParams,
request: (sender, options) => sender.modifyGroup(actions, options),
request: (sender, options) =>
sender.modifyGroup(actions, options, inviteLinkPassword),
});
}
export async function modifyGroupV2({
conversation,
createGroupChange,
inviteLinkPassword,
name,
}: {
conversation: ConversationModel;
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
inviteLinkPassword?: string;
name: string;
}): Promise<void> {
const idLog = `${name}/${conversation.idForLogging()}`;
if (!conversation.isGroupV2()) {
throw new Error(
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
);
}
const ONE_MINUTE = 1000 * 60;
const startTime = Date.now();
const timeoutTime = startTime + ONE_MINUTE;
const MAX_ATTEMPTS = 5;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
try {
// eslint-disable-next-line no-await-in-loop
await window.waitForEmptyEventQueue();
window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
// eslint-disable-next-line no-await-in-loop
await conversation.queueJob(async () => {
window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
const actions = await createGroupChange();
if (!actions) {
window.log.warn(
`modifyGroupV2/${idLog}: No change actions. Returning early.`
);
return;
}
// The new revision has to be exactly one more than the current revision
// or it won't upload properly, and it won't apply in maybeUpdateGroup
const currentRevision = conversation.get('revision');
const newRevision = actions.version;
if ((currentRevision || 0) + 1 !== newRevision) {
throw new Error(
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
);
}
// Upload. If we don't have permission, the server will return an error here.
const groupChange = await window.Signal.Groups.uploadGroupChange({
actions,
inviteLinkPassword,
group: conversation.attributes,
});
const groupChangeBuffer = groupChange.toArrayBuffer();
const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer);
// Apply change locally, just like we would with an incoming change. This will
// change conversation state and add change notifications to the timeline.
await window.Signal.Groups.maybeUpdateGroup({
conversation,
groupChangeBase64,
newRevision,
});
// Send message to notify group members (including pending members) of change
const profileKey = conversation.get('profileSharing')
? window.storage.get('profileKey')
: undefined;
const sendOptions = conversation.getSendOptions();
const timestamp = Date.now();
const promise = conversation.wrapSend(
window.textsecure.messaging.sendMessageToGroup(
{
groupV2: conversation.getGroupV2Info({
groupChange: groupChangeBuffer,
includePendingMembers: true,
}),
timestamp,
profileKey,
},
sendOptions
)
);
// We don't save this message; we just use it to ensure that a sync message is
// sent to our linked devices.
const m = new window.Whisper.Message(({
conversationId: conversation.id,
type: 'not-to-save',
sent_at: timestamp,
received_at: timestamp,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown) as MessageAttributesType);
// This is to ensure that the functions in send() and sendSyncMessage()
// don't save anything to the database.
m.doNotSave = true;
await m.send(promise);
});
// If we've gotten here with no error, we exit!
window.log.info(
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
);
break;
} catch (error) {
if (error.code === 409 && Date.now() <= timeoutTime) {
window.log.info(
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
);
// eslint-disable-next-line no-await-in-loop
await conversation.fetchLatestGroupV2Data();
} else if (error.code === 409) {
window.log.error(
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
);
// We don't wait here because we're breaking out of the loop immediately.
conversation.fetchLatestGroupV2Data();
throw error;
} else {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`modifyGroupV2/${idLog}: Error updating: ${errorString}`
);
throw error;
}
}
}
}
// Utility
function idForLogging(group: ConversationAttributesType) {
return `groupv2(${group.groupId})`;
export function idForLogging(groupId: string | undefined): string {
return `groupv2(${groupId})`;
}
export function deriveGroupFields(
@ -1242,6 +1533,7 @@ export async function initiateMigrationToGroupV2(
accessControl: {
attributes: ACCESS_ENUM.MEMBER,
members: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
},
membersV2,
pendingMembersV2,
@ -1437,6 +1729,128 @@ export async function waitThenRespondToGroupV2Migration(
});
}
export function buildMigrationBubble(
previousGroupV1MembersIds: Array<string>,
newAttributes: ConversationAttributesType
): MessageAttributesType {
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
// Assemble items to commemorate this event for the timeline..
const combinedConversationIds: Array<string> = [
...(newAttributes.membersV2 || []).map(item => item.conversationId),
...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId),
];
const droppedMemberIds: Array<string> = difference(
previousGroupV1MembersIds,
combinedConversationIds
).filter(id => id && id !== ourConversationId);
const invitedMembers = (newAttributes.pendingMembersV2 || []).filter(
item => item.conversationId !== ourConversationId
);
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
item => item.conversationId === ourConversationId
);
return {
...generateBasicMessage(),
type: 'group-v1-migration',
groupMigration: {
areWeInvited,
invitedMembers,
droppedMemberIds,
},
};
}
export async function joinGroupV2ViaLinkAndMigrate({
approvalRequired,
conversation,
inviteLinkPassword,
revision,
}: {
approvalRequired: boolean;
conversation: ConversationModel;
inviteLinkPassword: string;
revision: number;
}): Promise<void> {
const isGroupV1 = conversation.isGroupV1();
const previousGroupV1Id = conversation.get('groupId');
if (!isGroupV1 || !previousGroupV1Id) {
throw new Error(
`joinGroupV2ViaLinkAndMigrate: Conversation is not GroupV1! ${conversation.idForLogging()}`
);
}
// Derive GroupV2 fields
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id);
const masterKeyBuffer = await deriveMasterKeyFromGroupV1(groupV1IdBuffer);
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(fields.id);
const logId = idForLogging(groupId);
window.log.info(
`joinGroupV2ViaLinkAndMigrate/${logId}: Migrating from ${conversation.idForLogging()}`
);
const masterKey = arrayBufferToBase64(masterKeyBuffer);
const secretParams = arrayBufferToBase64(fields.secretParams);
const publicParams = arrayBufferToBase64(fields.publicParams);
// A mini-migration, which will not show dropped/invited members
const newAttributes = {
...conversation.attributes,
// Core GroupV2 info
revision,
groupId,
groupVersion: 2,
masterKey,
publicParams,
secretParams,
groupInviteLinkPassword: inviteLinkPassword,
left: true,
// Capture previous GroupV1 data for future use
previousGroupV1Id: conversation.get('groupId'),
previousGroupV1Members: conversation.get('members'),
// Clear storage ID, since we need to start over on the storage service
storageID: undefined,
// Clear obsolete data
derivedGroupV2Id: undefined,
members: undefined,
};
const groupChangeMessages = [
{
...generateBasicMessage(),
type: 'group-v1-migration',
groupMigration: {
areWeInvited: false,
invitedMembers: [],
droppedMemberIds: [],
},
},
];
await updateGroup({
conversation,
updates: {
newAttributes,
groupChangeMessages,
members: [],
},
});
// Now things are set up, so we can go through normal channels
await conversation.joinGroupV2ViaLink({
inviteLinkPassword,
approvalRequired,
});
}
// This may be called from storage service, an out-of-band check, or an incoming message.
// If this is kicked off via an incoming message, we want to do the right thing and hit
// the log endpoint - the parameters beyond conversation are needed in that scenario.
@ -1459,17 +1873,11 @@ export async function respondToGroupV2Migration({
);
}
// If we were not previously a member, we won't migrate
const ourConversationId = window.ConversationController.getOurConversationId();
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const wereWePreviouslyAMember =
!conversation.get('left') &&
ourConversationId &&
conversation.hasMember(ourConversationId);
if (!ourConversationId) {
throw new Error(
`respondToGroupV2Migration: No conversationId when attempting to migrate ${conversation.idForLogging()}. Returning early.`
);
}
// Derive GroupV2 fields
const groupV1IdBuffer = fromEncodedBinaryToArrayBuffer(previousGroupV1Id);
@ -1477,7 +1885,7 @@ export async function respondToGroupV2Migration({
const fields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(fields.id);
const logId = `groupv2(${groupId})`;
const logId = idForLogging(groupId);
window.log.info(
`respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}`
);
@ -1600,17 +2008,11 @@ export async function respondToGroupV2Migration({
groupState,
});
// Assemble items to commemorate this event for the timeline..
const combinedConversationIds: Array<string> = [
...(newAttributes.membersV2 || []).map(item => item.conversationId),
...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId),
];
const droppedMemberIds: Array<string> = difference(
previousGroupV1MembersIds,
combinedConversationIds
).filter(id => id && id !== ourConversationId);
const invitedMembers = (newAttributes.pendingMembersV2 || []).filter(
item => item.conversationId !== ourConversationId
// Generate notifications into the timeline
const groupChangeMessages: Array<MessageAttributesType> = [];
groupChangeMessages.push(
buildMigrationBubble(previousGroupV1MembersIds, newAttributes)
);
const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
@ -1619,19 +2021,6 @@ export async function respondToGroupV2Migration({
const areWeMember = (newAttributes.membersV2 || []).some(
item => item.conversationId === ourConversationId
);
// Generate notifications into the timeline
const groupChangeMessages: Array<MessageAttributesType> = [];
groupChangeMessages.push({
...generateBasicMessage(),
type: 'group-v1-migration',
groupMigration: {
areWeInvited,
invitedMembers,
droppedMemberIds,
},
});
if (!areWeInvited && !areWeMember) {
// Add a message to the timeline saying the user was removed. This shouldn't happen.
groupChangeMessages.push({
@ -1764,6 +2153,8 @@ async function updateGroup({
const isInitialDataFetch =
!isNumber(startingRevision) && isNumber(endingRevision);
const isInGroup = !updates.newAttributes.left;
const justJoinedGroup = conversation.get('left') && isInGroup;
// Ensure that all generated messages are ordered properly.
// Before the provided timestamp so update messages appear before the
@ -1782,9 +2173,12 @@ async function updateGroup({
// fetched data about it, and we were able to fetch its name. Nobody likes to see
// Unknown Group in the left pane.
active_at:
isInitialDataFetch && newAttributes.name
(isInitialDataFetch || justJoinedGroup) && newAttributes.name
? finalReceivedAt
: newAttributes.active_at,
temporaryMemberCount: isInGroup
? undefined
: newAttributes.temporaryMemberCount,
});
if (idChanged) {
@ -1843,14 +2237,18 @@ async function getGroupUpdates({
newRevision?: number;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
window.log.info(`getGroupUpdates/${logId}: Starting...`);
const currentRevision = group.revision;
const isFirstFetch = !isNumber(group.revision);
const ourConversationId = window.ConversationController.getOurConversationId();
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
item => item.conversationId === ourConversationId
);
const isOneVersionUp =
isNumber(currentRevision) &&
isNumber(newRevision) &&
@ -1860,7 +2258,7 @@ async function getGroupUpdates({
window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
groupChangeBase64 &&
isNumber(newRevision) &&
(isInitialCreationMessage || isOneVersionUp)
(isInitialCreationMessage || weAreAwaitingApproval || isOneVersionUp)
) {
window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
@ -1872,7 +2270,12 @@ async function getGroupUpdates({
groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;
if (isChangeSupported) {
return integrateGroupChange({ group, newRevision, groupChange });
return updateGroupViaSingleChange({
group,
newRevision,
groupChange,
serverPublicParamsBase64,
});
}
window.log.info(
@ -1933,7 +2336,7 @@ async function updateGroupViaState({
group: ConversationAttributesType;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
if (!data) {
throw new Error('updateGroupViaState: No group credentials!');
@ -1980,6 +2383,46 @@ async function updateGroupViaState({
}
}
async function updateGroupViaSingleChange({
group,
groupChange,
newRevision,
serverPublicParamsBase64,
}: {
group: ConversationAttributesType;
groupChange: GroupChangeClass;
newRevision: number;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const wasInGroup = !group.left;
const result: UpdatesResultType = await integrateGroupChange({
group,
groupChange,
newRevision,
});
const nowInGroup = !result.newAttributes.left;
// If we were just added to the group (for example, via a join link), we go fetch the
// entire group state to make sure we're up to date.
if (!wasInGroup && nowInGroup) {
const { newAttributes, members } = await updateGroupViaState({
group: result.newAttributes,
serverPublicParamsBase64,
});
// We discard any change events that come out of this full group fetch, but we do
// keep the final group attributes generated, as well as any new members.
return {
...result,
members: [...result.members, ...members],
newAttributes,
};
}
return result;
}
async function updateGroupViaLogs({
group,
serverPublicParamsBase64,
@ -1989,7 +2432,7 @@ async function updateGroupViaLogs({
newRevision: number;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
if (!data) {
throw new Error('getGroupUpdates: No group credentials!');
@ -2032,10 +2475,10 @@ function generateBasicMessage() {
} as MessageAttributesType;
}
function generateLeftGroupChanges(
async function generateLeftGroupChanges(
group: ConversationAttributesType
): UpdatesResultType {
const logId = idForLogging(group);
): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
window.log.info(`generateLeftGroupChanges/${logId}: Starting...`);
const ourConversationId = window.ConversationController.getOurConversationId();
if (!ourConversationId) {
@ -2043,6 +2486,29 @@ function generateLeftGroupChanges(
'generateLeftGroupChanges: We do not have a conversationId!'
);
}
const { masterKey, groupInviteLinkPassword } = group;
let { revision } = group;
try {
if (masterKey && groupInviteLinkPassword) {
window.log.info(
`generateLeftGroupChanges/${logId}: Have invite link. Attempting to fetch latest revision with it.`
);
const preJoinInfo = await getPreJoinGroupInfo(
groupInviteLinkPassword,
masterKey
);
revision = preJoinInfo.version;
}
} catch (error) {
window.log.warn(
'generateLeftGroupChanges: Failed to fetch latest revision via group link. Code:',
error.code
);
}
const existingMembers = group.membersV2 || [];
const newAttributes: ConversationAttributesType = {
...group,
@ -2050,6 +2516,7 @@ function generateLeftGroupChanges(
member => member.conversationId !== ourConversationId
),
left: true,
revision,
};
const isNewlyRemoved =
existingMembers.length > (newAttributes.membersV2 || []).length;
@ -2162,7 +2629,7 @@ async function integrateGroupChanges({
newRevision: number;
changes: Array<GroupChangesClass>;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
let attributes = group;
const finalMessages: Array<Array<MessageAttributesType>> = [];
const finalMembers: Array<Array<MemberType>> = [];
@ -2258,7 +2725,7 @@ async function integrateGroupChange({
groupState?: GroupClass;
newRevision: number;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
if (!group.secretParams) {
throw new Error(
`integrateGroupChange/${logId}: Group was missing secretParams!`
@ -2299,7 +2766,16 @@ async function integrateGroupChange({
isNumber(group.revision) &&
groupChangeActions.version > group.revision + 1;
if (!isChangeSupported || isFirstFetch || isMoreThanOneVersionUp) {
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find(
item => item.conversationId === ourConversationId
);
if (
!isChangeSupported ||
isFirstFetch ||
(isMoreThanOneVersionUp && !weAreAwaitingApproval)
) {
if (!groupState) {
throw new Error(
`integrateGroupChange/${logId}: No group state, but we can't apply changes!`
@ -2372,7 +2848,7 @@ async function getCurrentGroupState({
group: ConversationAttributesType;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error('textsecure.messaging is not available!');
@ -2425,7 +2901,7 @@ function extractDiffs({
old: ConversationAttributesType;
sourceConversationId?: string;
}): Array<MessageAttributesType> {
const logId = idForLogging(old);
const logId = idForLogging(old.groupId);
const details: Array<GroupV2ChangeDetailType> = [];
const ourConversationId = window.ConversationController.getOurConversationId();
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
@ -2847,7 +3323,7 @@ async function applyGroupChange({
group: ConversationAttributesType;
sourceConversationId: string;
}): Promise<GroupChangeResultType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
const ourConversationId = window.ConversationController.getOurConversationId();
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
@ -3335,11 +3811,11 @@ async function applyGroupChange({
// Ovewriting result.avatar as part of functionality
/* eslint-disable no-param-reassign */
async function applyNewAvatar(
export async function applyNewAvatar(
newAvatar: string | undefined,
result: ConversationAttributesType,
result: Pick<ConversationAttributesType, 'avatar' | 'secretParams'>,
logId: string
) {
): Promise<void> {
try {
// Avatar has been dropped
if (!newAvatar && result.avatar) {
@ -3413,7 +3889,7 @@ async function applyGroupState({
groupState: GroupClass;
sourceConversationId?: string;
}): Promise<ConversationAttributesType> {
const logId = idForLogging(group);
const logId = idForLogging(group.groupId);
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
const version = groupState.version || 0;
@ -4144,6 +4620,24 @@ function decryptGroupChange(
return actions;
}
export function decryptGroupTitle(
title: ProtoBinaryType,
secretParams: string
): string | undefined {
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
if (hasData(title)) {
const blob = window.textsecure.protobuf.GroupAttributeBlob.decode(
decryptGroupBlob(clientZkGroupCipher, title.toArrayBuffer())
);
if (blob && blob.content === 'title') {
return blob.title;
}
}
return undefined;
}
function decryptGroupState(
groupState: GroupClass,
groupSecretParams: string,

423
ts/groups/joinViaLink.ts Normal file
View file

@ -0,0 +1,423 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
applyNewAvatar,
decryptGroupTitle,
deriveGroupFields,
getPreJoinGroupInfo,
idForLogging,
LINK_VERSION_ERROR,
parseGroupLink,
} from '../groups';
import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
import type { GroupJoinInfoClass } from '../textsecure.d';
import type { ConversationAttributesType } from '../model-types.d';
import type { ConversationModel } from '../models/conversations';
import type { PreJoinConversationType } from '../state/ducks/conversations';
export async function joinViaLink(hash: string): Promise<void> {
let inviteLinkPassword: string;
let masterKey: string;
try {
({ inviteLinkPassword, masterKey } = parseGroupLink(hash));
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(`joinViaLink: Failed to parse group link ${errorString}`);
if (error && error.name === LINK_VERSION_ERROR) {
showErrorDialog(
window.i18n('GroupV2--join--unknown-link-version'),
window.i18n('GroupV2--join--unknown-link-version--title')
);
} else {
showErrorDialog(
window.i18n('GroupV2--join--invalid-link'),
window.i18n('GroupV2--join--invalid-link--title')
);
}
return;
}
const data = deriveGroupFields(base64ToArrayBuffer(masterKey));
const id = arrayBufferToBase64(data.id);
const logId = `groupv2(${id})`;
const secretParams = arrayBufferToBase64(data.secretParams);
const publicParams = arrayBufferToBase64(data.publicParams);
const existingConversation =
window.ConversationController.get(id) ||
window.ConversationController.getByDerivedGroupV2Id(id);
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
if (
existingConversation &&
existingConversation.hasMember(ourConversationId)
) {
window.log.warn(
`joinViaLink/${logId}: Already a member of group, opening conversation`
);
window.reduxActions.conversations.openConversationInternal(
existingConversation.id
);
window.window.Whisper.ToastView.show(
window.Whisper.AlreadyGroupMemberToast,
document.getElementsByClassName('conversation-stack')[0]
);
return;
}
let result: GroupJoinInfoClass;
try {
result = await longRunningTaskWrapper({
name: 'getPreJoinGroupInfo',
idForLogging: idForLogging(id),
// If an error happens here, we won't show a dialog. We'll rely on the catch a few
// lines below.
suppressErrorDialog: true,
task: () => getPreJoinGroupInfo(inviteLinkPassword, masterKey),
});
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`joinViaLink/${logId}: Failed to fetch group info - ${errorString}`
);
showErrorDialog(
error.code && error.code === 403
? window.i18n('GroupV2--join--link-revoked')
: window.i18n('GroupV2--join--general-join-failure'),
error.code && error.code === 403
? window.i18n('GroupV2--join--link-revoked--title')
: window.i18n('GroupV2--join--general-join-failure--title')
);
return;
}
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
if (
result.addFromInviteLink !== ACCESS_ENUM.ADMINISTRATOR &&
result.addFromInviteLink !== ACCESS_ENUM.ANY
) {
window.log.error(
`joinViaLink/${logId}: addFromInviteLink value of ${result.addFromInviteLink} is invalid`
);
showErrorDialog(
window.i18n('GroupV2--join--link-revoked'),
window.i18n('GroupV2--join--link-revoked--title')
);
return;
}
let localAvatar:
| {
loading?: boolean;
path?: string;
}
| undefined = result.avatar ? { loading: true } : undefined;
const memberCount = result.memberCount || 1;
const approvalRequired =
result.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR;
const title =
decryptGroupTitle(result.title, secretParams) ||
window.i18n('unknownGroup');
if (
approvalRequired &&
existingConversation &&
existingConversation.isMemberAwaitingApproval(ourConversationId)
) {
window.log.warn(
`joinViaLink/${logId}: Already awaiting approval, opening conversation`
);
window.reduxActions.conversations.openConversationInternal(
existingConversation.id
);
window.Whisper.ToastView.show(
window.Whisper.AlreadyRequestedToJoinToast,
document.getElementsByClassName('conversation-stack')[0]
);
return;
}
const getPreJoinConversation = (): PreJoinConversationType => {
let avatar;
if (!localAvatar) {
avatar = undefined;
} else if (localAvatar && localAvatar.loading) {
avatar = {
loading: true,
};
} else if (localAvatar && localAvatar.path) {
avatar = {
url: window.Signal.Migrations.getAbsoluteAttachmentPath(
localAvatar.path
),
};
}
return {
approvalRequired,
avatar,
memberCount,
title,
};
};
// Explode a promise so we know when this whole join process is complete
const { promise, resolve, reject } = explodePromise();
const closeDialog = async () => {
try {
if (groupV2InfoDialog) {
groupV2InfoDialog.remove();
groupV2InfoDialog = undefined;
}
window.reduxActions.conversations.setPreJoinConversation(undefined);
if (localAvatar && localAvatar.path) {
await window.Signal.Migrations.deleteAttachmentData(localAvatar.path);
}
resolve();
} catch (error) {
reject(error);
}
};
const join = async () => {
try {
if (groupV2InfoDialog) {
groupV2InfoDialog.remove();
groupV2InfoDialog = undefined;
}
window.reduxActions.conversations.setPreJoinConversation(undefined);
await longRunningTaskWrapper({
name: 'joinViaLink',
idForLogging: idForLogging(id),
// If an error happens here, we won't show a dialog. We'll rely on a top-level
// error dialog provided by the caller of this function.
suppressErrorDialog: true,
task: async () => {
let targetConversation =
existingConversation ||
window.ConversationController.get(id) ||
window.ConversationController.getByDerivedGroupV2Id(id);
let tempConversation: ConversationModel | undefined;
// Check again to ensure that we haven't already joined or requested to join
// via some other process. If so, just open that conversation.
if (
targetConversation &&
(targetConversation.hasMember(ourConversationId) ||
(approvalRequired &&
targetConversation.isMemberAwaitingApproval(ourConversationId)))
) {
window.log.warn(
`joinViaLink/${logId}: User is part of group on second check, opening conversation`
);
window.reduxActions.conversations.openConversationInternal(
targetConversation.id
);
return;
}
try {
if (!targetConversation) {
// Note: we save this temp conversation in the database, so we'll need to
// clean it up if something goes wrong
tempConversation = window.ConversationController.getOrCreate(
id,
'group',
{
// This will cause this conversation to be deleted at next startup
isTemporary: true,
groupVersion: 2,
masterKey,
secretParams,
publicParams,
left: true,
revision: result.version,
avatar:
localAvatar && localAvatar.path && result.avatar
? {
url: result.avatar,
path: localAvatar.path,
}
: undefined,
groupInviteLinkPassword: inviteLinkPassword,
name: title,
temporaryMemberCount: memberCount,
}
);
targetConversation = tempConversation;
} else {
// Ensure the group maintains the title and avatar you saw when attempting
// to join it.
targetConversation.set({
avatar:
localAvatar && localAvatar.path && result.avatar
? {
url: result.avatar,
path: localAvatar.path,
}
: undefined,
groupInviteLinkPassword: inviteLinkPassword,
name: title,
temporaryMemberCount: memberCount,
});
window.Signal.Data.updateConversation(
targetConversation.attributes
);
}
if (targetConversation.isGroupV1()) {
await targetConversation.joinGroupV2ViaLinkAndMigrate({
approvalRequired,
inviteLinkPassword,
revision: result.version || 0,
});
} else {
await targetConversation.joinGroupV2ViaLink({
inviteLinkPassword,
approvalRequired,
});
}
if (tempConversation) {
tempConversation.set({
// We want to keep this conversation around, since the join succeeded
isTemporary: undefined,
});
window.Signal.Data.updateConversation(
tempConversation.attributes
);
}
window.reduxActions.conversations.openConversationInternal(
targetConversation.id
);
} catch (error) {
// Delete newly-created conversation if we encountered any errors
if (tempConversation) {
window.ConversationController.dangerouslyRemoveById(
tempConversation.id
);
await window.Signal.Data.removeConversation(tempConversation.id, {
Conversation: window.Whisper.Conversation,
});
}
throw error;
}
},
});
resolve();
} catch (error) {
reject(error);
}
};
// Initial add to redux, with basic group information
window.reduxActions.conversations.setPreJoinConversation(
getPreJoinConversation()
);
window.log.info(`joinViaLink/${logId}: Showing modal`);
let groupV2InfoDialog = new Whisper.ReactWrapperView({
className: 'group-v2-join-dialog-wrapper',
JSX: window.Signal.State.Roots.createGroupV2JoinModal(window.reduxStore, {
join,
onClose: closeDialog,
}),
});
// We declare a new function here so we can await but not block
const fetchAvatar = async () => {
if (result.avatar) {
localAvatar = {
loading: true,
};
const attributes: Pick<
ConversationAttributesType,
'avatar' | 'secretParams'
> = {
avatar: null,
secretParams,
};
await applyNewAvatar(result.avatar, attributes, logId);
if (attributes.avatar && attributes.avatar.path) {
localAvatar = {
path: attributes.avatar.path,
};
// Dialog has been dismissed; we'll delete the unneeeded avatar
if (!groupV2InfoDialog) {
await window.Signal.Migrations.deleteAttachmentData(
attributes.avatar.path
);
return;
}
} else {
localAvatar = undefined;
}
// Update join dialog with newly-downloaded avatar
window.reduxActions.conversations.setPreJoinConversation(
getPreJoinConversation()
);
}
};
fetchAvatar();
await promise;
}
function showErrorDialog(description: string, title: string) {
const errorView = new window.Whisper.ReactWrapperView({
className: 'error-modal-wrapper',
Component: window.Signal.Components.ErrorModal,
props: {
title,
description,
onClose: () => {
errorView.remove();
},
},
});
}
function explodePromise(): {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
} {
let resolve: () => void;
let reject: (error: Error) => void;
const promise = new Promise<void>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return {
promise,
// Typescript thinks that resolve and reject can be undefined here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolve: resolve!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
reject: reject!,
};
}

9
ts/model-types.d.ts vendored
View file

@ -211,6 +211,9 @@ export type ConversationAttributesType = {
// Group-only
groupId?: string;
// A shorthand, representing whether the user is part of the group. Not strictly for
// when the user manually left the group. But historically, that was the only way
// to leave a group.
left: boolean;
groupVersion?: number;
@ -233,7 +236,7 @@ export type ConversationAttributesType = {
avatar?: {
url: string;
path: string;
hash: string;
hash?: string;
} | null;
expireTimer?: number;
membersV2?: Array<GroupV2MemberType>;
@ -242,6 +245,10 @@ export type ConversationAttributesType = {
groupInviteLinkPassword?: string;
previousGroupV1Id?: string;
previousGroupV1Members?: Array<string>;
// Used only when user is waiting for approval to join via link
isTemporary?: boolean;
temporaryMemberCount?: number;
};
export type GroupV2MemberType = {

View file

@ -331,6 +331,22 @@ export class ConversationModel extends window.Backbone.Model<
);
}
isMemberAwaitingApproval(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
}
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return window._.any(
pendingAdminApprovalV2,
item => item.conversationId === conversationId
);
}
isMember(conversationId: string): boolean {
if (!this.isGroupV2()) {
throw new Error(
@ -483,6 +499,97 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async addPendingApprovalRequest(): Promise<
GroupChangeClass.Actions | undefined
> {
const idLog = this.idForLogging();
// Hard-coded to our own ID, because you don't add other users for admin approval
const conversationId = window.ConversationController.getOurConversationIdOrThrow();
const toRequest = window.ConversationController.get(conversationId);
if (!toRequest) {
throw new Error(
`addPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
);
}
// We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will
// ensure that we have as much as we can get with the data we have.
let profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
await toRequest.getProfiles();
profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
throw new Error(
`promotePendingMember/${idLog}: No profileKeyCredential for conversation ${toRequest.idForLogging()}`
);
}
}
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (this.isMemberAwaitingApproval(conversationId)) {
window.log.warn(
`addPendingApprovalRequest/${idLog}: ${conversationId} already in pending approval.`
);
return undefined;
}
return window.Signal.Groups.buildAddPendingAdminApprovalMemberChange({
group: this.attributes,
profileKeyCredentialBase64,
serverPublicParamsBase64: window.getServerPublicParams(),
});
}
async addMember(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
const toRequest = window.ConversationController.get(conversationId);
if (!toRequest) {
throw new Error(
`addMember/${idLog}: No conversation found for conversation ${conversationId}`
);
}
// We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will
// ensure that we have as much as we can get with the data we have.
let profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
await toRequest.getProfiles();
profileKeyCredentialBase64 = toRequest.get('profileKeyCredential');
if (!profileKeyCredentialBase64) {
throw new Error(
`addMember/${idLog}: No profileKeyCredential for conversation ${toRequest.idForLogging()}`
);
}
}
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (this.isMember(conversationId)) {
window.log.warn(
`addMember/${idLog}: ${conversationId} already a member.`
);
return undefined;
}
return window.Signal.Groups.buildAddMember({
group: this.attributes,
profileKeyCredentialBase64,
serverPublicParamsBase64: window.getServerPublicParams(),
});
}
async removePendingMember(
conversationIds: Array<string>
): Promise<GroupChangeClass.Actions | undefined> {
@ -609,142 +716,19 @@ export class ConversationModel extends window.Backbone.Model<
async modifyGroupV2({
name,
inviteLinkPassword,
createGroupChange,
}: {
name: string;
inviteLinkPassword?: string;
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
}): Promise<void> {
const idLog = `${name}/${this.idForLogging()}`;
if (!this.isGroupV2()) {
throw new Error(
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
);
}
const ONE_MINUTE = 1000 * 60;
const startTime = Date.now();
const timeoutTime = startTime + ONE_MINUTE;
const MAX_ATTEMPTS = 5;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
try {
// eslint-disable-next-line no-await-in-loop
await window.waitForEmptyEventQueue();
window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
// eslint-disable-next-line no-await-in-loop
await this.queueJob(async () => {
window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
const actions = await createGroupChange();
if (!actions) {
window.log.warn(
`modifyGroupV2/${idLog}: No change actions. Returning early.`
);
return;
}
// The new revision has to be exactly one more than the current revision
// or it won't upload properly, and it won't apply in maybeUpdateGroup
const currentRevision = this.get('revision');
const newRevision = actions.version;
if ((currentRevision || 0) + 1 !== newRevision) {
throw new Error(
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
);
}
// Upload. If we don't have permission, the server will return an error here.
const groupChange = await window.Signal.Groups.uploadGroupChange({
actions,
group: this.attributes,
});
const groupChangeBuffer = groupChange.toArrayBuffer();
const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer);
// Apply change locally, just like we would with an incoming change. This will
// change conversation state and add change notifications to the timeline.
await window.Signal.Groups.maybeUpdateGroup({
conversation: this,
groupChangeBase64,
newRevision,
});
// Send message to notify group members (including pending members) of change
const profileKey = this.get('profileSharing')
? window.storage.get('profileKey')
: undefined;
const sendOptions = this.getSendOptions();
const timestamp = Date.now();
const promise = this.wrapSend(
window.textsecure.messaging.sendMessageToGroup(
{
groupV2: this.getGroupV2Info({
groupChange: groupChangeBuffer,
includePendingMembers: true,
}),
timestamp,
profileKey,
},
sendOptions
)
);
// We don't save this message; we just use it to ensure that a sync message is
// sent to our linked devices.
const m = new window.Whisper.Message(({
conversationId: this.id,
type: 'not-to-save',
sent_at: timestamp,
received_at: timestamp,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown) as MessageAttributesType);
// This is to ensure that the functions in send() and sendSyncMessage()
// don't save anything to the database.
m.doNotSave = true;
await m.send(promise);
});
// If we've gotten here with no error, we exit!
window.log.info(
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
);
break;
} catch (error) {
if (error.code === 409 && Date.now() <= timeoutTime) {
window.log.info(
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
);
// eslint-disable-next-line no-await-in-loop
await this.fetchLatestGroupV2Data();
} else if (error.code === 409) {
window.log.error(
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
);
// We don't wait here because we're breaking out of the loop immediately.
this.fetchLatestGroupV2Data();
throw error;
} else {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`modifyGroupV2/${idLog}: Error updating: ${errorString}`
);
throw error;
}
}
}
await window.Signal.Groups.modifyGroupV2({
createGroupChange,
conversation: this,
inviteLinkPassword,
name,
});
}
isEverUnregistered(): boolean {
@ -1324,6 +1308,9 @@ export class ConversationModel extends window.Backbone.Model<
areWePending: Boolean(
ourConversationId && this.isMemberPending(ourConversationId)
),
areWePendingApproval: Boolean(
ourConversationId && this.isMemberAwaitingApproval(ourConversationId)
),
areWeAdmin: this.areWeAdmin(),
canChangeTimer: this.canChangeTimer(),
canEditGroupInfo: this.canEditGroupInfo(),
@ -1353,9 +1340,7 @@ export class ConversationModel extends window.Backbone.Model<
lastUpdated: this.get('timestamp')!,
left: Boolean(this.get('left')),
markedUnread: this.get('markedUnread')!,
membersCount: this.isPrivate()
? undefined
: (this.get('membersV2')! || this.get('members')! || []).length,
membersCount: this.getMembersCount(),
memberships: this.getMemberships(),
pendingMemberships: this.getPendingMemberships(),
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
@ -1427,6 +1412,26 @@ export class ConversationModel extends window.Backbone.Model<
window.Signal.Data.updateConversation(this.attributes);
}
getMembersCount(): number {
if (this.isPrivate()) {
return 1;
}
const memberList = this.get('membersV2') || this.get('members');
// We'll fail over if the member list is empty
if (memberList && memberList.length) {
return memberList.length;
}
const temporaryMemberCount = this.get('temporaryMemberCount');
if (window._.isNumber(temporaryMemberCount)) {
return temporaryMemberCount;
}
return 0;
}
decrementMessageCount(): void {
this.set({
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
@ -1623,6 +1628,98 @@ export class ConversationModel extends window.Backbone.Model<
}
}
async joinGroupV2ViaLinkAndMigrate({
approvalRequired,
inviteLinkPassword,
revision,
}: {
approvalRequired: boolean;
inviteLinkPassword: string;
revision: number;
}): Promise<void> {
await window.Signal.Groups.joinGroupV2ViaLinkAndMigrate({
approvalRequired,
conversation: this,
inviteLinkPassword,
revision,
});
}
async joinGroupV2ViaLink({
inviteLinkPassword,
approvalRequired,
}: {
inviteLinkPassword: string;
approvalRequired: boolean;
}): Promise<void> {
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
try {
if (approvalRequired) {
await this.modifyGroupV2({
name: 'requestToJoin',
inviteLinkPassword,
createGroupChange: () => this.addPendingApprovalRequest(),
});
} else {
await this.modifyGroupV2({
name: 'joinGroup',
inviteLinkPassword,
createGroupChange: () => this.addMember(ourConversationId),
});
}
} catch (error) {
const ALREADY_REQUESTED_TO_JOIN =
'{"code":400,"message":"cannot ask to join via invite link if already asked to join"}';
if (!error.response) {
throw error;
} else {
const errorDetails = stringFromBytes(error.response);
if (errorDetails !== ALREADY_REQUESTED_TO_JOIN) {
throw error;
} else {
window.log.info(
'joinGroupV2ViaLink: Got 400, but server is telling us we have already requested to join. Forcing that local state'
);
this.set({
pendingAdminApprovalV2: [
{
conversationId: ourConversationId,
timestamp: Date.now(),
},
],
});
}
}
}
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
// Ensure active_at is set, because this is an event that justifies putting the group
// in the left pane.
this.set({
messageRequestResponseType: messageRequestEnum.ACCEPT,
active_at: this.get('active_at') || Date.now(),
});
window.Signal.Data.updateConversation(this.attributes);
}
async cancelJoinRequest(): Promise<void> {
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
const inviteLinkPassword = this.get('groupInviteLinkPassword');
if (!inviteLinkPassword) {
throw new Error('Missing groupInviteLinkPassword!');
}
await this.modifyGroupV2({
name: 'cancelJoinRequest',
inviteLinkPassword,
createGroupChange: () =>
this.denyPendingApprovalRequest(ourConversationId),
});
}
async leaveGroupV2(): Promise<void> {
const ourConversationId = window.ConversationController.getOurConversationId();

View file

@ -61,6 +61,7 @@ export type ConversationType = {
avatarPath?: string;
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
canChangeTimer?: boolean;
canEditGroupInfo?: boolean;
color?: ColorType;
@ -208,7 +209,18 @@ export type MessagesByConversationType = {
[key: string]: ConversationMessageType | undefined;
};
export type PreJoinConversationType = {
avatar?: {
loading?: boolean;
url?: string;
};
memberCount: number;
title: string;
approvalRequired: boolean;
};
export type ConversationsStateType = {
preJoinConversation?: PreJoinConversationType;
conversationLookup: ConversationLookupType;
conversationsByE164: ConversationLookupType;
conversationsByUuid: ConversationLookupType;
@ -252,6 +264,13 @@ export const getConversationCallMode = (
// Actions
type SetPreJoinConversationActionType = {
type: 'SET_PRE_JOIN_CONVERSATION';
payload: {
data: PreJoinConversationType | undefined;
};
};
type ConversationAddedActionType = {
type: 'CONVERSATION_ADDED';
payload: {
@ -421,34 +440,33 @@ type SetRecentMediaItemsActionType = {
};
export type ConversationActionType =
| ClearChangedMessagesActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
| ConversationUnloadedActionType
| RemoveAllConversationsActionType
| MessageSelectedActionType
| MessageSizeChangedActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
| MessageSelectedActionType
| MessageSizeChangedActionType
| MessagesResetActionType
| RemoveAllConversationsActionType
| RepairNewestMessageActionType
| RepairOldestMessageActionType
| MessagesResetActionType
| SetMessagesLoadingActionType
| ScrollToMessageActionType
| SelectedConversationChangedActionType
| SetConversationHeaderTitleActionType
| SetIsNearBottomActionType
| SetLoadCountdownStartActionType
| ClearChangedMessagesActionType
| ClearSelectedMessageActionType
| ClearUnreadMetricsActionType
| ScrollToMessageActionType
| SetConversationHeaderTitleActionType
| SetSelectedConversationPanelDepthActionType
| SelectedConversationChangedActionType
| MessageDeletedActionType
| SelectedConversationChangedActionType
| SetMessagesLoadingActionType
| SetPreJoinConversationActionType
| SetRecentMediaItemsActionType
| ShowInboxActionType
| ShowArchivedConversationsActionType;
| SetSelectedConversationPanelDepthActionType
| ShowArchivedConversationsActionType
| ShowInboxActionType;
// Action Creators
@ -462,8 +480,8 @@ export const actions = {
conversationUnloaded,
messageChanged,
messageDeleted,
messageSizeChanged,
messagesAdded,
messageSizeChanged,
messagesReset,
openConversationExternal,
openConversationInternal,
@ -475,6 +493,7 @@ export const actions = {
setIsNearBottom,
setLoadCountdownStart,
setMessagesLoading,
setPreJoinConversation,
setRecentMediaItems,
setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth,
@ -482,6 +501,16 @@ export const actions = {
showInbox,
};
function setPreJoinConversation(
data: PreJoinConversationType | undefined
): SetPreJoinConversationActionType {
return {
type: 'SET_PRE_JOIN_CONVERSATION',
payload: {
data,
},
};
}
function conversationAdded(
id: string,
data: ConversationType
@ -924,6 +953,15 @@ export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType>
): ConversationsStateType {
if (action.type === 'SET_PRE_JOIN_CONVERSATION') {
const { payload } = action;
const { data } = payload;
return {
...state,
preJoinConversation: data,
};
}
if (action.type === 'CONVERSATION_ADDED') {
const { payload } = action;
const { id, data } = payload;

View file

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { ModalHost } from '../../components/ModalHost';
import { SmartGroupV2JoinDialog, PropsType } from '../smart/GroupV2JoinDialog';
export const createGroupV2JoinModal = (
store: Store,
props: PropsType
): React.ReactElement => {
const { onClose } = props;
return (
<Provider store={store}>
<ModalHost onClose={onClose}>
<SmartGroupV2JoinDialog {...props} />
</ModalHost>
</Provider>
);
};

View file

@ -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 => {

View file

@ -0,0 +1,36 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import {
GroupV2JoinDialog,
PropsType as GroupV2JoinDialogPropsType,
} from '../../components/GroupV2JoinDialog';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getPreJoinConversation } from '../selectors/conversations';
export type PropsType = Pick<GroupV2JoinDialogPropsType, 'join' | 'onClose'>;
const mapStateToProps = (
state: StateType,
props: PropsType
): GroupV2JoinDialogPropsType => {
const preJoinConversation = getPreJoinConversation(state);
if (!preJoinConversation) {
throw new Error('smart/GroupV2JoinDialog: No pre-join conversation!');
}
return {
...props,
...preJoinConversation,
i18n: getIntl(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartGroupV2JoinDialog = smart(GroupV2JoinDialog);

View file

@ -0,0 +1,85 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { toWebSafeBase64, fromWebSafeBase64 } from '../../util/webSafeBase64';
describe('both/util/webSafeBase64', () => {
it('roundtrips with all elements', () => {
const base64 = 'X0KjoAj3h7Tu9YjJ++PamFc4kAg//D4FKommANpP41I=';
const webSafe = toWebSafeBase64(base64);
const actual = fromWebSafeBase64(webSafe);
assert.strictEqual(base64, actual);
});
describe('#toWebSafeBase64', () => {
it('replaces +', () => {
const base64 = 'X++y';
const expected = 'X--y';
const actual = toWebSafeBase64(base64);
assert.strictEqual(expected, actual);
});
it('replaces /', () => {
const base64 = 'X//y';
const expected = 'X__y';
const actual = toWebSafeBase64(base64);
assert.strictEqual(expected, actual);
});
it('removes =', () => {
const base64 = 'X===';
const expected = 'X';
const actual = toWebSafeBase64(base64);
assert.strictEqual(expected, actual);
});
});
describe('#fromWebSafeBase64', () => {
it('replaces -', () => {
const webSafeBase64 = 'X--y';
const expected = 'X++y';
const actual = fromWebSafeBase64(webSafeBase64);
assert.strictEqual(expected, actual);
});
it('replaces _', () => {
const webSafeBase64 = 'X__y';
const expected = 'X//y';
const actual = fromWebSafeBase64(webSafeBase64);
assert.strictEqual(expected, actual);
});
it('adds ===', () => {
const webSafeBase64 = 'X';
const expected = 'X===';
const actual = fromWebSafeBase64(webSafeBase64);
assert.strictEqual(expected, actual);
});
it('adds ==', () => {
const webSafeBase64 = 'Xy';
const expected = 'Xy==';
const actual = fromWebSafeBase64(webSafeBase64);
assert.strictEqual(expected, actual);
});
it('adds =', () => {
const webSafeBase64 = 'XyZ';
const expected = 'XyZ=';
const actual = fromWebSafeBase64(webSafeBase64);
assert.strictEqual(expected, actual);
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -100,11 +100,12 @@ describe('sgnlHref', () => {
'sgnl://foo?',
'SGNL://foo?',
'sgnl://user:pass@foo',
'sgnl://foo/path/data#hash-data',
'sgnl://foo/path/data',
].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: 'foo',
args: new Map<string, string>(),
hash: undefined,
});
});
});
@ -124,6 +125,7 @@ describe('sgnlHref', () => {
['empty', ''],
['encoded', 'hello world'],
]),
hash: undefined,
}
);
});
@ -144,17 +146,30 @@ describe('sgnlHref', () => {
);
});
it('includes hash', () => {
[
'sgnl://foo?bar=baz#somehash',
'sgnl://user:pass@foo?bar=baz#somehash',
].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: 'foo',
args: new Map([['bar', 'baz']]),
hash: 'somehash',
});
});
});
it('ignores other parts of the URL', () => {
[
'sgnl://foo?bar=baz',
'sgnl://foo/?bar=baz',
'sgnl://foo/lots/of/path?bar=baz',
'sgnl://foo?bar=baz#hash',
'sgnl://user:pass@foo?bar=baz',
].forEach(href => {
assert.deepEqual(parseSgnlHref(href, explodingLogger), {
command: 'foo',
args: new Map([['bar', 'baz']]),
hash: undefined,
});
});
});

36
ts/textsecure.d.ts vendored
View file

@ -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

View file

@ -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(

View file

@ -35,6 +35,7 @@ import {
GroupChangeClass,
GroupClass,
GroupExternalCredentialClass,
GroupJoinInfoClass,
StorageServiceCallOptionsType,
StorageServiceCredentials,
SyncMessageClass,
@ -1769,6 +1770,13 @@ export default class MessageSender {
return this.server.getGroup(options);
}
async getGroupFromLink(
groupInviteLink: string,
auth: GroupCredentialsType
): Promise<GroupJoinInfoClass> {
return this.server.getGroupFromLink(groupInviteLink, auth);
}
async getGroupLog(
startVersion: number,
options: GroupCredentialsType
@ -1782,9 +1790,10 @@ export default class MessageSender {
async modifyGroup(
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
options: GroupCredentialsType,
inviteLinkBase64?: string
): Promise<GroupChangeClass> {
return this.server.modifyGroup(changes, options);
return this.server.modifyGroup(changes, options, inviteLinkBase64);
}
async leaveGroup(

View file

@ -29,6 +29,7 @@ import { v4 as getGuid } from 'uuid';
import { Long } from '../window.d';
import { getUserAgent } from '../util/getUserAgent';
import { toWebSafeBase64 } from '../util/webSafeBase64';
import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
import {
arrayBufferToBase64,
@ -50,6 +51,7 @@ import {
GroupChangeClass,
GroupChangesClass,
GroupClass,
GroupJoinInfoClass,
GroupExternalCredentialClass,
StorageServiceCallOptionsType,
StorageServiceCredentials,
@ -58,6 +60,11 @@ import {
import { WebSocket } from './WebSocket';
import MessageSender from './SendMessage';
// Note: this will break some code that expects to be able to use err.response when a
// web request fails, because it will force it to text. But it is very useful for
// debugging failed requests.
const DEBUG = false;
type SgxConstantsType = {
SGX_FLAGS_INITTED: Long;
SGX_FLAGS_DEBUG: Long;
@ -340,6 +347,10 @@ type ArrayBufferWithDetailsType = {
response: Response;
};
function isSuccess(status: number): boolean {
return status >= 0 && status < 400;
}
async function _promiseAjax(
providedUrl: string | null,
options: PromiseAjaxOptionsType
@ -432,7 +443,9 @@ async function _promiseAjax(
}
let resultPromise;
if (
if (DEBUG && !isSuccess(response.status)) {
resultPromise = response.text();
} else if (
(options.responseType === 'json' ||
options.responseType === 'jsonwithdetails') &&
response.headers.get('Content-Type') === 'application/json'
@ -448,45 +461,51 @@ async function _promiseAjax(
}
return resultPromise.then(result => {
if (
options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails'
) {
result = result.buffer.slice(
result.byteOffset,
result.byteOffset + result.byteLength
);
}
if (
options.responseType === 'json' ||
options.responseType === 'jsonwithdetails'
) {
if (options.validateResponse) {
if (!_validateResponse(result, options.validateResponse)) {
if (options.redactUrl) {
window.log.info(
options.type,
options.redactUrl(url),
response.status,
'Error'
if (isSuccess(response.status)) {
if (
options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails'
) {
result = result.buffer.slice(
result.byteOffset,
result.byteOffset + result.byteLength
);
}
if (
options.responseType === 'json' ||
options.responseType === 'jsonwithdetails'
) {
if (options.validateResponse) {
if (!_validateResponse(result, options.validateResponse)) {
if (options.redactUrl) {
window.log.info(
options.type,
options.redactUrl(url),
response.status,
'Error'
);
} else {
window.log.error(
options.type,
url,
response.status,
'Error'
);
}
reject(
makeHTTPError(
'promiseAjax: invalid response',
response.status,
result,
options.stack
)
);
} else {
window.log.error(options.type, url, response.status, 'Error');
}
reject(
makeHTTPError(
'promiseAjax: invalid response',
response.status,
result,
options.stack
)
);
return;
return;
}
}
}
}
if (response.status >= 0 && response.status < 400) {
if (options.redactUrl) {
window.log.info(
options.type,
@ -605,6 +624,10 @@ function makeHTTPError(
const e = new Error(`${message}; code: ${code}`);
e.name = 'HTTPError';
e.code = code;
if (DEBUG && response) {
e.stack += `\nresponse: ${response}`;
}
e.stack += `\nOriginal stack:\n${stack}`;
if (response) {
e.response = response;
@ -628,6 +651,7 @@ const URL_CALLS = {
getStickerPackUpload: 'v1/sticker/pack/form',
groupLog: 'v1/groups/logs',
groups: 'v1/groups',
groupsViaLink: 'v1/groups/join',
groupToken: 'v1/groups/token',
keys: 'v2/keys',
messages: 'v1/messages',
@ -734,6 +758,10 @@ export type WebAPIType = {
getAvatar: (path: string) => Promise<any>;
getDevices: () => Promise<any>;
getGroup: (options: GroupCredentialsType) => Promise<GroupClass>;
getGroupFromLink: (
inviteLinkPassword: string,
auth: GroupCredentialsType
) => Promise<GroupJoinInfoClass>;
getGroupAvatar: (key: string) => Promise<ArrayBuffer>;
getGroupCredentials: (
startDay: number,
@ -803,7 +831,8 @@ export type WebAPIType = {
) => Promise<ArrayBufferWithDetailsType>;
modifyGroup: (
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
options: GroupCredentialsType,
inviteLinkBase64?: string
) => Promise<GroupChangeClass>;
modifyStorageRecords: MessageSender['modifyStorageRecords'];
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
@ -955,6 +984,8 @@ export function initialize({
return {
confirmCode,
createGroup,
fetchLinkPreviewImage,
fetchLinkPreviewMetadata,
getAttachment,
getAvatar,
getConfig,
@ -963,6 +994,7 @@ export function initialize({
getGroupAvatar,
getGroupCredentials,
getGroupExternalCredential,
getGroupFromLink,
getGroupLog,
getIceServers,
getKeysForIdentifier,
@ -979,8 +1011,6 @@ export function initialize({
getStorageManifest,
getStorageRecords,
getUuidsForE164s,
fetchLinkPreviewMetadata,
fetchLinkPreviewImage,
makeProxiedRequest,
makeSfuRequest,
modifyGroup,
@ -2052,9 +2082,32 @@ export function initialize({
return window.textsecure.protobuf.Group.decode(response);
}
async function getGroupFromLink(
inviteLinkPassword: string,
auth: GroupCredentialsType
): Promise<GroupJoinInfoClass> {
const basicAuth = generateGroupAuth(
auth.groupPublicParamsHex,
auth.authCredentialPresentationHex
);
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'groupsViaLink',
contentType: 'application/x-protobuf',
host: storageUrl,
httpType: 'GET',
responseType: 'arraybuffer',
urlParameters: `/${toWebSafeBase64(inviteLinkPassword)}`,
});
return window.textsecure.protobuf.GroupJoinInfo.decode(response);
}
async function modifyGroup(
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
options: GroupCredentialsType,
inviteLinkBase64?: string
): Promise<GroupChangeClass> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
@ -2070,6 +2123,9 @@ export function initialize({
host: storageUrl,
httpType: 'PATCH',
responseType: 'arraybuffer',
urlParameters: inviteLinkBase64
? `?inviteLinkPassword=${toWebSafeBase64(inviteLinkBase64)}`
: undefined,
});
return window.textsecure.protobuf.GroupChange.decode(response);

View file

@ -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,
};

View file

@ -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"
},

View file

@ -0,0 +1,90 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export async function longRunningTaskWrapper<T>({
name,
idForLogging,
task,
suppressErrorDialog,
}: {
name: string;
idForLogging: string;
task: () => Promise<T>;
suppressErrorDialog?: boolean;
}): Promise<T> {
const idLog = `${name}/${idForLogging}`;
const ONE_SECOND = 1000;
const TWO_SECONDS = 2000;
let progressView: typeof Whisper.ReactWrapperView | undefined;
let spinnerStart;
let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => {
window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`);
// Note: this component uses a portal to render itself into the top-level DOM. No
// need to attach it to the DOM here.
progressView = new Whisper.ReactWrapperView({
className: 'progress-modal-wrapper',
Component: window.Signal.Components.ProgressModal,
});
spinnerStart = Date.now();
}, TWO_SECONDS);
// Note: any task we put here needs to have its own safety valve; this function will
// show a spinner until it's done
try {
window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`);
const result = await task();
window.log.info(
`longRunningTaskWrapper/${idLog}: Task completed successfully`
);
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = undefined;
}
if (progressView) {
const now = Date.now();
if (spinnerStart && now - spinnerStart < ONE_SECOND) {
window.log.info(
`longRunningTaskWrapper/${idLog}: Spinner shown for less than second, showing for another second`
);
await window.Signal.Util.sleep(ONE_SECOND);
}
progressView.remove();
progressView = undefined;
}
return result;
} catch (error) {
window.log.error(
`longRunningTaskWrapper/${idLog}: Error!`,
error && error.stack ? error.stack : error
);
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = undefined;
}
if (progressView) {
progressView.remove();
progressView = undefined;
}
if (!suppressErrorDialog) {
window.log.info(`longRunningTaskWrapper/${idLog}: Showing error dialog`);
// Note: this component uses a portal to render itself into the top-level DOM. No
// need to attach it to the DOM here.
const errorView = new Whisper.ReactWrapperView({
className: 'error-modal-wrapper',
Component: window.Signal.Components.ErrorModal,
props: {
onClose: () => errorView.remove(),
},
});
}
throw error;
}
}

View file

@ -25,7 +25,7 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
type ParsedSgnlHref =
| { command: null; args: Map<never, never> }
| { command: string; args: Map<string, string> };
| { command: string; args: Map<string, string>; hash: string | undefined };
export function parseSgnlHref(
href: string,
logger: LoggerType
@ -42,5 +42,9 @@ export function parseSgnlHref(
}
});
return { command: url.host, args };
return {
command: url.host,
args,
hash: url.hash ? url.hash.slice(1) : undefined,
};
}

25
ts/util/webSafeBase64.ts Normal file
View file

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function toWebSafeBase64(base64: string): string {
return base64.replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, '');
}
export function fromWebSafeBase64(webSafeBase64: string): string {
const base64 = webSafeBase64.replace(/_/g, '/').replace(/-/g, '+');
// Ensure that the character count is a multiple of four, filling in the extra
// space needed with '='
const remainder = base64.length % 4;
if (remainder === 3) {
return `${base64}=`;
}
if (remainder === 2) {
return `${base64}==`;
}
if (remainder === 1) {
return `${base64}===`;
}
return base64;
}

View file

@ -280,6 +280,14 @@ Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: window.i18n('maximumAttachments'),
});
Whisper.AlreadyGroupMemberToast = Whisper.ToastView.extend({
template: window.i18n('GroupV2--join--already-in-group'),
});
Whisper.AlreadyRequestedToJoinToast = Whisper.ToastView.extend({
template: window.i18n('GroupV2--join--already-awaiting-approval'),
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen',
className: 'conversation-loading-screen',
@ -660,6 +668,21 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
onStartGroupMigration: () => this.startMigrationToGV2(),
onCancelJoinRequest: async () => {
await window.showConfirmationDialog({
message: window.i18n(
'GroupV2--join--cancel-request-to-join--confirmation'
),
okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'),
cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'onCancelJoinRequest',
task: async () => this.model.cancelJoinRequest(),
});
},
});
},
};
this.compositionAreaView = new Whisper.ReactWrapperView({
@ -681,79 +704,12 @@ Whisper.ConversationView = Whisper.View.extend({
name: string;
task: () => Promise<T>;
}): Promise<T> {
const idLog = `${name}/${this.model.idForLogging()}`;
const ONE_SECOND = 1000;
const TWO_SECONDS = 2000;
let progressView: any | undefined;
let spinnerStart;
let progressTimeout: NodeJS.Timeout | undefined = setTimeout(() => {
window.log.info(`longRunningTaskWrapper/${idLog}: Creating spinner`);
// Note: this component uses a portal to render itself into the top-level DOM. No
// need to attach it to the DOM here.
progressView = new Whisper.ReactWrapperView({
className: 'progress-modal-wrapper',
Component: window.Signal.Components.ProgressModal,
});
spinnerStart = Date.now();
}, TWO_SECONDS);
// Note: any task we put here needs to have its own safety valve; this function will
// show a spinner until it's done
try {
window.log.info(`longRunningTaskWrapper/${idLog}: Starting task`);
const result = await task();
window.log.info(
`longRunningTaskWrapper/${idLog}: Task completed successfully`
);
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = undefined;
}
if (progressView) {
const now = Date.now();
if (spinnerStart && now - spinnerStart < ONE_SECOND) {
window.log.info(
`longRunningTaskWrapper/${idLog}: Spinner shown for less than second, showing for another second`
);
await window.Signal.Util.sleep(ONE_SECOND);
}
progressView.remove();
progressView = undefined;
}
return result;
} catch (error) {
window.log.error(
`longRunningTaskWrapper/${idLog}: Error!`,
error && error.stack ? error.stack : error
);
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = undefined;
}
if (progressView) {
progressView.remove();
progressView = undefined;
}
window.log.info(`longRunningTaskWrapper/${idLog}: Showing error dialog`);
// Note: this component uses a portal to render itself into the top-level DOM. No
// need to attach it to the DOM here.
const errorView = new Whisper.ReactWrapperView({
className: 'error-modal-wrapper',
Component: window.Signal.Components.ErrorModal,
props: {
onClose: () => errorView.remove(),
},
});
throw error;
}
const idForLogging = this.model.idForLogging();
return window.Signal.Util.longRunningTaskWrapper({
name,
idForLogging,
task,
});
},
setupTimeline() {

11
ts/window.d.ts vendored
View file

@ -47,6 +47,7 @@ import { createConversationDetails } from './state/roots/createConversationDetai
import { createConversationHeader } from './state/roots/createConversationHeader';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
import { createGroupV2Permissions } from './state/roots/createGroupV2Permissions';
import { createLeftPane } from './state/roots/createLeftPane';
import { createPendingInvites } from './state/roots/createPendingInvites';
@ -458,6 +459,7 @@ declare global {
createConversationHeader: typeof createConversationHeader;
createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
createGroupV2JoinModal: typeof createGroupV2JoinModal;
createGroupV2Permissions: typeof createGroupV2Permissions;
createLeftPane: typeof createLeftPane;
createPendingInvites: typeof createPendingInvites;
@ -510,7 +512,10 @@ declare global {
readyForUpdates: () => void;
logAppLoadedEvent: () => void;
// Flags
// Runtime Flags
isShowingModal?: boolean;
// Feature Flags
isGroupCallingEnabled: () => boolean;
GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean;
GV2_ENABLE_CHANGE_PROCESSING: boolean;
@ -640,7 +645,7 @@ export type WhisperType = {
ReactWrapperView: WhatIsThis;
activeConfirmationView: WhatIsThis;
ToastView: typeof Whisper.View & {
show: (view: Backbone.View, el: Element) => void;
show: (view: typeof Backbone.View, el: Element) => void;
};
ConversationArchivedToast: WhatIsThis;
ConversationUnarchivedToast: WhatIsThis;
@ -715,6 +720,8 @@ export type WhisperType = {
deliveryReceiptBatcher: BatcherType<WhatIsThis>;
RotateSignedPreKeyListener: WhatIsThis;
AlreadyGroupMemberToast: typeof Whisper.ToastView;
AlreadyRequestedToJoinToast: typeof Whisper.ToastView;
BlockedGroupToast: typeof Whisper.ToastView;
BlockedToast: typeof Whisper.ToastView;
CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView;