diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 59968c74e..b7b67ef42 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx index 7a6d612e0..3b1767a9c 100644 --- a/ts/components/Intl.tsx +++ b/ts/components/Intl.tsx @@ -70,6 +70,11 @@ export class Intl extends React.Component { 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 | null diff --git a/ts/components/conversation/GroupV2Change.stories.tsx b/ts/components/conversation/GroupV2Change.stories.tsx index c15ecca56..d6f49fe42 100644 --- a/ts/components/conversation/GroupV2Change.stories.tsx +++ b/ts/components/conversation/GroupV2Change.stories.tsx @@ -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 = ( ); -const renderChange = (change: GroupV2ChangeType, groupName?: string) => ( +const renderChange = ( + change: GroupV2ChangeType, + { + groupBannedMemberships, + groupMemberships, + groupName, + areWeAdmin = true, + }: { + groupMemberships?: Array<{ + uuid: UUIDStringType; + isAdmin: boolean; + }>; + groupBannedMemberships?: Array; + groupName?: string; + areWeAdmin?: boolean; + } = {} +) => ( ); }) @@ -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 🌲' } )} ); diff --git a/ts/components/conversation/GroupV2Change.tsx b/ts/components/conversation/GroupV2Change.tsx index 0e2843551..6d8706342 100644 --- a/ts/components/conversation/GroupV2Change.tsx +++ b/ts/components/conversation/GroupV2Change.tsx @@ -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; groupName?: string; ourUuid?: UUIDStringType; change: GroupV2ChangeType; }; +export type PropsActionsType = { + blockGroupLinkRequests: (uuid: UUIDStringType) => unknown; +}; + export type PropsHousekeepingType = { i18n: LocalizerType; renderContact: SmartContactRendererType; }; -export type PropsType = PropsDataType & PropsHousekeepingType; +export type PropsType = PropsDataType & + PropsActionsType & + PropsHousekeepingType; function renderStringToIntl( id: string, @@ -41,6 +55,12 @@ function renderStringToIntl( return ; } +enum ModalState { + None = 'None', + ViewingGroupDescription = 'ViewingGroupDescription', + ConfirmingblockGroupLinkRequests = 'ConfirmingblockGroupLinkRequests', +} + type GroupIconType = | 'group' | 'group-access' @@ -58,6 +78,7 @@ const changeToIconMap = new Map([ ['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([ 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; + groupName?: string; i18n: LocalizerType; fromId?: UUIDStringType; - onButtonClick: (x: string) => unknown; + ourUuid?: UUIDStringType; + renderContact: SmartContactRendererType; 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.None); + let modalNode: ReactNode; + + switch (modalState) { + case ModalState.None: + modalNode = undefined; + break; + case ModalState.ViewingGroupDescription: + if (detail.type !== 'description' || !detail.description) { + log.warn( + 'GroupV2Detail: ViewingGroupDescription but missing description or wrong change type' + ); + modalNode = undefined; + break; + } + + modalNode = ( + setModalState(ModalState.None)} + > + + + ); + 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 = ( + blockGroupLinkRequests(detail.uuid), + text: i18n('PendingRequests--block--confirm'), + }, + ]} + i18n={i18n} + onClose={() => setModalState(ModalState.None)} + > + + + ); + break; + default: { + const state: never = modalState; + log.warn(`GroupV2Detail: unexpected modal state ${state}`); + modalNode = undefined; + break; + } + } + + if (detail.type === 'description' && detail.description) { + buttonNode = ( + + ); + } 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 = ( + + ); + } return ( - onButtonClick(newGroupDescription)} - size={ButtonSize.Small} - variant={ButtonVariant.SystemMessage} - > - {i18n('view')} - - ) : undefined - } - /> + <> + + {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) => ( - - setGroupDescription(nextGroupDescription) - } - text={text} - /> - ))} - {groupDescription ? ( - setGroupDescription(undefined)} - > - - - ) : null} + }).map(({ detail, isLastText, text }, index) => { + return ( + + ); + })} ); } diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index dadfad4b4..97b609738 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -337,6 +337,7 @@ const actions = () => ({ acknowledgeGroupMemberNameCollisions: action( 'acknowledgeGroupMemberNameCollisions' ), + blockGroupLinkRequests: action('blockGroupLinkRequests'), checkForAccount: action('checkForAccount'), clearInvitedUuidsForNewlyCreatedGroup: action( 'clearInvitedUuidsForNewlyCreatedGroup' diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 0bc929ce7..8d21b7129 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -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', diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index a02acd332..f19c14745 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -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'), diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 1575835fb..90189789e 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -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 { theme, nextItem, previousItem, - renderContact, renderUniversalTimerNotification, returnToActiveCall, selectMessage, @@ -294,11 +297,7 @@ export class TimelineItem extends React.PureComponent { ); } else if (item.type === 'groupV2Change') { notification = ( - + ); } else if (item.type === 'groupV1Migration') { notification = ( diff --git a/ts/groupChange.ts b/ts/groupChange.ts index bf8095a62..640b3087a 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -28,24 +28,44 @@ export type RenderOptionsType = { const AccessControlEnum = Proto.AccessControl.AccessRequired; const RoleEnum = Proto.Member.Role; +export type RenderChangeResultType = ReadonlyArray< + Readonly<{ + detail: GroupV2ChangeDetailType; + text: T | string; + + // Used to differentiate between the multiple texts produced by + // 'admin-approval-bounce' + isLastText: boolean; + }> +>; + export function renderChange( change: GroupV2ChangeType, options: RenderOptionsType -): Array { +): RenderChangeResultType { const { details, from } = change; - return details.map((detail: GroupV2ChangeDetailType) => - renderChangeDetail(detail, { + return details.flatMap((detail: GroupV2ChangeDetailType) => { + const texts = renderChangeDetail(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( detail: GroupV2ChangeDetailType, options: RenderOptionsType -): T | string { +): T | string | ReadonlyArray { const { from, i18n, ourUuid, renderContact, renderString } = options; const fromYou = Boolean(from && ourUuid && from === ourUuid); @@ -768,6 +788,38 @@ export function renderChangeDetail( [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; diff --git a/ts/groups.ts b/ts/groups.ts index c8978565d..bb0144c55 100644 --- a/ts/groups.ts +++ b/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; + groupChangeMessages: Array; // 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; @@ -263,6 +270,33 @@ type UploadedAvatarType = { key: string; }; +type BasicMessageType = Pick; + +type GroupV2ChangeMessageType = { + type: 'group-v2-change'; +} & Pick; + +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 = []; + const groupChangeMessages: Array = []; groupChangeMessages.push({ ...generateBasicMessage(), type: 'group-v1-migration', @@ -2210,7 +2294,7 @@ export async function waitThenRespondToGroupV2Migration( export function buildMigrationBubble( previousGroupV1MembersIds: Array, 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 = [ + const groupChangeMessages: Array = [ { ...generateBasicMessage(), type: 'group-v1-migration', @@ -2536,7 +2620,7 @@ export async function respondToGroupV2Migration({ }); // Generate notifications into the timeline - const groupChangeMessages: Array = []; + const groupChangeMessages: Array = []; 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 +): Promise { + 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 { 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 { @@ -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 { @@ -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; }): Promise { const logId = idForLogging(group.groupId); let attributes = group; - const finalMessages: Array> = []; + const finalMessages: Array> = []; const finalMembers: Array> = []; 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 { 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 { +}): Array { const logId = idForLogging(old.groupId); const details: Array = []; 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); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 2247e185c..c573cd055 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 { + 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 { + await this.modifyGroupV2({ + name: 'addBannedMember', + createGroupChange: async () => this.addBannedMember(uuid), + }); + } + async toggleAdmin(conversationId: string): Promise { if (!isGroupV2(this.attributes)) { return; @@ -3495,6 +3544,14 @@ export class ConversationModel extends window.Backbone })); } + private getBannedMemberships(): Array { + if (!isGroupV2(this.attributes)) { + return []; + } + + return this.get('bannedMembersV2') || []; + } + getMembers( options: { includePendingMembers?: boolean } = {} ): Array { @@ -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; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 53ca4a1b9..edc23194c 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -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 { 'getNotificationData: isGroupV2Change true, but no groupV2Change!' ); - const lines = window.Signal.GroupChange.renderChange(change, { + const changes = GroupChange.renderChange(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 { ) => window.i18n(key, components), }); - return { text: lines.join(' ') }; + return { text: changes.map(({ text }) => text).join(' ') }; } const attachments = this.get('attachments') || []; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index dfdb9820d..b1086a008 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -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, + arrayOfMessages: ReadonlyArray, 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 { +}): Promise { 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 diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 9e52ad2da..f5edcc29e 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -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; saveMessages: ( - arrayOfMessages: Array, + arrayOfMessages: ReadonlyArray, options: { forceSave?: boolean; ourUuid: UUIDStringType } ) => Promise; removeMessage: (id: string) => Promise; @@ -453,10 +453,13 @@ export type DataInterface = { storyId?: UUIDStringType ) => Promise; // getConversationRangeCenteredOnMessage is JSON on server, full message on client - getLastConversationMessages: (options: { + getConversationMessageStats: (options: { conversationId: string; ourUuid: UUIDStringType; - }) => Promise; + }) => Promise; + getLastConversationMessage(options: { + conversationId: string; + }): Promise; hasGroupCallHistoryMessage: ( conversationId: string, eraId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index fab24c65a..a85148eb8 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -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, + arrayOfMessages: ReadonlyArray, options: { forceSave?: boolean; ourUuid: UUIDStringType } ): Promise { 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 { +}): Promise { const db = getInstance(); return db.transaction(() => { @@ -2612,6 +2613,32 @@ async function getLastConversationMessages({ })(); } +async function getLastConversationMessage({ + conversationId, +}: { + conversationId: string; +}): Promise { + const db = getInstance(); + const row = db + .prepare( + ` + 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 diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 50833c753..f7073f745 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -171,6 +171,7 @@ export type ConversationType = { pendingApprovalMemberships?: Array<{ uuid: UUIDStringType; }>; + bannedMemberships?: Array; muteExpiresAt?: number; dontNotifyForMentionsIfMuted?: boolean; type: ConversationTypeType; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index e8c9ec93b..77eadd269 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -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, }; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 914591dd0..3a773e594 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -61,6 +61,7 @@ export type TimelinePropsType = ExternalProps & ComponentPropsType, | 'acknowledgeGroupMemberNameCollisions' | 'contactSupport' + | 'blockGroupLinkRequests' | 'deleteMessage' | 'deleteMessageForEveryone' | 'displayTapToViewMessage' diff --git a/ts/test-both/groups/message_merge_test.ts b/ts/test-both/groups/message_merge_test.ts new file mode 100644 index 000000000..0b29b5c6b --- /dev/null +++ b/ts/test-both/groups/message_merge_test.ts @@ -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)); + }); + }); +}); diff --git a/ts/test-electron/sql/conversationSummary_test.ts b/ts/test-electron/sql/conversationSummary_test.ts index 9341066a7..50cb92fc3 100644 --- a/ts/test-electron/sql/conversationSummary_test.ts +++ b/ts/test-electron/sql/conversationSummary_test.ts @@ -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, }); diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 567eb8ed4..1cc831d4b 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -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 { ): void => { this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions); }, + blockGroupLinkRequests: (uuid: UUIDStringType) => { + this.model.blockGroupLinkRequests(uuid); + }, contactSupport, learnMoreAboutDeliveryIssue, loadNewerMessages: this.model.loadNewerMessages.bind(this.model), diff --git a/ts/window.d.ts b/ts/window.d.ts index 481f86857..bf75f40ed 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -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;