Introduce new 'Block request' button in timeline
This commit is contained in:
parent
536dd0c7b0
commit
703bb8a3a3
22 changed files with 1088 additions and 157 deletions
|
@ -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 aren’t 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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,7 +1198,9 @@ storiesOf('Components/Conversation/GroupV2Change', module)
|
|||
},
|
||||
],
|
||||
})}
|
||||
{renderChange({
|
||||
Should show button:
|
||||
{renderChange(
|
||||
{
|
||||
from: CONTACT_A,
|
||||
details: [
|
||||
{
|
||||
|
@ -1184,7 +1208,12 @@ storiesOf('Components/Conversation/GroupV2Change', module)
|
|||
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({
|
||||
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'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 🌲' }
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
||||
return (
|
||||
<SystemMessage
|
||||
icon={icon}
|
||||
contents={text}
|
||||
button={
|
||||
newGroupDescription ? (
|
||||
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={() => onButtonClick(newGroupDescription)}
|
||||
onClick={() => setModalState(ModalState.ViewingGroupDescription)}
|
||||
size={ButtonSize.Small}
|
||||
variant={ButtonVariant.SystemMessage}
|
||||
>
|
||||
{i18n('view')}
|
||||
</Button>
|
||||
) : undefined
|
||||
);
|
||||
} 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={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) => (
|
||||
}).map(({ detail, isLastText, text }, index) => {
|
||||
return (
|
||||
<GroupV2Detail
|
||||
detail={change.details[index]}
|
||||
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}
|
||||
onButtonClick={nextGroupDescription =>
|
||||
setGroupDescription(nextGroupDescription)
|
||||
}
|
||||
ourUuid={ourUuid}
|
||||
renderContact={renderContact}
|
||||
text={text}
|
||||
/>
|
||||
))}
|
||||
{groupDescription ? (
|
||||
<Modal
|
||||
hasXButton
|
||||
i18n={i18n}
|
||||
title={groupName}
|
||||
onClose={() => setGroupDescription(undefined)}
|
||||
>
|
||||
<GroupDescriptionText text={groupDescription} />
|
||||
</Modal>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -337,6 +337,7 @@ const actions = () => ({
|
|||
acknowledgeGroupMemberNameCollisions: action(
|
||||
'acknowledgeGroupMemberNameCollisions'
|
||||
),
|
||||
blockGroupLinkRequests: action('blockGroupLinkRequests'),
|
||||
checkForAccount: action('checkForAccount'),
|
||||
clearInvitedUuidsForNewlyCreatedGroup: action(
|
||||
'clearInvitedUuidsForNewlyCreatedGroup'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
364
ts/groups.ts
364
ts/groups.ts
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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') || [];
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -171,6 +171,7 @@ export type ConversationType = {
|
|||
pendingApprovalMemberships?: Array<{
|
||||
uuid: UUIDStringType;
|
||||
}>;
|
||||
bannedMemberships?: Array<UUIDStringType>;
|
||||
muteExpiresAt?: number;
|
||||
dontNotifyForMentionsIfMuted?: boolean;
|
||||
type: ConversationTypeType;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -61,6 +61,7 @@ export type TimelinePropsType = ExternalProps &
|
|||
ComponentPropsType,
|
||||
| 'acknowledgeGroupMemberNameCollisions'
|
||||
| 'contactSupport'
|
||||
| 'blockGroupLinkRequests'
|
||||
| 'deleteMessage'
|
||||
| 'deleteMessageForEveryone'
|
||||
| 'displayTapToViewMessage'
|
||||
|
|
217
ts/test-both/groups/message_merge_test.ts
Normal file
217
ts/test-both/groups/message_merge_test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue