Introduce new 'Block request' button in timeline

This commit is contained in:
Scott Nonnenberg 2022-03-15 17:11:28 -07:00 committed by GitHub
parent 536dd0c7b0
commit 703bb8a3a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1088 additions and 157 deletions

View File

@ -5116,6 +5116,30 @@
}
}
},
"GroupV2--admin-approval-bounce--one": {
"message": "$joinerName$ requested and cancelled their request to join via the group link",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"joinerName": {
"content": "$1",
"example": "Alice"
}
}
},
"GroupV2--admin-approval-bounce": {
"message": "$joinerName$ requested and cancelled $numberOfRequests$ requests to join via the group link",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"joinerName": {
"content": "$1",
"example": "Alice"
},
"numberOfRequests": {
"content": "$1",
"example": "3"
}
}
},
"GroupV2--group-link-add--disabled--you": {
"message": "You turned on the group link with admin approval disabled.",
@ -5796,6 +5820,30 @@
"message": "Details about people invited to this group arent shown until they join. Invitees will only see messages after they join the group.",
"description": "Information shown below the invite list"
},
"PendingRequests--block--button": {
"message": "Block request",
"description": "Shown in timeline if users cancel their request to join a group via a group link"
},
"PendingRequests--block--title": {
"message": "Block request?",
"description": "Title of dialog to block a user from requesting to join via the link again"
},
"PendingRequests--block--contents": {
"message": "$name$ will not be able to join or request to join this group via the group link. They can still be added to the group manually.",
"description": "Details of dialog to block a user from requesting to join via the link again",
"placeholders": {
"name": {
"content": "$1",
"example": "Annoying Person"
}
}
},
"PendingRequests--block--confirm": {
"message": "Block Request",
"description": "Confirmation button of dialog to block a user from requesting to join via the link again"
},
"AvatarInput--no-photo-label--group": {
"message": "Add a group photo",
"description": "The label for the avatar uploader when no group photo is selected"

View File

@ -70,6 +70,11 @@ export class Intl extends React.Component<Props> {
public override render() {
const { components, id, i18n, renderText } = this.props;
if (!id) {
log.error('Error: Intl id prop not provided');
return null;
}
const text = i18n(id);
const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null

View File

@ -3,10 +3,12 @@
/* eslint-disable-next-line max-classes-per-file */
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { setupI18n } from '../../util/setupI18n';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import enMessages from '../../../_locales/en/messages.json';
import type { GroupV2ChangeType } from '../../groups';
import { SignalService as Proto } from '../../protobuf';
@ -34,9 +36,29 @@ const renderContact: SmartContactRendererType<FullJSXType> = (
</React.Fragment>
);
const renderChange = (change: GroupV2ChangeType, groupName?: string) => (
const renderChange = (
change: GroupV2ChangeType,
{
groupBannedMemberships,
groupMemberships,
groupName,
areWeAdmin = true,
}: {
groupMemberships?: Array<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
groupBannedMemberships?: Array<UUIDStringType>;
groupName?: string;
areWeAdmin?: boolean;
} = {}
) => (
<GroupV2Change
areWeAdmin={areWeAdmin ?? true}
blockGroupLinkRequests={action('blockGroupLinkRequests')}
change={change}
groupBannedMemberships={groupBannedMemberships}
groupMemberships={groupMemberships}
groupName={groupName}
i18n={i18n}
ourUuid={OUR_ID}
@ -1176,15 +1198,22 @@ storiesOf('Components/Conversation/GroupV2Change', module)
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
})}
Should show button:
{renderChange(
{
from: CONTACT_A,
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
},
{
groupMemberships: [{ uuid: CONTACT_C, isAdmin: false }],
groupBannedMemberships: [CONTACT_B],
}
)}
{renderChange({
from: ADMIN_A,
details: [
@ -1194,14 +1223,62 @@ storiesOf('Components/Conversation/GroupV2Change', module)
},
],
})}
{renderChange({
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
})}
Should show button:
{renderChange(
{
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
},
{
groupMemberships: [{ uuid: CONTACT_C, isAdmin: false }],
groupBannedMemberships: [CONTACT_B],
}
)}
Would show button, but we&apos;re not admin:
{renderChange(
{
from: CONTACT_A,
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
},
{ areWeAdmin: false, groupName: 'Group 1' }
)}
Would show button, but user is a group member:
{renderChange(
{
from: CONTACT_A,
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
},
{ groupMemberships: [{ uuid: CONTACT_A, isAdmin: false }] }
)}
Would show button, but user is already banned:
{renderChange(
{
from: CONTACT_A,
details: [
{
type: 'admin-approval-remove-one',
uuid: CONTACT_A,
},
],
},
{ groupBannedMemberships: [CONTACT_A] }
)}
</>
);
})
@ -1367,7 +1444,7 @@ storiesOf('Components/Conversation/GroupV2Change', module)
},
],
},
'We do hikes 🌲'
{ groupName: 'We do hikes 🌲' }
)}
{renderChange(
{
@ -1380,7 +1457,7 @@ storiesOf('Components/Conversation/GroupV2Change', module)
},
],
},
'We do hikes 🌲'
{ groupName: 'We do hikes 🌲' }
)}
{renderChange(
{
@ -1392,7 +1469,7 @@ storiesOf('Components/Conversation/GroupV2Change', module)
},
],
},
'We do hikes 🌲'
{ groupName: 'We do hikes 🌲' }
)}
</>
);

View File

