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": { "GroupV2--group-link-add--disabled--you": {
"message": "You turned on the group link with admin approval disabled.", "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.", "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" "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": { "AvatarInput--no-photo-label--group": {
"message": "Add a group photo", "message": "Add a group photo",
"description": "The label for the avatar uploader when no group photo is selected" "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() { public override render() {
const { components, id, i18n, renderText } = this.props; const { components, id, i18n, renderText } = this.props;
if (!id) {
log.error('Error: Intl id prop not provided');
return null;
}
const text = i18n(id); const text = i18n(id);
const results: Array< const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null string | JSX.Element | Array<string | JSX.Element> | null

View file

@ -3,10 +3,12 @@
/* eslint-disable-next-line max-classes-per-file */ /* eslint-disable-next-line max-classes-per-file */
import * as React from 'react'; import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import { UUID } from '../../types/UUID'; import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { GroupV2ChangeType } from '../../groups'; import type { GroupV2ChangeType } from '../../groups';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
@ -34,9 +36,29 @@ const renderContact: SmartContactRendererType<FullJSXType> = (
</React.Fragment> </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 <GroupV2Change
areWeAdmin={areWeAdmin ?? true}
blockGroupLinkRequests={action('blockGroupLinkRequests')}
change={change} change={change}
groupBannedMemberships={groupBannedMemberships}
groupMemberships={groupMemberships}
groupName={groupName} groupName={groupName}
i18n={i18n} i18n={i18n}
ourUuid={OUR_ID} ourUuid={OUR_ID}
@ -1176,15 +1198,22 @@ storiesOf('Components/Conversation/GroupV2Change', module)
}, },
], ],
})} })}
{renderChange({ Should show button:
from: CONTACT_A, {renderChange(
details: [ {
{ from: CONTACT_A,
type: 'admin-approval-remove-one', details: [
uuid: CONTACT_A, {
}, type: 'admin-approval-remove-one',
], uuid: CONTACT_A,
})} },
],
},
{
groupMemberships: [{ uuid: CONTACT_C, isAdmin: false }],
groupBannedMemberships: [CONTACT_B],
}
)}
{renderChange({ {renderChange({
from: ADMIN_A, from: ADMIN_A,
details: [ details: [
@ -1194,14 +1223,62 @@ storiesOf('Components/Conversation/GroupV2Change', module)
}, },
], ],
})} })}
{renderChange({ Should show button:
details: [ {renderChange(
{ {
type: 'admin-approval-remove-one', details: [
uuid: CONTACT_A, {
}, 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( {renderChange(
{ {
@ -1380,7 +1457,7 @@ storiesOf('Components/Conversation/GroupV2Change', module)
}, },
], ],
}, },
'We do hikes 🌲' { groupName: 'We do hikes 🌲' }
)} )}
{renderChange( {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 // Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react'; import type { ReactElement, ReactNode } from 'react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { get } from 'lodash'; import { get } from 'lodash';
import * as log from '../../logging/log';
import type { ReplacementValuesType } from '../../types/I18N'; import type { ReplacementValuesType } from '../../types/I18N';
import type { FullJSXType } from '../Intl'; import type { FullJSXType } from '../Intl';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
@ -19,19 +20,32 @@ import type { GroupV2ChangeType, GroupV2ChangeDetailType } from '../../groups';
import type { SmartContactRendererType } from '../../groupChange'; import type { SmartContactRendererType } from '../../groupChange';
import { renderChange } from '../../groupChange'; import { renderChange } from '../../groupChange';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { ConfirmationDialog } from '../ConfirmationDialog';
export type PropsDataType = { export type PropsDataType = {
areWeAdmin: boolean;
groupMemberships?: Array<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
groupBannedMemberships?: Array<UUIDStringType>;
groupName?: string; groupName?: string;
ourUuid?: UUIDStringType; ourUuid?: UUIDStringType;
change: GroupV2ChangeType; change: GroupV2ChangeType;
}; };
export type PropsActionsType = {
blockGroupLinkRequests: (uuid: UUIDStringType) => unknown;
};
export type PropsHousekeepingType = { export type PropsHousekeepingType = {
i18n: LocalizerType; i18n: LocalizerType;
renderContact: SmartContactRendererType<FullJSXType>; renderContact: SmartContactRendererType<FullJSXType>;
}; };
export type PropsType = PropsDataType & PropsHousekeepingType; export type PropsType = PropsDataType &
PropsActionsType &
PropsHousekeepingType;
function renderStringToIntl( function renderStringToIntl(
id: string, id: string,
@ -41,6 +55,12 @@ function renderStringToIntl(
return <Intl id={id} i18n={i18n} components={components} />; return <Intl id={id} i18n={i18n} components={components} />;
} }
enum ModalState {
None = 'None',
ViewingGroupDescription = 'ViewingGroupDescription',
ConfirmingblockGroupLinkRequests = 'ConfirmingblockGroupLinkRequests',
}
type GroupIconType = type GroupIconType =
| 'group' | 'group'
| 'group-access' | 'group-access'
@ -58,6 +78,7 @@ const changeToIconMap = new Map<string, GroupIconType>([
['access-members', 'group-access'], ['access-members', 'group-access'],
['admin-approval-add-one', 'group-add'], ['admin-approval-add-one', 'group-add'],
['admin-approval-remove-one', 'group-decline'], ['admin-approval-remove-one', 'group-decline'],
['admin-approval-bounce', 'group-decline'],
['announcements-only', 'group-access'], ['announcements-only', 'group-access'],
['avatar', 'group-avatar'], ['avatar', 'group-avatar'],
['description', 'group-edit'], ['description', 'group-edit'],
@ -79,6 +100,7 @@ const changeToIconMap = new Map<string, GroupIconType>([
function getIcon( function getIcon(
detail: GroupV2ChangeDetailType, detail: GroupV2ChangeDetailType,
isLastText = true,
fromId?: UUIDStringType fromId?: UUIDStringType
): GroupIconType { ): GroupIconType {
const changeType = detail.type; const changeType = detail.type;
@ -92,52 +114,170 @@ function getIcon(
possibleIcon = 'group-approved'; 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'; return possibleIcon || 'group';
} }
function GroupV2Detail({ function GroupV2Detail({
areWeAdmin,
blockGroupLinkRequests,
detail, detail,
i18n, isLastText,
fromId, fromId,
onButtonClick, groupMemberships,
groupBannedMemberships,
groupName,
i18n,
ourUuid,
renderContact,
text, text,
}: { }: {
areWeAdmin: boolean;
blockGroupLinkRequests: (uuid: UUIDStringType) => unknown;
detail: GroupV2ChangeDetailType; detail: GroupV2ChangeDetailType;
isLastText: boolean;
groupMemberships?: Array<{
uuid: UUIDStringType;
isAdmin: boolean;
}>;
groupBannedMemberships?: Array<UUIDStringType>;
groupName?: string;
i18n: LocalizerType; i18n: LocalizerType;
fromId?: UUIDStringType; fromId?: UUIDStringType;
onButtonClick: (x: string) => unknown; ourUuid?: UUIDStringType;
renderContact: SmartContactRendererType<FullJSXType>;
text: FullJSXType; text: FullJSXType;
}): JSX.Element { }): JSX.Element {
const icon = getIcon(detail, fromId); const icon = getIcon(detail, isLastText, fromId);
let buttonNode: ReactNode;
const newGroupDescription = const [modalState, setModalState] = useState<ModalState>(ModalState.None);
detail.type === 'description' && get(detail, 'description'); 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 ( return (
<SystemMessage <>
icon={icon} <SystemMessage icon={icon} contents={text} button={buttonNode} />
contents={text} {modalNode}
button={ </>
newGroupDescription ? (
<Button
onClick={() => onButtonClick(newGroupDescription)}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('view')}
</Button>
) : undefined
}
/>
); );
} }
export function GroupV2Change(props: PropsType): ReactElement { export function GroupV2Change(props: PropsType): ReactElement {
const { change, groupName, i18n, ourUuid, renderContact } = props; const {
areWeAdmin,
const [groupDescription, setGroupDescription] = useState< blockGroupLinkRequests,
string | undefined change,
>(); groupBannedMemberships,
groupMemberships,
groupName,
i18n,
ourUuid,
renderContact,
} = props;
return ( return (
<> <>
@ -146,30 +286,27 @@ export function GroupV2Change(props: PropsType): ReactElement {
ourUuid, ourUuid,
renderContact, renderContact,
renderString: renderStringToIntl, renderString: renderStringToIntl,
}).map((text: FullJSXType, index: number) => ( }).map(({ detail, isLastText, text }, index) => {
<GroupV2Detail return (
detail={change.details[index]} <GroupV2Detail
fromId={change.from} areWeAdmin={areWeAdmin}
i18n={i18n} blockGroupLinkRequests={blockGroupLinkRequests}
// Difficult to find a unique key for this type detail={detail}
// eslint-disable-next-line react/no-array-index-key isLastText={isLastText}
key={index} fromId={change.from}
onButtonClick={nextGroupDescription => groupBannedMemberships={groupBannedMemberships}
setGroupDescription(nextGroupDescription) groupMemberships={groupMemberships}
} groupName={groupName}
text={text} i18n={i18n}
/> // Difficult to find a unique key for this type
))} // eslint-disable-next-line react/no-array-index-key
{groupDescription ? ( key={index}
<Modal ourUuid={ourUuid}
hasXButton renderContact={renderContact}
i18n={i18n} text={text}
title={groupName} />
onClose={() => setGroupDescription(undefined)} );
> })}
<GroupDescriptionText text={groupDescription} />
</Modal>
) : null}
</> </>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -28,24 +28,44 @@ export type RenderOptionsType<T> = {
const AccessControlEnum = Proto.AccessControl.AccessRequired; const AccessControlEnum = Proto.AccessControl.AccessRequired;
const RoleEnum = Proto.Member.Role; 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>( export function renderChange<T>(
change: GroupV2ChangeType, change: GroupV2ChangeType,
options: RenderOptionsType<T> options: RenderOptionsType<T>
): Array<T | string> { ): RenderChangeResultType<T> {
const { details, from } = change; const { details, from } = change;
return details.map((detail: GroupV2ChangeDetailType) => return details.flatMap((detail: GroupV2ChangeDetailType) => {
renderChangeDetail<T>(detail, { const texts = renderChangeDetail<T>(detail, {
...options, ...options,
from, 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>( export function renderChangeDetail<T>(
detail: GroupV2ChangeDetailType, detail: GroupV2ChangeDetailType,
options: RenderOptionsType<T> options: RenderOptionsType<T>
): T | string { ): T | string | ReadonlyArray<T | string> {
const { from, i18n, ourUuid, renderContact, renderString } = options; const { from, i18n, ourUuid, renderContact, renderString } = options;
const fromYou = Boolean(from && ourUuid && from === ourUuid); const fromYou = Boolean(from && ourUuid && from === ourUuid);
@ -768,6 +788,38 @@ export function renderChangeDetail<T>(
[renderContact(uuid)] [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') { if (detail.type === 'group-link-add') {
const { privilege } = detail; const { privilege } = detail;

View file

@ -186,6 +186,12 @@ type GroupV2AdminApprovalRemoveOneChangeType = {
uuid: UUIDStringType; uuid: UUIDStringType;
inviter?: UUIDStringType; inviter?: UUIDStringType;
}; };
type GroupV2AdminApprovalBounceChangeType = {
type: 'admin-approval-bounce';
times: number;
isApprovalPending: boolean;
uuid: UUIDStringType;
};
export type GroupV2DescriptionChangeType = { export type GroupV2DescriptionChangeType = {
type: 'description'; type: 'description';
removed?: boolean; removed?: boolean;
@ -200,6 +206,7 @@ export type GroupV2ChangeDetailType =
| GroupV2AccessMembersChangeType | GroupV2AccessMembersChangeType
| GroupV2AdminApprovalAddOneChangeType | GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType | GroupV2AdminApprovalRemoveOneChangeType
| GroupV2AdminApprovalBounceChangeType
| GroupV2AnnouncementsOnlyChangeType | GroupV2AnnouncementsOnlyChangeType
| GroupV2AvatarChangeType | GroupV2AvatarChangeType
| GroupV2DescriptionChangeType | GroupV2DescriptionChangeType
@ -249,7 +256,7 @@ type MemberType = {
}; };
type UpdatesResultType = { type UpdatesResultType = {
// The array of new messages to be added into the message timeline // 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, // The set of members in the group, and we largely just pull profile keys for each,
// because the group membership is updated in newAttributes // because the group membership is updated in newAttributes
members: Array<MemberType>; members: Array<MemberType>;
@ -263,6 +270,33 @@ type UploadedAvatarType = {
key: string; 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 // Constants
export const MASTER_KEY_LENGTH = 32; export const MASTER_KEY_LENGTH = 32;
@ -277,6 +311,14 @@ const SUPPORTED_CHANGE_EPOCH = 4;
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR'; export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16; 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 // Group Links
export function generateGroupInviteLinkPassword(): Uint8Array { export function generateGroupInviteLinkPassword(): Uint8Array {
@ -1138,6 +1180,47 @@ export function buildDeleteMemberChange({
return actions; 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({ export function buildModifyMemberRoleChange({
uuid, uuid,
group, group,
@ -1692,13 +1775,14 @@ export async function createGroupV2({
conversationId: conversation.id, conversationId: conversation.id,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
timestamp,
sent_at: timestamp, sent_at: timestamp,
groupV2Change: { groupV2Change: {
from: ourUuid, from: ourUuid,
details: [{ type: 'create' }], details: [{ type: 'create' }],
}, },
}; };
await window.Signal.Data.saveMessages([createdTheGroupMessage], { await dataInterface.saveMessages([createdTheGroupMessage], {
forceSave: true, forceSave: true,
ourUuid, ourUuid,
}); });
@ -2127,7 +2211,7 @@ export async function initiateMigrationToGroupV2(
throw error; throw error;
} }
const groupChangeMessages: Array<MessageAttributesType> = []; const groupChangeMessages: Array<GroupChangeMessageType> = [];
groupChangeMessages.push({ groupChangeMessages.push({
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v1-migration', type: 'group-v1-migration',
@ -2210,7 +2294,7 @@ export async function waitThenRespondToGroupV2Migration(
export function buildMigrationBubble( export function buildMigrationBubble(
previousGroupV1MembersIds: Array<string>, previousGroupV1MembersIds: Array<string>,
newAttributes: ConversationAttributesType newAttributes: ConversationAttributesType
): MessageAttributesType { ): GroupChangeMessageType {
const ourUuid = window.storage.user.getCheckedUuid().toString(); const ourUuid = window.storage.user.getCheckedUuid().toString();
const ourConversationId = const ourConversationId =
window.ConversationController.getOurConversationId(); window.ConversationController.getOurConversationId();
@ -2249,7 +2333,7 @@ export function buildMigrationBubble(
}; };
} }
export function getBasicMigrationBubble(): MessageAttributesType { export function getBasicMigrationBubble(): GroupChangeMessageType {
return { return {
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v1-migration', type: 'group-v1-migration',
@ -2322,7 +2406,7 @@ export async function joinGroupV2ViaLinkAndMigrate({
derivedGroupV2Id: undefined, derivedGroupV2Id: undefined,
members: undefined, members: undefined,
}; };
const groupChangeMessages: Array<MessageAttributesType> = [ const groupChangeMessages: Array<GroupChangeMessageType> = [
{ {
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v1-migration', type: 'group-v1-migration',
@ -2536,7 +2620,7 @@ export async function respondToGroupV2Migration({
}); });
// Generate notifications into the timeline // Generate notifications into the timeline
const groupChangeMessages: Array<MessageAttributesType> = []; const groupChangeMessages: Array<GroupChangeMessageType> = [];
groupChangeMessages.push( groupChangeMessages.push(
buildMigrationBubble(previousGroupV1MembersIds, newAttributes) buildMigrationBubble(previousGroupV1MembersIds, newAttributes)
@ -2749,6 +2833,7 @@ async function updateGroup(
// Save all synthetic messages describing group changes // Save all synthetic messages describing group changes
let syntheticSentAt = initialSentAt - (groupChangeMessages.length + 1); let syntheticSentAt = initialSentAt - (groupChangeMessages.length + 1);
const timestamp = Date.now();
const changeMessagesToSave = groupChangeMessages.map(changeMessage => { const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
// We do this to preserve the order of the timeline. We only update sentAt to ensure // 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 // 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: finalReceivedAt,
received_at_ms: syntheticSentAt, received_at_ms: syntheticSentAt,
sent_at: syntheticSentAt, sent_at: syntheticSentAt,
timestamp,
}; };
}); });
@ -2801,15 +2887,7 @@ async function updateGroup(
} }
if (changeMessagesToSave.length > 0) { if (changeMessagesToSave.length > 0) {
await window.Signal.Data.saveMessages(changeMessagesToSave, { await appendChangeMessages(conversation, 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);
});
} }
// We update group membership last to ensure that all notifications are in place before // 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); 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<{ 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 { try {
const result = await updateGroupViaLogs({ const result = await updateGroupViaLogs({
group, group,
@ -3063,7 +3347,7 @@ async function updateGroupViaLogs({
newRevision, newRevision,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
newRevision: number; newRevision: number | undefined;
serverPublicParamsBase64: string; serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId); const logId = idForLogging(group.groupId);
@ -3081,7 +3365,9 @@ async function updateGroupViaLogs({
}; };
try { try {
log.info( 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); 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( async function generateLeftGroupChanges(
group: ConversationAttributesType group: ConversationAttributesType
): Promise<UpdatesResultType> { ): Promise<UpdatesResultType> {
@ -3148,7 +3426,7 @@ async function generateLeftGroupChanges(
const isNewlyRemoved = const isNewlyRemoved =
existingMembers.length > (newAttributes.membersV2 || []).length; existingMembers.length > (newAttributes.membersV2 || []).length;
const youWereRemovedMessage: MessageAttributesType = { const youWereRemovedMessage: GroupChangeMessageType = {
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v2-change', type: 'group-v2-change',
groupV2Change: { groupV2Change: {
@ -3202,7 +3480,7 @@ async function getGroupDelta({
authCredentialBase64, authCredentialBase64,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
newRevision: number; newRevision: number | undefined;
serverPublicParamsBase64: string; serverPublicParamsBase64: string;
authCredentialBase64: string; authCredentialBase64: string;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
@ -3225,6 +3503,7 @@ async function getGroupDelta({
}); });
const currentRevision = group.revision; const currentRevision = group.revision;
let latestRevision = newRevision;
const isFirstFetch = !isNumber(currentRevision); const isFirstFetch = !isNumber(currentRevision);
let revisionToFetch = isNumber(currentRevision) let revisionToFetch = isNumber(currentRevision)
? currentRevision + 1 ? currentRevision + 1
@ -3247,14 +3526,22 @@ async function getGroupDelta({
if (response.end) { if (response.end) {
revisionToFetch = response.end + 1; 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 // Would be nice to cache the unused groupChanges here, to reduce server roundtrips
return integrateGroupChanges({ return integrateGroupChanges({
changes, changes,
group, group,
newRevision, newRevision: latestRevision,
}); });
} }
@ -3264,12 +3551,12 @@ async function integrateGroupChanges({
changes, changes,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
newRevision: number; newRevision: number | undefined;
changes: Array<Proto.IGroupChanges>; changes: Array<Proto.IGroupChanges>;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId); const logId = idForLogging(group.groupId);
let attributes = group; let attributes = group;
const finalMessages: Array<Array<MessageAttributesType>> = []; const finalMessages: Array<Array<GroupChangeMessageType>> = [];
const finalMembers: Array<Array<MemberType>> = []; const finalMembers: Array<Array<MemberType>> = [];
const imax = changes.length; const imax = changes.length;
@ -3361,7 +3648,7 @@ async function integrateGroupChange({
group: ConversationAttributesType; group: ConversationAttributesType;
groupChange?: Proto.IGroupChange; groupChange?: Proto.IGroupChange;
groupState?: Proto.IGroup; groupState?: Proto.IGroup;
newRevision: number; newRevision: number | undefined;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId); const logId = idForLogging(group.groupId);
if (!group.secretParams) { if (!group.secretParams) {
@ -3396,6 +3683,7 @@ async function integrateGroupChange({
if ( if (
groupChangeActions.version && groupChangeActions.version &&
newRevision !== undefined &&
groupChangeActions.version > newRevision groupChangeActions.version > newRevision
) { ) {
return { return {
@ -3571,7 +3859,7 @@ function extractDiffs({
dropInitialJoinMessage?: boolean; dropInitialJoinMessage?: boolean;
old: ConversationAttributesType; old: ConversationAttributesType;
sourceUuid?: UUIDStringType; sourceUuid?: UUIDStringType;
}): Array<MessageAttributesType> { }): Array<GroupChangeMessageType> {
const logId = idForLogging(old.groupId); const logId = idForLogging(old.groupId);
const details: Array<GroupV2ChangeDetailType> = []; const details: Array<GroupV2ChangeDetailType> = [];
const ourUuid = window.storage.user.getCheckedUuid().toString(); const ourUuid = window.storage.user.getCheckedUuid().toString();
@ -3870,8 +4158,8 @@ function extractDiffs({
// final processing // final processing
let message: MessageAttributesType | undefined; let message: GroupChangeMessageType | undefined;
let timerNotification: MessageAttributesType | undefined; let timerNotification: GroupChangeMessageType | undefined;
const firstUpdate = !isNumber(old.revision); const firstUpdate = !isNumber(old.revision);

View file

@ -421,7 +421,21 @@ export class ConversationModel extends window.Backbone
} }
const uuid = UUID.checkedLookup(id).toString(); 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 { isMemberAwaitingApproval(id: string): boolean {
@ -1865,6 +1879,7 @@ export class ConversationModel extends window.Backbone
messageCount: this.get('messageCount') || 0, messageCount: this.get('messageCount') || 0,
pendingMemberships: this.getPendingMemberships(), pendingMemberships: this.getPendingMemberships(),
pendingApprovalMemberships: this.getPendingApprovalMemberships(), pendingApprovalMemberships: this.getPendingApprovalMemberships(),
bannedMemberships: this.getBannedMemberships(),
profileKey: this.get('profileKey'), profileKey: this.get('profileKey'),
messageRequestsEnabled, messageRequestsEnabled,
accessControlAddFromInviteLink: 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> { async toggleAdmin(conversationId: string): Promise<void> {
if (!isGroupV2(this.attributes)) { if (!isGroupV2(this.attributes)) {
return; 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( getMembers(
options: { includePendingMembers?: boolean } = {} options: { includePendingMembers?: boolean } = {}
): Array<ConversationModel> { ): Array<ConversationModel> {
@ -4069,17 +4126,17 @@ export class ConversationModel extends window.Backbone
const conversationId = this.id; const conversationId = this.id;
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const lastMessages = await window.Signal.Data.getLastConversationMessages({ const stats = await window.Signal.Data.getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
// This runs as a job to avoid race conditions // This runs as a job to avoid race conditions
this.queueJob('maybeSetPendingUniversalTimer', async () => 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 previewMessage: MessageModel | undefined;
let activityMessage: 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 * as Attachment from '../types/Attachment';
import { stringToMIMEType } from '../types/MIME'; import { stringToMIMEType } from '../types/MIME';
import * as MIME from '../types/MIME'; import * as MIME from '../types/MIME';
import * as GroupChange from '../groupChange';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import type { SendStateByConversationId } from '../messages/MessageSendState'; import type { SendStateByConversationId } from '../messages/MessageSendState';
import { import {
@ -486,7 +487,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
'getNotificationData: isGroupV2Change true, but no groupV2Change!' 'getNotificationData: isGroupV2Change true, but no groupV2Change!'
); );
const lines = window.Signal.GroupChange.renderChange<string>(change, { const changes = GroupChange.renderChange<string>(change, {
i18n: window.i18n, i18n: window.i18n,
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
renderContact: (conversationId: string) => { renderContact: (conversationId: string) => {
@ -503,7 +504,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
) => window.i18n(key, components), ) => window.i18n(key, components),
}); });
return { text: lines.join(' ') }; return { text: changes.map(({ text }) => text).join(' ') };
} }
const attachments = this.get('attachments') || []; const attachments = this.get('attachments') || [];

View file

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

View file

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

View file

@ -80,7 +80,7 @@ import type {
IdentityKeyType, IdentityKeyType,
ItemKeyType, ItemKeyType,
ItemType, ItemType,
LastConversationMessagesType, ConversationMessageStatsType,
MessageMetricsType, MessageMetricsType,
MessageType, MessageType,
MessageTypeUnhydrated, MessageTypeUnhydrated,
@ -237,7 +237,8 @@ const dataInterface: ServerInterface = {
getTotalUnreadForConversation, getTotalUnreadForConversation,
getMessageMetricsForConversation, getMessageMetricsForConversation,
getConversationRangeCenteredOnMessage, getConversationRangeCenteredOnMessage,
getLastConversationMessages, getConversationMessageStats,
getLastConversationMessage,
hasGroupCallHistoryMessage, hasGroupCallHistoryMessage,
migrateConversationMessages, migrateConversationMessages,
@ -1912,7 +1913,7 @@ async function saveMessage(
} }
async function saveMessages( async function saveMessages(
arrayOfMessages: Array<MessageType>, arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourUuid: UUIDStringType } options: { forceSave?: boolean; ourUuid: UUIDStringType }
): Promise<void> { ): Promise<void> {
const db = getInstance(); const db = getInstance();
@ -2591,13 +2592,13 @@ function getLastConversationPreview({
return jsonToObject(row.json); return jsonToObject(row.json);
} }
async function getLastConversationMessages({ async function getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}: { }: {
conversationId: string; conversationId: string;
ourUuid: UUIDStringType; ourUuid: UUIDStringType;
}): Promise<LastConversationMessagesType> { }): Promise<ConversationMessageStatsType> {
const db = getInstance(); const db = getInstance();
return db.transaction(() => { 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( function getOldestUnreadMessageForConversation(
conversationId: string, conversationId: string,
storyId?: UUIDStringType storyId?: UUIDStringType

View file

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

View file

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

View file

@ -61,6 +61,7 @@ export type TimelinePropsType = ExternalProps &
ComponentPropsType, ComponentPropsType,
| 'acknowledgeGroupMemberNameCollisions' | 'acknowledgeGroupMemberNameCollisions'
| 'contactSupport' | 'contactSupport'
| 'blockGroupLinkRequests'
| 'deleteMessage' | 'deleteMessage'
| 'deleteMessageForEveryone' | 'deleteMessageForEveryone'
| 'displayTapToViewMessage' | '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, removeAll,
_getAllMessages, _getAllMessages,
saveMessages, saveMessages,
getLastConversationMessages, getConversationMessageStats,
} = dataInterface; } = dataInterface;
function getUuid(): UUIDStringType { function getUuid(): UUIDStringType {
@ -25,7 +25,7 @@ describe('sql/conversationSummary', () => {
await removeAll(); await removeAll();
}); });
describe('getLastConversationMessages', () => { describe('getConversationMessageStats', () => {
it('returns the latest message in current conversation', async () => { it('returns the latest message in current conversation', async () => {
assert.lengthOf(await _getAllMessages(), 0); assert.lengthOf(await _getAllMessages(), 0);
@ -67,7 +67,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -176,7 +176,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 8); assert.lengthOf(await _getAllMessages(), 8);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -293,7 +293,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 9); assert.lengthOf(await _getAllMessages(), 9);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -341,7 +341,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2); assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -390,7 +390,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2); assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -432,7 +432,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2); assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -476,7 +476,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2); assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });
@ -535,7 +535,7 @@ describe('sql/conversationSummary', () => {
assert.lengthOf(await _getAllMessages(), 2); assert.lengthOf(await _getAllMessages(), 2);
const messages = await getLastConversationMessages({ const messages = await getConversationMessageStats({
conversationId, conversationId,
ourUuid, ourUuid,
}); });

View file

@ -114,6 +114,7 @@ import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue';
import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue'; import { viewedReceiptsJobQueue } from '../jobs/viewedReceiptsJobQueue';
import { RecordingState } from '../state/ducks/audioRecorder'; import { RecordingState } from '../state/ducks/audioRecorder';
import { UUIDKind } from '../types/UUID'; import { UUIDKind } from '../types/UUID';
import type { UUIDStringType } from '../types/UUID';
import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone'; import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone';
type AttachmentOptions = { type AttachmentOptions = {
@ -513,6 +514,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
): void => { ): void => {
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions); this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
}, },
blockGroupLinkRequests: (uuid: UUIDStringType) => {
this.model.blockGroupLinkRequests(uuid);
},
contactSupport, contactSupport,
learnMoreAboutDeliveryIssue, learnMoreAboutDeliveryIssue,
loadNewerMessages: this.model.loadNewerMessages.bind(this.model), 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 { ConversationView } from './views/conversation_view';
import type { SignalContextType } from './windows/context'; import type { SignalContextType } from './windows/context';
import { GroupV2Change } from './components/conversation/GroupV2Change'; import { GroupV2Change } from './components/conversation/GroupV2Change';
import * as GroupChange from './groupChange';
export { Long } from 'long'; export { Long } from 'long';
@ -389,7 +388,6 @@ declare global {
QualifiedAddress: typeof QualifiedAddress; QualifiedAddress: typeof QualifiedAddress;
}; };
Util: typeof Util; Util: typeof Util;
GroupChange: typeof GroupChange;
Components: { Components: {
AttachmentList: typeof AttachmentList; AttachmentList: typeof AttachmentList;
ChatColorPicker: typeof ChatColorPicker; ChatColorPicker: typeof ChatColorPicker;