@ -1,10 +1,11 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import React, { useState } from 'react';
import { get } from 'lodash';
import * as log from '../../logging/log';
import type { ReplacementValuesType } from '../../types/I18N';
import type { FullJSXType } from '../Intl';
import { Intl } from '../Intl';
@ -19,19 +20,32 @@ import type { GroupV2ChangeType, GroupV2ChangeDetailType } from '../../groups';
import type { SmartContactRendererType } from '../../groupChange';
import { renderChange } from '../../groupChange';
import { Modal } from '../Modal';
import { ConfirmationDialog } from '../ConfirmationDialog';
export type PropsDataType = {
areWeAdmin: boolean;
groupMemberships?: Array<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
groupBannedMemberships?: Array<UUIDStringType>;
groupName?: string;
ourUuid?: UUIDStringType;
change: GroupV2ChangeType;
};
export type PropsActionsType = {
blockGroupLinkRequests: (uuid: UUIDStringType) => unknown;
};
export type PropsHousekeepingType = {
i18n: LocalizerType;
renderContact: SmartContactRendererType<FullJSXType>;
};
export type PropsType = PropsDataType & PropsHousekeepingType;
export type PropsType = PropsDataType &
PropsActionsType &
PropsHousekeepingType;
function renderStringToIntl(
id: string,
@ -41,6 +55,12 @@ function renderStringToIntl(
return <Intl id={id} i18n={i18n} components={components} />;
}
enum ModalState {
None = 'None',
ViewingGroupDescription = 'ViewingGroupDescription',
ConfirmingblockGroupLinkRequests = 'ConfirmingblockGroupLinkRequests',
}
type GroupIconType =
| 'group'
| 'group-access'
@ -58,6 +78,7 @@ const changeToIconMap = new Map<string, GroupIconType>([
['access-members', 'group-access'],
['admin-approval-add-one', 'group-add'],
['admin-approval-remove-one', 'group-decline'],
['admin-approval-bounce', 'group-decline'],
['announcements-only', 'group-access'],
['avatar', 'group-avatar'],
['description', 'group-edit'],
@ -79,6 +100,7 @@ const changeToIconMap = new Map<string, GroupIconType>([
function getIcon(
detail: GroupV2ChangeDetailType,
isLastText = true,
fromId?: UUIDStringType
): GroupIconType {
const changeType = detail.type;
@ -92,52 +114,170 @@ function getIcon(
possibleIcon = 'group-approved';
}
}
// Use default icon for "... requested to join via group link" added to
// bounce notification.
if (changeType === 'admin-approval-bounce' && isLastText) {
possibleIcon = undefined;
}
return possibleIcon || 'group';
}
function GroupV2Detail({
areWeAdmin,
blockGroupLinkRequests,
detail,
i18n,
isLastText,
fromId,
onButtonClick,
groupMemberships,
groupBannedMemberships,
groupName,
i18n,
ourUuid,
renderContact,
text,
}: {
areWeAdmin: boolean;
blockGroupLinkRequests: (uuid: UUIDStringType) => unknown;
detail: GroupV2ChangeDetailType;
isLastText: boolean;
groupMemberships?: Array<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
groupBannedMemberships?: Array<UUIDStringType>;
groupName?: string;
i18n: LocalizerType;
fromId?: UUIDStringType;
onButtonClick: (x: string) => unknown;
ourUuid?: UUIDStringType;
renderContact: SmartContactRendererType<FullJSXType>;
text: FullJSXType;
}): JSX.Element {
const icon = getIcon(detail, fromId);
const icon = getIcon(detail, isLastText, fromId);
let buttonNode: ReactNode;
const newGroupDescription =
detail.type === 'description' && get(detail, 'description');
const [modalState, setModalState] = useState<ModalState>(ModalState.None);
let modalNode: ReactNode;
switch (modalState) {
case ModalState.None:
modalNode = undefined;
break;
case ModalState.ViewingGroupDescription:
if (detail.type !== 'description' || !detail.description) {
log.warn(
'GroupV2Detail: ViewingGroupDescription but missing description or wrong change type'
);
modalNode = undefined;
break;
}
modalNode = (
<Modal
hasXButton
i18n={i18n}
title={groupName}
onClose={() => setModalState(ModalState.None)}
>
<GroupDescriptionText text={detail.description} />
</Modal>
);
break;
case ModalState.ConfirmingblockGroupLinkRequests:
if (
!isLastText ||
detail.type !== 'admin-approval-bounce' ||
!detail.uuid
) {
log.warn(
'GroupV2Detail: ConfirmingblockGroupLinkRequests but missing uuid or wrong change type'
);
modalNode = undefined;
break;
}
modalNode = (
<ConfirmationDialog
title={i18n('PendingRequests--block--title')}
actions={[
{
action: () => blockGroupLinkRequests(detail.uuid),
text: i18n('PendingRequests--block--confirm'),
},
]}
i18n={i18n}
onClose={() => setModalState(ModalState.None)}
>
<Intl
id="PendingRequests--block--contents"
i18n={i18n}
components={{
name: renderContact(detail.uuid),
}}
/>
</ConfirmationDialog>
);
break;
default: {
const state: never = modalState;
log.warn(`GroupV2Detail: unexpected modal state ${state}`);
modalNode = undefined;
break;
}
}
if (detail.type === 'description' && detail.description) {
buttonNode = (
<Button
onClick={() => setModalState(ModalState.ViewingGroupDescription)}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('view')}
</Button>
);
} else if (
isLastText &&
detail.type === 'admin-approval-bounce' &&
areWeAdmin &&
detail.uuid &&
detail.uuid !== ourUuid &&
(!fromId || fromId === detail.uuid) &&
!groupMemberships?.some(item => item.uuid === detail.uuid) &&
!groupBannedMemberships?.some(uuid => uuid === detail.uuid)
) {
buttonNode = (
<Button
onClick={() =>
setModalState(ModalState.ConfirmingblockGroupLinkRequests)
}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('PendingRequests--block--button')}
</Button>
);
}
return (
<SystemMessage
icon={icon}
contents={text}
button={
newGroupDescription ? (
<Button
onClick={() => onButtonClick(newGroupDescription)}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('view')}
</Button>
) : undefined
}
/>
<>
<SystemMessage icon={icon} contents={text} button={buttonNode} />
{modalNode}
</>
);
}
export function GroupV2Change(props: PropsType): ReactElement {
const { change, groupName, i18n, ourUuid, renderContact } = props;
const [groupDescription, setGroupDescription] = useState<
string | undefined
>();
const {
areWeAdmin,
blockGroupLinkRequests,
change,
groupBannedMemberships,
groupMemberships,
groupName,
i18n,
ourUuid,
renderContact,
} = props;
return (
<>
@ -146,30 +286,27 @@ export function GroupV2Change(props: PropsType): ReactElement {
ourUuid,
renderContact,
renderString: renderStringToIntl,
}).map((text: FullJSXType, index: number) => (
<GroupV2Detail
detail={change.details[index]}
fromId={change.from}
i18n={i18n}
// Difficult to find a unique key for this type
// eslint-disable-next-line react/no-array-index-key
key={index}
onButtonClick={nextGroupDescription =>
setGroupDescription(nextGroupDescription)
}
text={text}
/>
))}
{groupDescription ? (
<Modal
hasXButton
i18n={i18n}
title={groupName}
onClose={() => setGroupDescription(undefined)}
>
<GroupDescriptionText text={groupDescription} />
</Modal>
) : null}
}).map(({ detail, isLastText, text }, index) => {
return (
<GroupV2Detail
areWeAdmin={areWeAdmin}
blockGroupLinkRequests={blockGroupLinkRequests}
detail={detail}
isLastText={isLastText}
fromId={change.from}
groupBannedMemberships={groupBannedMemberships}
groupMemberships={groupMemberships}
groupName={groupName}
i18n={i18n}
// Difficult to find a unique key for this type
// eslint-disable-next-line react/no-array-index-key
key={index}
ourUuid={ourUuid}
renderContact={renderContact}
text={text}
/>
);
})}
</>
);
}

View File

@ -337,6 +337,7 @@ const actions = () => ({
acknowledgeGroupMemberNameCollisions: action(
'acknowledgeGroupMemberNameCollisions'
),
blockGroupLinkRequests: action('blockGroupLinkRequests'),
checkForAccount: action('checkForAccount'),
clearInvitedUuidsForNewlyCreatedGroup: action(
'clearInvitedUuidsForNewlyCreatedGroup'

View File

@ -21,6 +21,7 @@ import { WidthBreakpoint } from '../_util';
import type { PropsActions as MessageActionsType } from './Message';
import type { PropsActions as UnsupportedMessageActionsType } from './UnsupportedMessage';
import type { PropsActionsType as ChatSessionRefreshedNotificationActionsType } from './ChatSessionRefreshedNotification';
import type { PropsActionsType as GroupV2ChangeActionsType } from './GroupV2Change';
import { ErrorBoundary } from './ErrorBoundary';
import type { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
import { Intl } from '../Intl';
@ -167,6 +168,7 @@ export type PropsActionsType = {
} & MessageActionsType &
SafetyNumberActionsType &
UnsupportedMessageActionsType &
GroupV2ChangeActionsType &
ChatSessionRefreshedNotificationActionsType;
export type PropsType = PropsDataType &
@ -199,6 +201,7 @@ const getActions = createSelector(
(props: PropsType): PropsActionsType => {
const unsafe = pick(props, [
'acknowledgeGroupMemberNameCollisions',
'blockGroupLinkRequests',
'clearInvitedUuidsForNewlyCreatedGroup',
'closeContactSpoofingReview',
'setIsNearBottom',

View File

@ -65,6 +65,7 @@ const getDefaultProps = () => ({
replyToMessage: action('replyToMessage'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retrySend: action('retrySend'),
blockGroupLinkRequests: action('blockGroupLinkRequests'),
deleteMessage: action('deleteMessage'),
deleteMessageForEveryone: action('deleteMessageForEveryone'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),

View File

@ -45,7 +45,10 @@ import type { PropsData as VerificationNotificationProps } from './VerificationN
import { VerificationNotification } from './VerificationNotification';
import type { PropsData as GroupNotificationProps } from './GroupNotification';
import { GroupNotification } from './GroupNotification';
import type { PropsDataType as GroupV2ChangeProps } from './GroupV2Change';
import type {
PropsDataType as GroupV2ChangeProps,
PropsActionsType as GroupV2ChangeActionsType,
} from './GroupV2Change';
import { GroupV2Change } from './GroupV2Change';
import type { PropsDataType as GroupV1MigrationProps } from './GroupV1Migration';
import { GroupV1Migration } from './GroupV1Migration';
@ -161,6 +164,7 @@ type PropsLocalType = {
type PropsActionsType = MessageActionsType &
CallingNotificationActionsType &
DeliveryIssueActionProps &
GroupV2ChangeActionsType &
PropsChatSessionRefreshedActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
@ -190,7 +194,6 @@ export class TimelineItem extends React.PureComponent<PropsType> {
theme,
nextItem,
previousItem,
renderContact,
renderUniversalTimerNotification,
returnToActiveCall,
selectMessage,
@ -294,11 +297,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
);
} else if (item.type === 'groupV2Change') {
notification = (
<GroupV2Change
renderContact={renderContact}
{...item.data}
i18n={i18n}
/>
<GroupV2Change {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV1Migration') {
notification = (

View File

@ -28,24 +28,44 @@ export type RenderOptionsType<T> = {
const AccessControlEnum = Proto.AccessControl.AccessRequired;
const RoleEnum = Proto.Member.Role;
export type RenderChangeResultType<T> = ReadonlyArray<
Readonly<{
detail: GroupV2ChangeDetailType;
text: T | string;
// Used to differentiate between the multiple texts produced by
// 'admin-approval-bounce'
isLastText: boolean;
}>
>;
export function renderChange<T>(
change: GroupV2ChangeType,
options: RenderOptionsType<T>
): Array<T | string> {
): RenderChangeResultType<T> {
const { details, from } = change;
return details.map((detail: GroupV2ChangeDetailType) =>
renderChangeDetail<T>(detail, {
return details.flatMap((detail: GroupV2ChangeDetailType) => {
const texts = renderChangeDetail<T>(detail, {
...options,
from,
})
);
});
if (!Array.isArray(texts)) {
return { detail, isLastText: true, text: texts };
}
return texts.map((text, index) => {
const isLastText = index === texts.length - 1;
return { detail, isLastText, text };
});
});
}
export function renderChangeDetail<T>(
detail: GroupV2ChangeDetailType,
options: RenderOptionsType<T>
): T | string {
): T | string | ReadonlyArray<T | string> {
const { from, i18n, ourUuid, renderContact, renderString } = options;
const fromYou = Boolean(from && ourUuid && from === ourUuid);
@ -768,6 +788,38 @@ export function renderChangeDetail<T>(
[renderContact(uuid)]
);
}
if (detail.type === 'admin-approval-bounce') {
const { uuid, times, isApprovalPending } = detail;
let firstMessage: T | string;
if (times === 1) {
firstMessage = renderString('GroupV2--admin-approval-bounce--one', i18n, {
joinerName: renderContact(uuid),
});
} else {
firstMessage = renderString('GroupV2--admin-approval-bounce', i18n, {
joinerName: renderContact(uuid),
numberOfRequests: String(times),
});
}
if (!isApprovalPending) {
return firstMessage;
}
const secondMessage = renderChangeDetail(
{
type: 'admin-approval-add-one',
uuid,
},
options
);
return [
firstMessage,
...(Array.isArray(secondMessage) ? secondMessage : [secondMessage]),
];
}
if (detail.type === 'group-link-add') {
const { privilege } = detail;

View File

@ -186,6 +186,12 @@ type GroupV2AdminApprovalRemoveOneChangeType = {
uuid: UUIDStringType;
inviter?: UUIDStringType;
};
type GroupV2AdminApprovalBounceChangeType = {
type: 'admin-approval-bounce';
times: number;
isApprovalPending: boolean;
uuid: UUIDStringType;
};
export type GroupV2DescriptionChangeType = {
type: 'description';
removed?: boolean;
@ -200,6 +206,7 @@ export type GroupV2ChangeDetailType =
| GroupV2AccessMembersChangeType
| GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType
| GroupV2AdminApprovalBounceChangeType
| GroupV2AnnouncementsOnlyChangeType
| GroupV2AvatarChangeType
| GroupV2DescriptionChangeType
@ -249,7 +256,7 @@ type MemberType = {
};
type UpdatesResultType = {
// The array of new messages to be added into the message timeline
groupChangeMessages: Array<MessageAttributesType>;
groupChangeMessages: Array<GroupChangeMessageType>;
// The set of members in the group, and we largely just pull profile keys for each,
// because the group membership is updated in newAttributes
members: Array<MemberType>;
@ -263,6 +270,33 @@ type UploadedAvatarType = {
key: string;
};
type BasicMessageType = Pick<MessageAttributesType, 'id' | 'schemaVersion'>;
type GroupV2ChangeMessageType = {
type: 'group-v2-change';
} & Pick<MessageAttributesType, 'groupV2Change' | 'sourceUuid'>;
type GroupV1MigrationMessageType = {
type: 'group-v1-migration';
} & Pick<
MessageAttributesType,
'invitedGV2Members' | 'droppedGV2MemberIds' | 'groupMigration'
>;
type TimerNotificationMessageType = {
type: 'timer-notification';
} & Pick<
MessageAttributesType,
'sourceUuid' | 'flags' | 'expirationTimerUpdate'
>;
type GroupChangeMessageType = BasicMessageType &
(
| GroupV2ChangeMessageType
| GroupV1MigrationMessageType
| TimerNotificationMessageType
);
// Constants
export const MASTER_KEY_LENGTH = 32;
@ -277,6 +311,14 @@ const SUPPORTED_CHANGE_EPOCH = 4;
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
function generateBasicMessage(): BasicMessageType {
return {
id: getGuid(),
schemaVersion: MAX_MESSAGE_SCHEMA,
// this is missing most properties to fulfill this type
};
}
// Group Links
export function generateGroupInviteLinkPassword(): Uint8Array {
@ -1138,6 +1180,47 @@ export function buildDeleteMemberChange({
return actions;
}
export function buildAddBannedMemberChange({
uuid,
group,
}: {
uuid: UUIDStringType;
group: ConversationAttributesType;
}): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions();
if (!group.secretParams) {
throw new Error(
'buildAddBannedMemberChange: group was missing secretParams!'
);
}
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const addMemberBannedAction =
new Proto.GroupChange.Actions.AddMemberBannedAction();
addMemberBannedAction.added = new Proto.MemberBanned();
addMemberBannedAction.added.userId = uuidCipherTextBuffer;
actions.addMembersBanned = [addMemberBannedAction];
if (group.pendingAdminApprovalV2?.some(item => item.uuid === uuid)) {
const deleteMemberPendingAdminApprovalAction =
new Proto.GroupChange.Actions.DeleteMemberPendingAdminApprovalAction();
deleteMemberPendingAdminApprovalAction.deletedUserId = uuidCipherTextBuffer;
actions.deleteMemberPendingAdminApprovals = [
deleteMemberPendingAdminApprovalAction,
];
}
actions.version = (group.revision || 0) + 1;
return actions;
}
export function buildModifyMemberRoleChange({
uuid,
group,
@ -1692,13 +1775,14 @@ export async function createGroupV2({
conversationId: conversation.id,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp,
timestamp,
sent_at: timestamp,
groupV2Change: {
from: ourUuid,
details: [{ type: 'create' }],
},
};
await window.Signal.Data.saveMessages([createdTheGroupMessage], {
await dataInterface.saveMessages([createdTheGroupMessage], {
forceSave: true,
ourUuid,
});
@ -2127,7 +2211,7 @@ export async function initiateMigrationToGroupV2(
throw error;
}
const groupChangeMessages: Array<MessageAttributesType> = [];
const groupChangeMessages: Array<GroupChangeMessageType> = [];
groupChangeMessages.push({
...generateBasicMessage(),
type: 'group-v1-migration',
@ -2210,7 +2294,7 @@ export async function waitThenRespondToGroupV2Migration(
export function buildMigrationBubble(
previousGroupV1MembersIds: Array<string>,
newAttributes: ConversationAttributesType
): MessageAttributesType {
): GroupChangeMessageType {
const ourUuid = window.storage.user.getCheckedUuid().toString();
const ourConversationId =
window.ConversationController.getOurConversationId();
@ -2249,7 +2333,7 @@ export function buildMigrationBubble(
};
}
export function getBasicMigrationBubble(): MessageAttributesType {
export function getBasicMigrationBubble(): GroupChangeMessageType {
return {
...generateBasicMessage(),
type: 'group-v1-migration',
@ -2322,7 +2406,7 @@ export async function joinGroupV2ViaLinkAndMigrate({
derivedGroupV2Id: undefined,
members: undefined,
};
const groupChangeMessages: Array<MessageAttributesType> = [
const groupChangeMessages: Array<GroupChangeMessageType> = [
{
...generateBasicMessage(),
type: 'group-v1-migration',
@ -2536,7 +2620,7 @@ export async function respondToGroupV2Migration({
});
// Generate notifications into the timeline
const groupChangeMessages: Array<MessageAttributesType> = [];
const groupChangeMessages: Array<GroupChangeMessageType> = [];
groupChangeMessages.push(
buildMigrationBubble(previousGroupV1MembersIds, newAttributes)
@ -2749,6 +2833,7 @@ async function updateGroup(
// Save all synthetic messages describing group changes
let syntheticSentAt = initialSentAt - (groupChangeMessages.length + 1);
const timestamp = Date.now();
const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
// We do this to preserve the order of the timeline. We only update sentAt to ensure
// that we don't stomp on messages received around the same time as the message
@ -2761,6 +2846,7 @@ async function updateGroup(
received_at: finalReceivedAt,
received_at_ms: syntheticSentAt,
sent_at: syntheticSentAt,
timestamp,
};
});
@ -2801,15 +2887,7 @@ async function updateGroup(
}
if (changeMessagesToSave.length > 0) {
await window.Signal.Data.saveMessages(changeMessagesToSave, {
forceSave: true,
ourUuid: ourUuid.toString(),
});
changeMessagesToSave.forEach(changeMessage => {
const model = new window.Whisper.Message(changeMessage);
window.MessageController.register(model.id, model);
conversation.trigger('newmessage', model);
});
await appendChangeMessages(conversation, changeMessagesToSave);
}
// We update group membership last to ensure that all notifications are in place before
@ -2827,7 +2905,210 @@ async function updateGroup(
conversation.trigger('idUpdated', conversation, 'groupId', previousId);
}
// No need for convo.updateLastMessage(), 'newmessage' handler does that
// Save these most recent updates to conversation
await updateConversation(conversation.attributes);
}
// Exported for testing
export function _mergeGroupChangeMessages(
first: MessageAttributesType | undefined,
second: MessageAttributesType
): MessageAttributesType | undefined {
if (!first) {
return undefined;
}
if (first.type !== 'group-v2-change' || second.type !== first.type) {
return undefined;
}
const { groupV2Change: firstChange } = first;
const { groupV2Change: secondChange } = second;
if (!firstChange || !secondChange) {
return undefined;
}
if (firstChange.details.length !== 1 && secondChange.details.length !== 1) {
return undefined;
}
const [firstDetail] = firstChange.details;
const [secondDetail] = secondChange.details;
let isApprovalPending: boolean;
if (secondDetail.type === 'admin-approval-add-one') {
isApprovalPending = true;
} else if (secondDetail.type === 'admin-approval-remove-one') {
isApprovalPending = false;
} else {
return undefined;
}
const { uuid } = secondDetail;
strictAssert(uuid, 'admin approval message should have uuid');
let updatedDetail;
// Member was previously added and is now removed
if (
!isApprovalPending &&
firstDetail.type === 'admin-approval-add-one' &&
firstDetail.uuid === uuid
) {
updatedDetail = {
type: 'admin-approval-bounce' as const,
uuid,
times: 1,
isApprovalPending,
};
// There is an existing bounce event - merge this one into it.
} else if (
firstDetail.type === 'admin-approval-bounce' &&
firstDetail.uuid === uuid &&
firstDetail.isApprovalPending === !isApprovalPending
) {
updatedDetail = {
type: 'admin-approval-bounce' as const,
uuid,
times: firstDetail.times + (isApprovalPending ? 0 : 1),
isApprovalPending,
};
} else {
return undefined;
}
return {
...first,
groupV2Change: {
...first.groupV2Change,
details: [updatedDetail],
},
};
}
// Exported for testing
export function _isGroupChangeMessageBounceable(
message: MessageAttributesType
): boolean {
if (message.type !== 'group-v2-change') {
return false;
}
const { groupV2Change } = message;
if (!groupV2Change) {
return false;
}
if (groupV2Change.details.length !== 1) {
return false;
}
const [first] = groupV2Change.details;
if (
first.type === 'admin-approval-add-one' ||
first.type === 'admin-approval-bounce'
) {
return true;
}
return false;
}
async function appendChangeMessages(
conversation: ConversationModel,
messages: ReadonlyArray<MessageAttributesType>
): Promise<void> {
const logId = conversation.idForLogging();
log.info(
`appendChangeMessages/${logId}: processing ${messages.length} messages`
);
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
let lastMessage = await dataInterface.getLastConversationMessage({
conversationId: conversation.id,
});
if (lastMessage && !_isGroupChangeMessageBounceable(lastMessage)) {
lastMessage = undefined;
}
const mergedMessages = [];
let previousMessage = lastMessage;
for (const message of messages) {
const merged = _mergeGroupChangeMessages(previousMessage, message);
if (!merged) {
if (previousMessage && previousMessage !== lastMessage) {
mergedMessages.push(previousMessage);
}
previousMessage = message;
continue;
}
previousMessage = merged;
log.info(
`appendChangeMessages/${logId}: merged ${message.id} into ${merged.id}`
);
}
if (previousMessage && previousMessage !== lastMessage) {
mergedMessages.push(previousMessage);
}
// Update existing message
if (lastMessage && mergedMessages[0]?.id === lastMessage?.id) {
const [first, ...rest] = mergedMessages;
strictAssert(first !== undefined, 'First message must be there');
log.info(`appendChangeMessages/${logId}: updating ${first.id}`);
await dataInterface.saveMessage(first, {
ourUuid: ourUuid.toString(),
// We don't use forceSave here because this is an update of existing
// message.
});
log.info(
`appendChangeMessages/${logId}: saving ${rest.length} new messages`
);
await dataInterface.saveMessages(rest, {
ourUuid: ourUuid.toString(),
forceSave: true,
});
} else {
log.info(
`appendChangeMessages/${logId}: saving ${mergedMessages.length} new messages`
);
await dataInterface.saveMessages(mergedMessages, {
ourUuid: ourUuid.toString(),
forceSave: true,
});
}
let newMessages = 0;
for (const changeMessage of mergedMessages) {
const existing = window.MessageController.getById(changeMessage.id);
// Update existing message
if (existing) {
strictAssert(
changeMessage.id === lastMessage?.id,
'Should only update group change that was already in the database'
);
existing.set(changeMessage);
continue;
}
const model = new window.Whisper.Message(changeMessage);
window.MessageController.register(model.id, model);
conversation.trigger('newmessage', model);
newMessages += 1;
}
// We updated the message, but didn't add new ones - refresh left pane
if (!newMessages && mergedMessages.length > 0) {
await conversation.updateLastMessage();
}
}
type GetGroupUpdatesType = Readonly<{
@ -2915,7 +3196,10 @@ async function getGroupUpdates({
);
}
if (isNumber(newRevision) && window.GV2_ENABLE_CHANGE_PROCESSING) {
if (
(!isFirstFetch || isNumber(newRevision)) &&
window.GV2_ENABLE_CHANGE_PROCESSING
) {
try {
const result = await updateGroupViaLogs({
group,
@ -3063,7 +3347,7 @@ async function updateGroupViaLogs({
newRevision,
}: {
group: ConversationAttributesType;
newRevision: number;
newRevision: number | undefined;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
@ -3081,7 +3365,9 @@ async function updateGroupViaLogs({
};
try {
log.info(
`updateGroupViaLogs/${logId}: Getting group delta from ${group.revision} to ${newRevision} for group groupv2(${group.groupId})...`
`updateGroupViaLogs/${logId}: Getting group delta from ` +
`${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` +
`groupv2(${group.groupId})...`
);
const result = await getGroupDelta(deltaOptions);
@ -3101,14 +3387,6 @@ async function updateGroupViaLogs({
}
}
function generateBasicMessage() {
return {
id: getGuid(),
schemaVersion: MAX_MESSAGE_SCHEMA,
// this is missing most properties to fulfill this type
} as MessageAttributesType;
}
async function generateLeftGroupChanges(
group: ConversationAttributesType
): Promise<UpdatesResultType> {
@ -3148,7 +3426,7 @@ async function generateLeftGroupChanges(
const isNewlyRemoved =
existingMembers.length > (newAttributes.membersV2 || []).length;
const youWereRemovedMessage: MessageAttributesType = {
const youWereRemovedMessage: GroupChangeMessageType = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
@ -3202,7 +3480,7 @@ async function getGroupDelta({
authCredentialBase64,
}: {
group: ConversationAttributesType;
newRevision: number;
newRevision: number | undefined;
serverPublicParamsBase64: string;
authCredentialBase64: string;
}): Promise<UpdatesResultType> {
@ -3225,6 +3503,7 @@ async function getGroupDelta({
});
const currentRevision = group.revision;
let latestRevision = newRevision;
const isFirstFetch = !isNumber(currentRevision);
let revisionToFetch = isNumber(currentRevision)
? currentRevision + 1
@ -3247,14 +3526,22 @@ async function getGroupDelta({
if (response.end) {
revisionToFetch = response.end + 1;
}
} while (response.end && response.end < newRevision);
if (latestRevision === undefined) {
latestRevision = response.currentRevision ?? response.end;
}
} while (
response.end &&
latestRevision !== undefined &&
response.end < latestRevision
);
// Would be nice to cache the unused groupChanges here, to reduce server roundtrips
return integrateGroupChanges({
changes,
group,
newRevision,
newRevision: latestRevision,
});
}
@ -3264,12 +3551,12 @@ async function integrateGroupChanges({
changes,
}: {
group: ConversationAttributesType;
newRevision: number;
newRevision: number | undefined;
changes: Array<Proto.IGroupChanges>;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
let attributes = group;
const finalMessages: Array<Array<MessageAttributesType>> = [];
const finalMessages: Array<Array<GroupChangeMessageType>> = [];
const finalMembers: Array<Array<MemberType>> = [];
const imax = changes.length;
@ -3361,7 +3648,7 @@ async function integrateGroupChange({
group: ConversationAttributesType;
groupChange?: Proto.IGroupChange;
groupState?: Proto.IGroup;
newRevision: number;
newRevision: number | undefined;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
if (!group.secretParams) {
@ -3396,6 +3683,7 @@ async function integrateGroupChange({
if (
groupChangeActions.version &&
newRevision !== undefined &&
groupChangeActions.version > newRevision
) {
return {
@ -3571,7 +3859,7 @@ function extractDiffs({
dropInitialJoinMessage?: boolean;
old: ConversationAttributesType;
sourceUuid?: UUIDStringType;
}): Array<MessageAttributesType> {
}): Array<GroupChangeMessageType> {
const logId = idForLogging(old.groupId);
const details: Array<GroupV2ChangeDetailType> = [];
const ourUuid = window.storage.user.getCheckedUuid().toString();
@ -3870,8 +4158,8 @@ function extractDiffs({
// final processing
let message: MessageAttributesType | undefined;
let timerNotification: MessageAttributesType | undefined;
let message: GroupChangeMessageType | undefined;
let timerNotification: GroupChangeMessageType | undefined;
const firstUpdate = !isNumber(old.revision);

View File

@ -421,7 +421,21 @@ export class ConversationModel extends window.Backbone
}
const uuid = UUID.checkedLookup(id).toString();
return window._.any(pendingMembersV2, item => item.uuid === uuid);
return pendingMembersV2.some(item => item.uuid === uuid);
}
isMemberBanned(id: string): boolean {
if (!isGroupV2(this.attributes)) {
return false;
}
const bannedMembersV2 = this.get('bannedMembersV2');
if (!bannedMembersV2 || !bannedMembersV2.length) {
return false;
}
const uuid = UUID.checkedLookup(id).toString();
return bannedMembersV2.some(item => item === uuid);
}
isMemberAwaitingApproval(id: string): boolean {
@ -1865,6 +1879,7 @@ export class ConversationModel extends window.Backbone
messageCount: this.get('messageCount') || 0,
pendingMemberships: this.getPendingMemberships(),
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
bannedMemberships: this.getBannedMemberships(),
profileKey: this.get('profileKey'),
messageRequestsEnabled,
accessControlAddFromInviteLink:
@ -2337,6 +2352,40 @@ export class ConversationModel extends window.Backbone
}
}
async addBannedMember(
uuid: UUIDStringType
): Promise<Proto.GroupChange.Actions | undefined> {
if (this.isMember(uuid)) {
log.warn('addBannedMember: Member is a part of the group!');
return;
}
if (this.isMemberPending(uuid)) {
log.warn('addBannedMember: Member is pending to be added to group!');
return;
}
if (this.isMemberBanned(uuid)) {
log.warn('addBannedMember: Member is already banned!');
return;
}
return window.Signal.Groups.buildAddBannedMemberChange({
group: this.attributes,
uuid,
});
}
async blockGroupLinkRequests(uuid: UUIDStringType): Promise<void> {
await this.modifyGroupV2({
name: 'addBannedMember',
createGroupChange: async () => this.addBannedMember(uuid),
});
}
async toggleAdmin(conversationId: string): Promise<void> {
if (!isGroupV2(this.attributes)) {
return;
@ -3495,6 +3544,14 @@ export class ConversationModel extends window.Backbone
}));
}
private getBannedMemberships(): Array<UUIDStringType> {
if (!isGroupV2(this.attributes)) {
return [];
}
return this.get('bannedMembersV2') || [];
}
getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<ConversationModel> {
@ -4069,17 +4126,17 @@ export class ConversationModel extends window.Backbone
const conversationId = this.id;
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const lastMessages = await window.Signal.Data.getLastConversationMessages({
const stats = await window.Signal.Data.getConversationMessageStats({
conversationId,
ourUuid,
});
// This runs as a job to avoid race conditions
this.queueJob('maybeSetPendingUniversalTimer', async () =>
this.maybeSetPendingUniversalTimer(lastMessages.hasUserInitiatedMessages)
this.maybeSetPendingUniversalTimer(stats.hasUserInitiatedMessages)
);
const { preview, activity } = lastMessages;
const { preview, activity } = stats;
let previewMessage: MessageModel | undefined;
let activityMessage: MessageModel | undefined;

View File

@ -51,6 +51,7 @@ import { isImage, isVideo } from '../types/Attachment';
import * as Attachment from '../types/Attachment';
import { stringToMIMEType } from '../types/MIME';
import * as MIME from '../types/MIME';
import * as GroupChange from '../groupChange';
import { ReadStatus } from '../messages/MessageReadStatus';
import type { SendStateByConversationId } from '../messages/MessageSendState';
import {
@ -486,7 +487,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
'getNotificationData: isGroupV2Change true, but no groupV2Change!'
);
const lines = window.Signal.GroupChange.renderChange<string>(change, {
const changes = GroupChange.renderChange<string>(change, {
i18n: window.i18n,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
renderContact: (conversationId: string) => {
@ -503,7 +504,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
) => window.i18n(key, components),
});
return { text: lines.join(' ') };
return { text: changes.map(({ text }) => text).join(' ') };
}
const attachments = this.get('attachments') || [];

View File

@ -56,7 +56,7 @@ import type {
IdentityKeyType,
ItemKeyType,
ItemType,
LastConversationMessagesType,
ConversationMessageStatsType,
MessageType,
MessageTypeUnhydrated,
PreKeyIdType,
@ -241,7 +241,8 @@ const dataInterface: ClientInterface = {
getNewerMessagesByConversation,
getMessageMetricsForConversation,
getConversationRangeCenteredOnMessage,
getLastConversationMessages,
getConversationMessageStats,
getLastConversationMessage,
hasGroupCallHistoryMessage,
migrateConversationMessages,
@ -1097,7 +1098,7 @@ async function saveMessage(
}
async function saveMessages(
arrayOfMessages: Array<MessageType>,
arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourUuid: UUIDStringType }
) {
await channels.saveMessages(
@ -1291,15 +1292,15 @@ async function getNewerMessagesByConversation(
return handleMessageJSON(messages);
}
async function getLastConversationMessages({
async function getConversationMessageStats({
conversationId,
ourUuid,
}: {
conversationId: string;
ourUuid: UUIDStringType;
}): Promise<LastConversationMessagesType> {
}): Promise<ConversationMessageStatsType> {
const { preview, activity, hasUserInitiatedMessages } =
await channels.getLastConversationMessages({
await channels.getConversationMessageStats({
conversationId,
ourUuid,
});
@ -1310,6 +1311,13 @@ async function getLastConversationMessages({
hasUserInitiatedMessages,
};
}
async function getLastConversationMessage({
conversationId,
}: {
conversationId: string;
}) {
return channels.getLastConversationMessage({ conversationId });
}
async function getMessageMetricsForConversation(
conversationId: string,
storyId?: UUIDStringType

View File

@ -218,7 +218,7 @@ export type UnprocessedUpdateType = {
decrypted?: string;
};
export type LastConversationMessagesType = {
export type ConversationMessageStatsType = {
activity?: MessageType;
preview?: MessageType;
hasUserInitiatedMessages: boolean;
@ -379,7 +379,7 @@ export type DataInterface = {
}
) => Promise<string>;
saveMessages: (
arrayOfMessages: Array<MessageType>,
arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourUuid: UUIDStringType }
) => Promise<void>;
removeMessage: (id: string) => Promise<void>;
@ -453,10 +453,13 @@ export type DataInterface = {
storyId?: UUIDStringType
) => Promise<ConversationMetricsType>;
// getConversationRangeCenteredOnMessage is JSON on server, full message on client
getLastConversationMessages: (options: {
getConversationMessageStats: (options: {
conversationId: string;
ourUuid: UUIDStringType;
}) => Promise<LastConversationMessagesType>;
}) => Promise<ConversationMessageStatsType>;
getLastConversationMessage(options: {
conversationId: string;
}): Promise<MessageType | undefined>;
hasGroupCallHistoryMessage: (
conversationId: string,
eraId: string

View File

@ -80,7 +80,7 @@ import type {
IdentityKeyType,
ItemKeyType,
ItemType,
LastConversationMessagesType,
ConversationMessageStatsType,
MessageMetricsType,
MessageType,
MessageTypeUnhydrated,
@ -237,7 +237,8 @@ const dataInterface: ServerInterface = {
getTotalUnreadForConversation,
getMessageMetricsForConversation,
getConversationRangeCenteredOnMessage,
getLastConversationMessages,
getConversationMessageStats,
getLastConversationMessage,
hasGroupCallHistoryMessage,
migrateConversationMessages,
@ -1912,7 +1913,7 @@ async function saveMessage(
}
async function saveMessages(
arrayOfMessages: Array<MessageType>,
arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourUuid: UUIDStringType }
): Promise<void> {
const db = getInstance();
@ -2591,13 +2592,13 @@ function getLastConversationPreview({
return jsonToObject(row.json);
}
async function getLastConversationMessages({
async function getConversationMessageStats({
conversationId,
ourUuid,
}: {
conversationId: string;
ourUuid: UUIDStringType;
}): Promise<LastConversationMessagesType> {
}): Promise<ConversationMessageStatsType> {
const db = getInstance();
return db.transaction(() => {
@ -2612,6 +2613,32 @@ async function getLastConversationMessages({
})();
}
async function getLastConversationMessage({
conversationId,
}: {
conversationId: string;
}): Promise<MessageType | undefined> {
const db = getInstance();
const row = db
.prepare<Query>(
`
SELECT * FROM messages WHERE
conversationId = $conversationId
ORDER BY received_at DESC, sent_at DESC
LIMIT 1;
`
)
.get({
conversationId,
});
if (!row) {
return undefined;
}
return jsonToObject(row.json);
}
function getOldestUnreadMessageForConversation(
conversationId: string,
storyId?: UUIDStringType

View File

@ -171,6 +171,7 @@ export type ConversationType = {
pendingApprovalMemberships?: Array<{
uuid: UUIDStringType;
}>;
bannedMemberships?: Array<UUIDStringType>;
muteExpiresAt?: number;
dontNotifyForMentionsIfMuted?: boolean;
type: ConversationTypeType;

View File

@ -858,7 +858,10 @@ function getPropsForGroupV2Change(
const conversation = getConversation(message, conversationSelector);
return {
areWeAdmin: Boolean(conversation.areWeAdmin),
groupName: conversation?.type === 'group' ? conversation?.name : undefined,
groupMemberships: conversation.memberships,
groupBannedMemberships: conversation.bannedMemberships,
ourUuid,
change,
};

View File

@ -61,6 +61,7 @@ export type TimelinePropsType = ExternalProps &
ComponentPropsType,
| 'acknowledgeGroupMemberNameCollisions'
| 'contactSupport'
| 'blockGroupLinkRequests'
| 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage'

View File

@ -0,0 +1,217 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { UUID } from '../../types/UUID';
import {
_isGroupChangeMessageBounceable,
_mergeGroupChangeMessages,
} from '../../groups';
describe('group message merging', () => {
const defaultMessage = {
id: UUID.generate().toString(),
conversationId: UUID.generate().toString(),
timestamp: Date.now(),
sent_at: Date.now(),
received_at: Date.now(),
};
const uuid = UUID.generate().toString();
describe('_isGroupChangeMessageBounceable', () => {
it('should return true for admin approval add', () => {
assert.isTrue(
_isGroupChangeMessageBounceable({
...defaultMessage,
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'admin-approval-add-one',
uuid,
},
],
},
})
);
});
it('should return true for bounce message', () => {
assert.isTrue(
_isGroupChangeMessageBounceable({
...defaultMessage,
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'admin-approval-bounce',
times: 1,
isApprovalPending: true,
uuid,
},
],
},
})
);
});
it('should return false otherwise', () => {
assert.isFalse(
_isGroupChangeMessageBounceable({
...defaultMessage,
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'admin-approval-remove-one',
uuid,
},
],
},
})
);
});
});
describe('_mergeGroupChangeMessages', () => {
const add = {
...defaultMessage,
type: 'group-v2-change' as const,
groupV2Change: {
details: [
{
type: 'admin-approval-add-one' as const,
uuid,
},
],
},
};
const remove = {
...defaultMessage,
type: 'group-v2-change' as const,
groupV2Change: {
details: [
{
type: 'admin-approval-remove-one' as const,
uuid,
},
],
},
};
const addOther = {
...defaultMessage,
type: 'group-v2-change' as const,
groupV2Change: {
details: [
{
type: 'admin-approval-add-one' as const,
uuid: UUID.generate().toString(),
},
],
},
};
const removeOther = {
...defaultMessage,
type: 'group-v2-change' as const,
groupV2Change: {
details: [
{
type: 'admin-approval-remove-one' as const,
uuid: UUID.generate().toString(),
},
],
},
};
const bounce = {
...defaultMessage,
type: 'group-v2-change' as const,
groupV2Change: {
details: [
{
type: 'admin-approval-bounce' as const,
times: 1,
isApprovalPending: false,
uuid,
},
],
},
};
const bounceAndAdd = {
...defaultMessage,
type: 'group-v2-change' as const,
groupV2Change: {
details: [
{
type: 'admin-approval-bounce' as const,
times: 1,
isApprovalPending: true,
uuid,
},
],
},
};
it('should merge add with remove if uuid matches', () => {
assert.deepStrictEqual(
_mergeGroupChangeMessages(add, remove)?.groupV2Change?.details,
[
{
isApprovalPending: false,
times: 1,
type: 'admin-approval-bounce',
uuid,
},
]
);
});
it('should not merge add with remove if uuid does not match', () => {
assert.isUndefined(_mergeGroupChangeMessages(add, removeOther));
});
it('should merge bounce with add if uuid matches', () => {
assert.deepStrictEqual(
_mergeGroupChangeMessages(bounce, add)?.groupV2Change?.details,
[
{
isApprovalPending: true,
times: 1,
type: 'admin-approval-bounce',
uuid,
},
]
);
});
it('should merge bounce and add with remove if uuid matches', () => {
assert.deepStrictEqual(
_mergeGroupChangeMessages(bounceAndAdd, remove)?.groupV2Change?.details,
[
{
isApprovalPending: false,
times: 2,
type: 'admin-approval-bounce',
uuid,
},
]
);
});
it('should not merge bounce with add if uuid does not match', () => {
assert.isUndefined(_mergeGroupChangeMessages(bounce, addOther));
});
it('should not merge bounce and add with add', () => {
assert.isUndefined(_mergeGroupChangeMessages(bounceAndAdd, add));
});
it('should not merge bounce and add with remove if uuid does not match', () => {
assert.isUndefined(_mergeGroupChangeMessages(bounceAndAdd, removeOther));
});
it('should not merge bounce with remove', () => {
assert.isUndefined(_mergeGroupChangeMessages(bounce, remove));
});
});
});

View File

@ -13,7 +13,7 @@ const {
removeAll,
_getAllMessages,
saveMessages,
getLastConversationMessages,
getConversationMessageStats,
} = dataInterface;
function getUuid(): UUIDStringType {
@ -25,7 +25,7 @@ describe('sql/conversationSummary', () => {
await removeAll();
});
describe('getLastConversationMessages', () => {
describe('getConversationMessageStats', () => {
it('returns the latest message in current conversation', async () => {
assert.lengthOf(await _getAllMessages(), 0);
@ -67,7 +67,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 3);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -176,7 +176,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 8);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -293,7 +293,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 9);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -341,7 +341,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -390,7 +390,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -432,7 +432,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -476,7 +476,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});
@ -535,7 +535,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({
const messages = await getConversationMessageStats({
conversationId,
ourUuid,
});

View File

@ -114,6 +114,7 @@ import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue';
import { RecordingState } from '../state/ducks/audioRecorder';
import { UUIDKind } from '../types/UUID';
import type { UUIDStringType } from '../types/UUID';
import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone';
type AttachmentOptions = {
@ -513,6 +514,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
): void => {
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
},
blockGroupLinkRequests: (uuid: UUIDStringType) => {
this.model.blockGroupLinkRequests(uuid);
},
contactSupport,
learnMoreAboutDeliveryIssue,
loadNewerMessages: this.model.loadNewerMessages.bind(this.model),

2
ts/window.d.ts vendored
View File

@ -112,7 +112,6 @@ import { IPCEventsType, IPCEventsValuesType } from './util/createIPCEvents';
import { ConversationView } from './views/conversation_view';
import type { SignalContextType } from './windows/context';
import { GroupV2Change } from './components/conversation/GroupV2Change';
import * as GroupChange from './groupChange';
export { Long } from 'long';
@ -389,7 +388,6 @@ declare global {
QualifiedAddress: typeof QualifiedAddress;
};
Util: typeof Util;
GroupChange: typeof GroupChange;
Components: {
AttachmentList: typeof AttachmentList;
ChatColorPicker: typeof ChatColorPicker;